This commit is contained in:
Linrador 2025-08-10 23:51:46 +02:00
parent 903d898a0a
commit 55a12c1f68
38 changed files with 2569 additions and 1316 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -1,48 +1,99 @@
// src/app/(admin)/admin/teams/[teamId]/TeamAdminClient.tsx
'use client' 'use client'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState, useRef } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import LoadingSpinner from '@/app/components/LoadingSpinner' import LoadingSpinner from '@/app/components/LoadingSpinner'
import TeamMemberView from '@/app/components/TeamMemberView' import TeamMemberView from '@/app/components/TeamMemberView'
import { useTeamStore } from '@/app/lib/stores' import { useTeamStore } from '@/app/lib/stores'
import { reloadTeam } from '@/app/lib/sse-actions' import { reloadTeam } from '@/app/lib/sse-actions'
import type { Player } from '@/app/types/team'
export default function TeamAdminClient({ teamId }: { teamId: string }) { type Props = { teamId: string }
const [loading, setLoading] = useState(true)
const { data: session } = useSession() export default function TeamAdminClient({ teamId }: Props) {
const { team, setTeam } = useTeamStore() const { team, setTeam } = useTeamStore()
const { data: session } = useSession()
const currentUserSteamId = session?.user?.steamId || ''
const [loading, setLoading] = useState(true)
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
useEffect(() => {
const fetch = async () => { const fetchTeam = useCallback(async () => {
const result = await reloadTeam(teamId) const result = await reloadTeam(teamId)
console.log('[TeamAdminClient] reloadTeam returned:', result) if (result) setTeam(result)
if (result) {
setTeam(result)
}
setLoading(false) setLoading(false)
}
if (teamId) fetch()
}, [teamId, setTeam]) }, [teamId, setTeam])
if (loading || !team) { useEffect(() => {
return <LoadingSpinner /> if (teamId) fetchTeam()
}, [teamId, fetchTeam])
// 👇 WICHTIG: subscribe by steamId (passt zu deinem SSE-Server)
useEffect(() => {
const steamId = session?.user?.steamId
if (!steamId) return
// ggf. .env nutzen: z. B. NEXT_PUBLIC_SSE_URL=http://localhost:3001
const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001'
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`
let es: EventSource | null = new EventSource(url, { withCredentials: false })
const onTeamUpdated = (ev: MessageEvent) => {
try {
const msg = JSON.parse(ev.data)
if (msg.teamId === teamId) {
fetchTeam()
} }
} catch (e) {
console.error('[SSE] parse error:', e)
}
}
es.addEventListener('team-updated', onTeamUpdated)
es.onerror = () => {
// sanftes Reconnect
es?.close()
es = null
setTimeout(() => {
// neuer EventSource
const next = new EventSource(url, { withCredentials: false })
next.addEventListener('team-updated', onTeamUpdated)
next.onerror = () => { next.close() }
es = next
}, 2000)
}
return () => {
es?.removeEventListener('team-updated', onTeamUpdated as any)
es?.close()
}
}, [session?.user?.steamId, teamId, fetchTeam])
if (loading || !team) return <LoadingSpinner />
return ( return (
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<TeamMemberView <TeamMemberView
team={team} key={
currentUserSteamId={session?.user?.steamId ?? ''} team
adminMode={true} ? `${team.id}|A:${team.activePlayers.map(p=>p.steamId).join(',')}|I:${team.inactivePlayers.map(p=>p.steamId).join(',')}|V:${team.invitedPlayers.map(p=>p.steamId).join(',')}`
activeDragItem={null} : 'no-team'
isDragging={false} }
showLeaveModal={false} currentUserSteamId={currentUserSteamId}
showInviteModal={false} adminMode
setShowLeaveModal={() => {}} activeDragItem={activeDragItem}
setShowInviteModal={() => {}} isDragging={isDragging}
setActiveDragItem={() => {}} showLeaveModal={showLeaveModal}
setIsDragging={() => {}} showInviteModal={showInviteModal}
setShowLeaveModal={setShowLeaveModal}
setShowInviteModal={setShowInviteModal}
setActiveDragItem={setActiveDragItem}
setIsDragging={setIsDragging}
/> />
</div> </div>
) )

View File

@ -1,20 +1,148 @@
// /api/notifications/mark-all-read/route.ts // app/api/notifications/mark-all-read/route.ts
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions(req)) const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!session?.user?.steamId) { if (!steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
} }
// 1) Unread-Notifications mit Handlungsbedarf einsammeln
const actionable = await prisma.notification.findMany({
where: {
steamId,
read: false,
actionType: { in: ['team-invite', 'team-join-request', 'invitation'] },
actionData: { not: null },
},
select: { id: true, actionType: true, actionData: true },
})
// Kandidaten-IDs extrahieren
const inviteIds = actionable
.map(n => n.actionData!)
.filter((x): x is string => typeof x === 'string' && x.length > 0)
if (inviteIds.length) {
// 2) Passende TeamInvite-Objekte holen
const invites = await prisma.teamInvite.findMany({
where: { id: { in: inviteIds } },
select: { id: true, type: true, teamId: true, steamId: true },
})
const byId = new Map(invites.map(i => [i.id, i]))
// 3) Für jede actionable Notification ablehnen (idempotent & silent)
await Promise.allSettled(
actionable.map(async n => {
const inv = byId.get(n.actionData!)
if (!inv) {
// Stale → nur die Notification aufräumen
await prisma.notification.updateMany({ await prisma.notification.updateMany({
where: { steamId: session.user.steamId, read: false }, where: { actionData: n.actionData! },
data: { read: true, actionType: null, actionData: null },
})
return
}
// Typen unterscheiden:
// - team-invite / invitation: Einladung an MICH -> darf ich ablehnen
// - team-join-request: Anfrage an den Leader -> darf ich ablehnen, wenn ich Leader bin
if (
(n.actionType === 'team-invite' || n.actionType === 'invitation' || inv.type === 'team-invite')
) {
// Einladung an mich? Nur dann löschen
if (inv.steamId !== steamId) return
await prisma.teamInvite.delete({ where: { id: inv.id } })
await prisma.notification.updateMany({
where: { actionData: inv.id },
data: { read: true, actionType: null, actionData: null },
})
// stilles UI-Update für (Leader + Mitglieder), Eingeladene ist noch kein Mitglied
if (inv.teamId) {
const team = await prisma.team.findUnique({
where: { id: inv.teamId },
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
})
const targetUserIds = Array.from(
new Set(
[
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
].filter(Boolean) as string[],
),
)
if (targetUserIds.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId: inv.teamId,
targetUserIds,
})
}
}
return
}
if (n.actionType === 'team-join-request' || inv.type === 'team-join-request') {
// nur der Leader darf eine Beitrittsanfrage ablehnen
if (!inv.teamId) return
const team = await prisma.team.findUnique({
where: { id: inv.teamId },
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
})
if (!team || team.leaderId !== steamId) return
await prisma.teamInvite.delete({ where: { id: inv.id } })
await prisma.notification.updateMany({
where: { actionData: inv.id },
data: { read: true, actionType: null, actionData: null },
})
// stilles UI-Update an Leader + Mitglieder (Requester ist noch kein Mitglied)
const targetUserIds = Array.from(
new Set(
[
team.leaderId,
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
].filter(Boolean) as string[],
),
)
if (targetUserIds.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId: inv.teamId,
targetUserIds,
})
}
return
}
}),
)
}
// 4) Danach ALLE ungelesenen Nachrichten als gelesen markieren (auch die ohne Actions)
const result = await prisma.notification.updateMany({
where: { steamId, read: false },
data: { read: true }, data: { read: true },
}) })
return NextResponse.json({ message: 'Alle Notifications als gelesen markiert' }) return NextResponse.json(
{ success: true, updated: result.count },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (error) {
console.error('[Notification] mark-all-read:', error)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
} }

View File

@ -1,39 +1,32 @@
// app/api/notifications/mark-read/[id]/route.ts // app/api/notifications/mark-read/[id]/route.ts
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { NextResponse } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import type { NextRequest } from 'next/server'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) { export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions(req)) const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!session?.user?.steamId) { if (!steamId) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
} }
const param = await params
const notificationId = param.id
try {
const notification = await prisma.notification.findUnique({ const notification = await prisma.notification.findUnique({
where: { id: notificationId }, where: { id: params.id },
}) })
if (!notification || notification.steamId !== steamId) {
if (!notification || notification.steamId !== session.user.steamId) {
return NextResponse.json({ error: 'Nicht gefunden oder nicht erlaubt' }, { status: 403 }) return NextResponse.json({ error: 'Nicht gefunden oder nicht erlaubt' }, { status: 403 })
} }
await prisma.notification.update({ await prisma.notification.update({
where: { id: notificationId }, where: { id: params.id },
data: { read: true }, data: { read: true },
}) })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error('[Notification] Fehler beim Markieren:', error) console.error('[Notification] mark-one-read:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
} }
} }

View File

@ -3,6 +3,10 @@ import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import type { Player } from '@/app/types/team' import type { Player } from '@/app/types/team'
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
{ params }: { params: { teamId: string } }, { params }: { params: { teamId: string } },
@ -87,7 +91,11 @@ export async function GET(
invitedPlayers, invitedPlayers,
} }
return NextResponse.json(result) return NextResponse.json(result, {
headers: {
'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate',
},
})
} catch (error) { } catch (error) {
console.error('GET /api/team/[teamId] failed:', error) console.error('GET /api/team/[teamId] failed:', error)
return NextResponse.json( return NextResponse.json(

View File

@ -37,10 +37,15 @@ export async function POST(req: NextRequest) {
] ]
/* ▸ SSE-Push --------------------------------------------------------------- */ /* ▸ SSE-Push --------------------------------------------------------------- */
await sendServerSSEMessage({
type : 'team-member-joined',
teamId,
users : steamIds,
})
await sendServerSSEMessage({ await sendServerSSEMessage({
type : 'team-updated', type : 'team-updated',
teamId : team.id, teamId,
targetUserIds : allPlayers,
}) })
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })

View File

@ -1,21 +1,20 @@
// /pages/api/team/change-logo.ts // /pages/api/team/change-logo.ts
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end() if (req.method !== 'POST') return res.status(405).end()
const { teamId, logoUrl } = req.body const { teamId, logoUrl } = req.body
if (!teamId || !logoUrl) return res.status(400).json({ error: 'Team-ID oder Logo-URL fehlt' })
if (!teamId || !logoUrl) {
return res.status(400).json({ error: 'Team-ID oder Logo-URL fehlt' })
}
try { try {
await prisma.team.update({ await prisma.team.update({ where: { id: teamId }, data: { logo: logoUrl } })
where: { id: teamId },
data: { logo: logoUrl }, // 🔔 spezifisch
}) await sendServerSSEMessage({ type: 'team-logo-updated', teamId })
// 🔔 generisch
await sendServerSSEMessage({ type: 'team-updated', teamId })
return res.status(200).json({ success: true }) return res.status(200).json({ success: true })
} catch (err) { } catch (err) {

View File

@ -1,3 +1,4 @@
// src/app/api/team/kick/route.ts
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
@ -7,90 +8,135 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
/* ───────── 1) Payload prüfen ───────── */ /* 1) Payload prüfen */
const { teamId, steamId } = await req.json() const { teamId, steamId } = await req.json()
if (!teamId || !steamId) { if (!teamId || !steamId) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 }) return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
} }
/* ───────── 2) Team + User laden ─────── */ /* 2) Team + User laden */
const team = await prisma.team.findUnique({ where: { id: teamId } }) const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 }) if (!team) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where : { steamId }, where : { steamId },
select: { name: true }, select: { name: true, teamId: true },
}) })
const userName = user?.name ?? 'Ein Mitglied' const userName = user?.name ?? 'Ein Mitglied'
const teamName = team.name ?? 'Unbekanntes Team' const teamName = team.name ?? 'Unbekanntes Team'
/* ───────── 3) Spieler aus Team-Arrays entfernen ───────── */ // Mitglied vor Kick?
const active = team.activePlayers.filter(id => id !== steamId) const wasMember =
const inactive = team.inactivePlayers.filter(id => id !== steamId) team.activePlayers.includes(steamId) || team.inactivePlayers.includes(steamId)
await prisma.team.update({ /* 3) Änderungen atomar durchführen */
const { active, inactive } = await prisma.$transaction(async (tx) => {
const nextActive = team.activePlayers.filter(id => id !== steamId)
const nextInactive = team.inactivePlayers.filter(id => id !== steamId)
// Team-Arrays aktualisieren
await tx.team.update({
where: { id: teamId }, where: { id: teamId },
data : { data : {
activePlayers : { set: active }, activePlayers : { set: nextActive },
inactivePlayers : { set: inactive }, inactivePlayers : { set: nextInactive },
}, },
}) })
/* ───────── 4) User vom Team lösen ───────── */ // User->Team trennen (idempotent)
await prisma.user.update({ where: { steamId }, data: { teamId: null } }) await tx.user.update({
where: { steamId },
data : { teamId: null },
})
/* ───────── 5) Spieler aus offenen Matches werfen ───────── */ // Offene Team-Invites für diesen User aufräumen
await tx.teamInvite.deleteMany({
where: { teamId, steamId },
})
return { active: nextActive, inactive: nextInactive }
})
/* 4) Spieler aus offenen Matches entfernen */
if (wasMember) {
await removePlayerFromMatches(teamId, steamId) await removePlayerFromMatches(teamId, steamId)
}
/* ───────── 6) Notifications & SSE ───────── */ /* 5) Notifications & SSE */
// Zielgruppen bestimmen
const allMembersAfter = Array.from(new Set(
[team.leaderId, ...active, ...inactive].filter(Boolean) as string[]
))
const remaining = allMembersAfter.filter(id => id !== steamId)
/* an gekickten User */ // a) an den Gekickten: sichtbare Notification + Self-Event (UI räumen)
const kickedN = await prisma.notification.create({ const kickedN = await prisma.notification.create({
data: { data: {
user : { connect: { steamId } }, user : { connect: { steamId } },
title : 'Team verlassen', title : 'Team verlassen',
message : `Du wurdest aus dem Team „${teamName}“ geworfen.`, message : `Du wurdest aus dem Team „${teamName}” entfernt.`,
actionType : 'team-kick', actionType : 'team-kick-self',
}, },
}) })
// sichtbare Notification (NotificationCenter hört auf type: 'notification')
await sendServerSSEMessage({ await sendServerSSEMessage({
type : kickedN.actionType ?? 'notification', type: 'notification',
targetUserIds: [steamId], targetUserIds: [steamId],
id : kickedN.id,
message: kickedN.message, message: kickedN.message,
id: kickedN.id,
actionType: kickedN.actionType ?? undefined,
createdAt: kickedN.createdAt.toISOString(), createdAt: kickedN.createdAt.toISOString(),
}) })
// Self-Event für Store-Reset/Redirect
await sendServerSSEMessage({
type: 'team-kick-self',
teamId,
targetUserIds: [steamId],
})
/* an verbleibende Mitglieder */ // b) an verbleibende Mitglieder: sichtbare Info in Echtzeit
const remaining = [...active, ...inactive] if (remaining.length) {
await Promise.all( // DB-Notifications erzeugen
remaining.map(async uid => { const created = await Promise.all(
const n = await prisma.notification.create({ remaining.map(uid =>
prisma.notification.create({
data: { data: {
user : { connect: { steamId: uid } }, user : { connect: { steamId: uid } },
title : 'Team-Update', title : 'Team-Update',
message : `${userName} wurde aus dem Team „${teamName}“ gekickt.`, message : `${userName} wurde aus dem Team „${teamName}” entfernt.`,
actionType : 'team-kick-other', actionType : 'team-member-kicked',
}, },
}) })
await sendServerSSEMessage({ )
type : n.actionType ?? 'notification', )
targetUserIds: [uid], // sofort zustellen (type: 'notification' → NotificationCenter zeigt Toast)
id : n.id, await Promise.all(
created.map(n =>
sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message, message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
createdAt: n.createdAt.toISOString(), createdAt: n.createdAt.toISOString(),
}) })
}),
) )
)
}
/* ► UI neu laden lassen */ // c) Ein einziges team-updated an ALLE verbleibenden (inkl. Leader) für UI-Refresh
if (allMembersAfter.length) {
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
teamId, teamId,
targetUserIds : remaining, targetUserIds: allMembersAfter,
}) })
}
return NextResponse.json({ message: 'Mitglied entfernt' }) return NextResponse.json(
{ message: 'Mitglied entfernt' },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) { } catch (err) {
console.error('[KICK] Fehler:', err) console.error('[KICK] Fehler:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })

View File

@ -1,6 +1,6 @@
// src/app/api/team/leave/route.ts
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { removePlayerFromTeam } from '@/app/lib/removePlayerFromTeam'
import { removePlayerFromMatches } from '@/app/lib/removePlayerFromMatches' import { removePlayerFromMatches } from '@/app/lib/removePlayerFromMatches'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Steam-ID fehlt' }, { status: 400 }) return NextResponse.json({ message: 'Steam-ID fehlt' }, { status: 400 })
} }
/* ───────── 1) Team ermitteln ───────── */ /* 1) Team ermitteln, in dem der User aktuell ist (inkl. Leader) */
const team = await prisma.team.findFirst({ const team = await prisma.team.findFirst({
where: { where: {
OR: [ OR: [
@ -21,80 +21,130 @@ export async function POST(req: NextRequest) {
{ inactivePlayers: { has: steamId } }, { inactivePlayers: { has: steamId } },
], ],
}, },
}) select: {
if (!team) return NextResponse.json({ message: 'Kein Team gefunden' }, { status: 404 }) id: true,
name: true,
const { activePlayers, inactivePlayers, leader } = removePlayerFromTeam( leaderId: true,
{ activePlayers: team.activePlayers, inactivePlayers: team.inactivePlayers, leader: team.leaderId }, activePlayers: true,
steamId, inactivePlayers: true,
)
/* ───────── 2) Team anpassen / löschen ───────── */
if (!leader) {
await prisma.team.delete({ where: { id: team.id } })
} else {
await prisma.team.update({
where: { id: team.id },
data : {
leader: { connect: { steamId: leader } },
activePlayers,
inactivePlayers,
}, },
}) })
if (!team) {
return NextResponse.json(
{ message: 'Kein Team gefunden' },
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
)
} }
/* ───────── 3) User lösen ───────── */ const user = await prisma.user.findUnique({
await prisma.user.update({ where: { steamId }, data: { teamId: null } }) where: { steamId },
select: { name: true },
/* ───────── 4) Spieler aus Matches entfernen ───────── */ })
await removePlayerFromMatches(team.id, steamId)
/* ───────── 5) Notifications ───────── */
const user = await prisma.user.findUnique({ where: { steamId }, select: { name: true } })
const userName = user?.name ?? 'Ein Spieler' const userName = user?.name ?? 'Ein Spieler'
const teamName = team.name ?? 'Dein Team' const teamName = team.name ?? 'Dein Team'
const remaining = [...activePlayers, ...inactivePlayers].filter(id => id !== steamId)
/* an leavenden User */ /* 2) Atomar: User aus Team entfernen, User.teamId null, Invites aufräumen */
const leaveN = await prisma.notification.create({ const txResult = await prisma.$transaction(async (tx) => {
const nextActive = team.activePlayers.filter((id) => id !== steamId)
const nextInactive = team.inactivePlayers.filter((id) => id !== steamId)
// Team-Listen aktualisieren (keine automatische Leader-Änderung)
await tx.team.update({
where: { id: team.id },
data: { data: {
user : { connect: { steamId } }, activePlayers: { set: nextActive },
title : 'Teamupdate', inactivePlayers: { set: nextInactive },
message : `Du hast das Team „${teamName}“ verlassen.`,
actionType : 'team-left',
}, },
}) })
await sendServerSSEMessage({
type : leaveN.actionType ?? 'notification', // User vom Team lösen (idempotent)
targetUserIds: [steamId], await tx.user.update({
id : leaveN.id, where: { steamId },
message : leaveN.message, data: { teamId: null },
createdAt : leaveN.createdAt.toISOString(),
}) })
/* an verbleibende Mitglieder */ // Eventuelle Einladungen zu diesem Team für den User aufräumen
await Promise.all( await tx.teamInvite.deleteMany({
remaining.map(async uid => { where: { teamId: team.id, steamId },
const n = await prisma.notification.create({ })
return { nextActive, nextInactive }
})
/* 3) Spieler aus offenen Matches entfernen */
await removePlayerFromMatches(team.id, steamId)
/* 4) Notifications & SSE in Echtzeit */
// a) an den Leaver: sichtbare Notification + Self-Event (UI räumen)
const leaveN = await prisma.notification.create({
data: { data: {
user : { connect: { steamId: uid } }, steamId,
title: 'Teamupdate',
message: `Du hast das Team „${teamName}“ verlassen.`,
actionType: 'team-left-self',
actionData: team.id,
},
})
// sichtbare Toast/Dropdown-Notification
await sendServerSSEMessage({
type: 'notification',
targetUserIds: [steamId],
message: leaveN.message,
id: leaveN.id,
actionType: leaveN.actionType ?? undefined,
actionData: leaveN.actionData ?? undefined,
createdAt: leaveN.createdAt.toISOString(),
})
// Self-Event für Store-Reset/Redirect
await sendServerSSEMessage({
type: 'team-left-self',
teamId: team.id,
targetUserIds: [steamId],
})
// b) an die Verbleibenden (inkl. Leader): sichtbare Info in Echtzeit
const remaining = Array.from(
new Set(
[
team.leaderId,
...(txResult.nextActive ?? []),
...(txResult.nextInactive ?? []),
].filter(Boolean) as string[],
),
).filter((id) => id !== steamId)
if (remaining.length) {
const created = await Promise.all(
remaining.map((uid) =>
prisma.notification.create({
data: {
steamId: uid,
title: 'Teamupdate', title: 'Teamupdate',
message: `${userName} hat das Team verlassen.`, message: `${userName} hat das Team verlassen.`,
actionType: 'team-member-left', actionType: 'team-member-left',
actionData: steamId,
}, },
})
await sendServerSSEMessage({
type : n.actionType ?? 'notification',
targetUserIds: [uid],
id : n.id,
message : n.message,
createdAt : n.createdAt.toISOString(),
})
}), }),
),
) )
/* ► UI neu laden lassen */ // sofort zustellen (sichtbar im NotificationCenter)
if (remaining.length) { await Promise.all(
created.map((n) =>
sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
actionData: n.actionData ?? undefined,
createdAt: n.createdAt.toISOString(),
}),
),
)
// zentrales UI-Refresh-Signal
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
teamId: team.id, teamId: team.id,
@ -102,7 +152,10 @@ export async function POST(req: NextRequest) {
}) })
} }
return NextResponse.json({ message: 'Erfolgreich aus dem Team entfernt' }) return NextResponse.json(
{ message: 'Erfolgreich aus dem Team ausgetreten' },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) { } catch (err) {
console.error('[LEAVE] Fehler:', err) console.error('[LEAVE] Fehler:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })

View File

@ -3,28 +3,104 @@ import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamId, newName } = await req.json() const { teamId, newName } = await req.json()
const name = (newName ?? '').trim()
if (!teamId || !newName) { if (!teamId || !name) {
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 }) return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
} }
await prisma.team.update({ // Team + Member laden (für Zielgruppe)
const teamBefore = await prisma.team.findUnique({
where: { id: teamId }, where: { id: teamId },
data: { name: newName }, select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
}) })
if (!teamBefore) {
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
}
// 🔔 SSE Nachricht an alle User (global) // umbenennen (Unique-Name beachten)
let updated
try {
updated = await prisma.team.update({
where: { id: teamId },
data: { name },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
} catch (e: any) {
if (e?.code === 'P2002') {
return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 })
}
throw e
}
// Zielnutzer (Leader + aktive + inaktive)
const targets = Array.from(new Set(
[
updated.leaderId,
...(updated.activePlayers ?? []),
...(updated.inactivePlayers ?? []),
].filter(Boolean) as string[]
))
const text = `Team wurde umbenannt in "${updated.name}".`
// Optional: persistente Notifications (sichtbar im Dropdown + Live via SSE)
if (targets.length) {
const created = await Promise.all(
targets.map(steamId =>
prisma.notification.create({
data: {
steamId,
title: 'Team umbenannt',
message: text,
actionType: 'team-renamed',
actionData: updated.id,
},
})
)
)
// live zustellen als sichtbare Notification
await Promise.all(
created.map(n =>
sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
actionData: n.actionData ?? undefined,
createdAt: n.createdAt.toISOString(),
})
)
)
}
// Team-Event (für SSEHandler → soft reload, keine Broadcasts)
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-renamed', type: 'team-renamed',
title: 'Team umbenannt!',
message: `Das Team wurde umbenannt in "${newName}".`,
teamId, teamId,
targetUserIds: targets,
message: text,
newName: updated.name,
}) })
return NextResponse.json({ success: true }) // Generisches Reload-Signal (failsafe)
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: targets,
})
return NextResponse.json(
{ success: true, team: { id: updated.id, name: updated.name } },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) { } catch (err) {
console.error('Fehler beim Umbenennen:', err) console.error('Fehler beim Umbenennen:', err)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })

View File

@ -0,0 +1,213 @@
// /api/team/request-join/[action]/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(
req: NextRequest,
{ params }: { params: { action: 'accept' | 'reject' } }
) {
try {
const session = await getServerSession(authOptions(req))
const leaderSteamId = session?.user?.steamId
if (!leaderSteamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const { action } = params
const { requestId, teamId: teamIdFromBody } = await req.json().catch(() => ({}))
if (!requestId) {
return NextResponse.json({ message: 'requestId fehlt' }, { status: 400 })
}
// Invitation/Join-Request laden
const joinInv = await prisma.teamInvite.findUnique({
where: { id: requestId },
})
if (!joinInv) {
return NextResponse.json({ message: 'Einladung existiert nicht mehr' }, { status: 404 })
}
if (joinInv.type !== 'team-join-request') {
return NextResponse.json({ message: 'Invitation ist keine Beitrittsanfrage' }, { status: 400 })
}
const teamId = joinInv.teamId ?? teamIdFromBody
if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt/ungültig' }, { status: 400 })
}
// Team + Leader prüfen
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
id: true,
name: true,
leaderId: true,
activePlayers: true,
inactivePlayers: true,
},
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
if (team.leaderId !== leaderSteamId) {
return NextResponse.json({ message: 'Nur der Team-Leader darf das' }, { status: 403 })
}
const requesterSteamId = joinInv.steamId
if (action === 'reject') {
// Anfrage löschen
await prisma.teamInvite.delete({ where: { id: requestId } })
// Leader-Notification (Join-Request) bereinigen
await prisma.notification.updateMany({
where: {
steamId: leaderSteamId,
actionType: 'team-join-request',
actionData: requestId,
},
data: { read: true, actionType: null, actionData: null },
})
// 🔇 KEINE Notification mehr an den Anfragenden senden!
// Stattdessen: Silent UI-Refresh für alle verbleibenden Teammitglieder
const remainingMembers = Array.from(new Set(
[
team.leaderId,
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
].filter(Boolean) as string[]
))
if (remainingMembers.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId: team.id,
targetUserIds: remainingMembers,
})
}
return NextResponse.json({ message: 'Beitrittsanfrage abgelehnt' }, { status: 200 })
}
// === accept ===
// Doppel-Check: bereits Mitglied?
const alreadyMember =
requesterSteamId === team.leaderId ||
team.activePlayers.includes(requesterSteamId) ||
team.inactivePlayers.includes(requesterSteamId)
if (alreadyMember) {
// Anfrage einfach entfernen & sauber aufräumen
await prisma.teamInvite.delete({ where: { id: requestId } })
await prisma.notification.updateMany({
where: {
steamId: leaderSteamId,
actionType: 'team-join-request',
actionData: requestId,
},
data: { read: true, actionType: null, actionData: null },
})
return NextResponse.json({ message: 'User ist bereits Mitglied' }, { status: 200 })
}
// User an Team hängen
await prisma.user.update({
where: { steamId: requesterSteamId },
data: { teamId: team.id },
})
// requester in inactivePlayers aufnehmen (ohne Duplikate)
const nextInactive = Array.from(new Set([...(team.inactivePlayers ?? []), requesterSteamId]))
await prisma.team.update({
where: { id: team.id },
data: { inactivePlayers: nextInactive },
})
// Anfrage löschen
await prisma.teamInvite.delete({ where: { id: requestId } })
// Leader-Notification (Join-Request) bereinigen
await prisma.notification.updateMany({
where: {
steamId: leaderSteamId,
actionType: 'team-join-request',
actionData: requestId,
},
data: { read: true, actionType: null, actionData: null },
})
// Requester informieren
const joinedNotif = await prisma.notification.create({
data: {
steamId: requesterSteamId,
title: 'Teambeitritt',
message: `Deine Beitrittsanfrage wurde akzeptiert. Du bist nun im Team "${team.name}".`,
actionType: 'team-joined',
actionData: team.id,
},
})
await sendServerSSEMessage({
type: joinedNotif.actionType ?? 'notification',
targetUserIds: [requesterSteamId],
message: joinedNotif.message,
id: joinedNotif.id,
actionType: joinedNotif.actionType ?? undefined,
actionData: joinedNotif.actionData ?? undefined,
createdAt: joinedNotif.createdAt.toISOString(),
})
// Team-Members informieren (ohne den neuen)
const allSteamIds = Array.from(new Set([...(team.activePlayers ?? []), ...nextInactive]))
const otherUserIds = allSteamIds.filter((id) => id !== requesterSteamId)
const requester = await prisma.user.findUnique({
where: { steamId: requesterSteamId },
select: { name: true },
})
await Promise.all(
otherUserIds.map(async (uid) => {
const notif = await prisma.notification.create({
data: {
steamId: uid,
title: 'Neues Mitglied',
message: `${requester?.name ?? 'Ein Spieler'} ist dem Team beigetreten.`,
actionType: 'team-member-joined',
actionData: requesterSteamId,
},
})
await sendServerSSEMessage({
type: notif.actionType ?? 'notification',
targetUserIds: [uid],
message: notif.message,
id: notif.id,
actionType: notif.actionType ?? undefined,
actionData: notif.actionData ?? undefined,
createdAt: notif.createdAt.toISOString(),
})
})
)
// Team-Update an alle (inkl. neuem Member)
await sendServerSSEMessage({
type: 'team-updated',
teamId: team.id,
targetUserIds: Array.from(new Set([...otherUserIds, requesterSteamId])),
})
return NextResponse.json({ message: 'Beitrittsanfrage angenommen' }, { status: 200 })
} catch (err) {
console.error('[POST] /api/team/request-join/[action]', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -1,49 +1,53 @@
// src/app/api/team/transfer-leader/route.ts // /app/api/team/transfer-leader/route.ts
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamId, newLeaderSteamId } = await req.json() const { teamId, newLeaderSteamId } = await req.json()
/* ────────────── Parameter prüfen ────────────── */
if (!teamId || !newLeaderSteamId) { if (!teamId || !newLeaderSteamId) {
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 }) return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
} }
/* ────────────── Team holen ───────────────────── */ const team = await prisma.team.findUnique({
const team = await prisma.team.findUnique({ where: { id: teamId } }) where: { id: teamId },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
if (!team) { if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 }) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
} }
/* ────────────── Mitgliedschaft prüfen ────────── */ const allPlayerIds = Array.from(new Set([
const allPlayerIds = Array.from(
new Set([
...(team.activePlayers ?? []), ...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []), ...(team.inactivePlayers ?? []),
]), team.leaderId, // alter Leader (kann null sein)
) ].filter(Boolean) as string[]))
// Neuer Leader muss Mitglied sein
if (!allPlayerIds.includes(newLeaderSteamId)) { if (!allPlayerIds.includes(newLeaderSteamId)) {
return NextResponse.json({ return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 })
message: 'Neuer Leader ist kein Teammitglied.',
}, { status: 400 })
} }
/* ────────────── Leader setzen ────────────────── */ // Leader setzen
await prisma.team.update({ await prisma.team.update({
where: { id: teamId }, where: { id: teamId },
data : { leaderId: newLeaderSteamId }, data : { leaderId: newLeaderSteamId },
}) })
/* ────────────── Namen des neuen Leaders ───────── */ // Namen neuer Leader
const newLeader = await prisma.user.findUnique({ const newLeader = await prisma.user.findUnique({
where : { steamId: newLeaderSteamId }, where : { steamId: newLeaderSteamId },
select: { name: true }, select: { name: true },
}) })
/* ────────── 1) Notification an neuen Leader ───── */ const textForOthers =
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
// 1) Notification an neuen Leader (sichtbar + live)
const leaderNote = await prisma.notification.create({ const leaderNote = await prisma.notification.create({
data: { data: {
steamId : newLeaderSteamId, steamId : newLeaderSteamId,
@ -53,9 +57,8 @@ export async function POST(req: NextRequest) {
actionData: teamId, actionData: teamId,
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type : leaderNote.actionType ?? 'notification', type : 'notification',
targetUserIds: [newLeaderSteamId], targetUserIds: [newLeaderSteamId],
message : leaderNote.message, message : leaderNote.message,
id : leaderNote.id, id : leaderNote.id,
@ -64,55 +67,60 @@ export async function POST(req: NextRequest) {
createdAt : leaderNote.createdAt.toISOString(), createdAt : leaderNote.createdAt.toISOString(),
}) })
/* ────────── 2) Info an alle anderen ───────────── */ // 2) Info an alle anderen (sichtbar + live)
const others: string[] = [ const others = allPlayerIds.filter(id => id !== newLeaderSteamId)
...allPlayerIds,
team.leaderId ?? undefined, // alter Leader (kann null sein)
]
/* Type-Guard: nur echte Strings behalten */
.filter((id): id is string => typeof id === 'string' && id !== newLeaderSteamId)
if (others.length) { if (others.length) {
const text =
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
const notes = await Promise.all( const notes = await Promise.all(
others.map(steamId => others.map(steamId =>
prisma.notification.create({ prisma.notification.create({
data: { data: {
steamId, steamId,
title: 'Neuer Teamleader', title: 'Neuer Teamleader',
message: text, message: textForOthers,
actionType: 'team-leader-changed', actionType: 'team-leader-changed',
actionData: newLeaderSteamId, actionData: newLeaderSteamId,
}, },
}), })
), )
) )
await Promise.all(
notes.map(n =>
sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
actionData: n.actionData ?? undefined,
createdAt: n.createdAt.toISOString(),
})
)
)
// zusätzliches Team-Event (für SSEHandler → soft reload)
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-leader-changed', type: 'team-leader-changed',
targetUserIds: others, targetUserIds: others,
message : text, teamId,
id : notes[0].id, // eine Referenz-ID reicht message: textForOthers,
actionType : 'team-leader-changed',
actionData: newLeaderSteamId, actionData: newLeaderSteamId,
createdAt : notes[0].createdAt.toISOString(),
}) })
} }
/* ── 3) Globales “team-updated” an ALLE ──────────────── */ // 3) Zielgerichtetes “team-updated” an ALLE (inkl. neuem Leader)
const reloadTargets = Array.from(new Set([...allPlayerIds, newLeaderSteamId]))
if (reloadTargets.length) {
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
targetUserIds: allPlayerIds, targetUserIds: reloadTargets,
teamId, teamId,
}) })
}
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' }) return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
} catch (error) { } catch (error) {
console.error('Fehler beim Leaderwechsel:', error) console.error('Fehler beim Leaderwechsel:', error)
return NextResponse.json({ return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
message: 'Serverfehler beim Leaderwechsel.',
}, { status: 500 })
} }
} }

View File

@ -6,56 +6,59 @@ import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json() const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) { if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 }) return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
} }
// 🟢 Team-Update: aktive & inaktive Spieler // 1) Altzustand
const prev = await prisma.team.findUnique({
where: { id: teamId },
include: { invites: true },
})
const prevMembers = new Set([...(prev?.activePlayers ?? []), ...(prev?.inactivePlayers ?? [])])
// 2) ✏️ Neues speichern
await prisma.team.update({ await prisma.team.update({
where: { id: teamId }, where: { id: teamId },
data: { activePlayers, inactivePlayers }, data: { activePlayers, inactivePlayers },
}) })
// 🔄 Einladungen synchronisieren // 3) Invites syncen (wie gehabt)
if (Array.isArray(invitedPlayers)) { if (Array.isArray(invitedPlayers)) {
// Zuerst: Bestehende Einladungen für dieses Team laden const existingInvites = await prisma.teamInvite.findMany({ where: { teamId } })
const existingInvites = await prisma.teamInvite.findMany({ const existingSteamIds = existingInvites.map(i => i.steamId)
where: { teamId },
})
const existingSteamIds = existingInvites.map((invite) => invite.steamId)
const toAdd = invitedPlayers.filter((id: string) => !existingSteamIds.includes(id)) const toAdd = invitedPlayers.filter((id: string) => !existingSteamIds.includes(id))
const toRemove = existingSteamIds.filter((id) => !invitedPlayers.includes(id)) const toRemove = existingSteamIds.filter((id) => !invitedPlayers.includes(id))
if (toAdd.length) await prisma.teamInvite.createMany({
// Neue Einladungen erstellen data: toAdd.map((steamId: string) => ({ teamId, steamId, type: 'invite' })), skipDuplicates: true,
await prisma.teamInvite.createMany({
data: toAdd.map((steamId: string) => ({
teamId,
steamId,
type: 'invite',
})),
skipDuplicates: true,
}) })
if (toRemove.length) await prisma.teamInvite.deleteMany({
// Nicht mehr gelistete Einladungen löschen where: { teamId, steamId: { in: toRemove } },
await prisma.teamInvite.deleteMany({
where: {
teamId,
steamId: { in: toRemove },
},
}) })
} }
const allSteamIds = [...activePlayers, ...inactivePlayers, ...(invitedPlayers || [])] // 4) Diff ermitteln (joined/left)
const nextMembers = new Set<string>([...activePlayers, ...inactivePlayers])
const joined: string[] = []
const left: string[] = []
for (const id of nextMembers) if (!prevMembers.has(id)) joined.push(id)
for (const id of prevMembers) if (!nextMembers.has(id)) left.push(id)
await sendServerSSEMessage({ // 🔔 spezifische Events (Broadcast)
type: 'team-updated', if (joined.length) {
teamId, await sendServerSSEMessage({ type: 'team-member-joined', teamId, users: joined })
targetUserIds: allSteamIds, }
}) if (left.length) {
await sendServerSSEMessage({ type: 'team-member-left', teamId, users: left })
}
return NextResponse.json({ message: 'Team-Mitglieder erfolgreich aktualisiert' }) // 🔔 generisch: Broadcast-Reload
await sendServerSSEMessage({ type: 'team-updated', teamId })
return NextResponse.json(
{ message: 'Team-Mitglieder erfolgreich aktualisiert' },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (error) { } catch (error) {
console.error('Fehler beim Aktualisieren der Team-Mitglieder:', error) console.error('Fehler beim Aktualisieren der Team-Mitglieder:', error)
return NextResponse.json({ error: 'Serverfehler beim Aktualisieren' }, { status: 500 }) return NextResponse.json({ error: 'Serverfehler beim Aktualisieren' }, { status: 500 })

View File

@ -7,11 +7,10 @@ export const dynamic = 'force-dynamic'
export async function POST( export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: { action: string } } { params }: { params: { action: 'accept' | 'reject' | 'revoke' } }
) { ) {
try { try {
const param = await params const { action } = params
const action = param.action
const { invitationId } = await req.json() const { invitationId } = await req.json()
if (!invitationId) { if (!invitationId) {
@ -23,144 +22,171 @@ export async function POST(
return NextResponse.json({ message: 'Einladung existiert nicht mehr' }, { status: 404 }) return NextResponse.json({ message: 'Einladung existiert nicht mehr' }, { status: 404 })
} }
const { steamId, teamId, type } = invitation const { steamId: invitedUserSteamId, teamId } = invitation
if (action === 'accept') { if (action === 'accept') {
await prisma.user.update({ where: { steamId }, data: { teamId } }) await prisma.user.update({ where: { steamId: invitedUserSteamId }, data: { teamId } })
const teamBefore = await prisma.team.findUnique({
where: { id: teamId },
select: { name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
const nextInactive = Array.from(new Set([...(teamBefore?.inactivePlayers ?? []), invitedUserSteamId]))
await prisma.team.update({ await prisma.team.update({
where: { id: teamId }, where: { id: teamId },
data: { inactivePlayers: { push: steamId } }, data: { inactivePlayers: nextInactive },
}) })
await prisma.teamInvite.delete({ where: { id: invitationId } }) await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({ await prisma.notification.updateMany({
where: { steamId, actionType: 'team-invite', actionData: invitationId }, where: { actionData: invitationId },
data: { read: true, actionType: null, actionData: null }, data: { read: true, actionType: null, actionData: null },
}) })
const team = await prisma.team.findUnique({ const team = await prisma.team.findUnique({
where: { id: teamId }, where: { id: teamId },
select: { name: true, activePlayers: true, inactivePlayers: true }, select: { name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
}) })
const allSteamIds = Array.from(new Set([ const allMembers = Array.from(
new Set(
[
team?.leaderId,
...(team?.activePlayers ?? []), ...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []), ...(team?.inactivePlayers ?? []),
])) ].filter(Boolean) as string[]
)
)
const notification = await prisma.notification.create({ const joinedNotif = await prisma.notification.create({
data: { data: {
steamId, steamId: invitedUserSteamId,
title: 'Teambeitritt', title: 'Teambeitritt',
message: `Du bist dem Team "${team?.name ?? 'Unbekannt'}" beigetreten.`, message: `Du bist dem Team "${team?.name ?? 'Unbekannt'}" beigetreten.`,
actionType: 'team-joined', actionType: 'team-joined',
actionData: teamId, actionData: teamId,
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type: notification.actionType ?? 'notification', type: joinedNotif.actionType ?? 'notification',
targetUserIds: [steamId], targetUserIds: [invitedUserSteamId],
message: notification.message, message: joinedNotif.message,
id: notification.id, id: joinedNotif.id,
actionType: notification.actionType ?? undefined, actionType: joinedNotif.actionType ?? undefined,
actionData: notification.actionData ?? undefined, actionData: joinedNotif.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(), createdAt: joinedNotif.createdAt.toISOString(),
}) })
const joiningUser = await prisma.user.findUnique({ const joiningUser = await prisma.user.findUnique({
where: { steamId }, where: { steamId: invitedUserSteamId },
select: { name: true }, select: { name: true },
}) })
const others = allMembers.filter(id => id !== invitedUserSteamId)
const otherUserIds = allSteamIds.filter(id => id !== steamId) if (others.length) {
await Promise.all( const created = await Promise.all(
otherUserIds.map(async (otherUserId) => { others.map(uid =>
const notification = await prisma.notification.create({ prisma.notification.create({
data: { data: {
steamId: otherUserId, steamId: uid,
title: 'Neues Mitglied', title: 'Neues Mitglied',
message: `${joiningUser?.name ?? 'Ein Spieler'} ist deinem Team beigetreten.`, message: `${joiningUser?.name ?? 'Ein Spieler'} ist deinem Team beigetreten.`,
actionType: 'team-member-joined', actionType: 'team-member-joined',
actionData: steamId, actionData: invitedUserSteamId,
}, },
}) })
)
await sendServerSSEMessage({ )
type: notification.actionType ?? 'notification', await Promise.all(
targetUserIds: [otherUserId], created.map(n =>
message: notification.message, sendServerSSEMessage({
id: notification.id, type: n.actionType ?? 'notification',
actionType: notification.actionType ?? undefined, targetUserIds: [n.steamId],
actionData: notification.actionData ?? undefined, message: n.message,
createdAt: notification.createdAt.toISOString(), id: n.id,
actionType: n.actionType ?? undefined,
actionData: n.actionData ?? undefined,
createdAt: n.createdAt.toISOString(),
}) })
)
)
}
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
teamId, teamId,
targetUserIds: allSteamIds, targetUserIds: allMembers,
}) })
})
)
return NextResponse.json({ message: 'Einladung angenommen' }) return NextResponse.json({ message: 'Einladung angenommen' })
} }
if (action === 'reject') { if (action === 'reject') {
const team = await prisma.team.findUnique({ // Einladung löschen & zugehörige Notifications aufräumen (keine sichtbare Nachricht)
where: { id: teamId },
select: { name: true },
})
await prisma.teamInvite.delete({ where: { id: invitationId } }) await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({ await prisma.notification.updateMany({
where: { steamId, actionData: invitationId }, where: { actionData: invitationId },
data: { read: true, actionType: null, actionData: null }, data: { read: true, actionType: null, actionData: null },
}) })
const eventType = type === 'team-join-request' // ➜ Team-Mitglieder ermitteln (Leader + aktive + inaktive), ohne die eingeladene Person
? 'team-join-request-reject' const team = await prisma.team.findUnique({
: 'team-invite-reject' where: { id: teamId },
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
await sendServerSSEMessage({
type: eventType,
targetUserIds: [steamId],
message: `Einladung zu Team "${team?.name}" wurde abgelehnt.`,
}) })
const remainingMembers = Array.from(
new Set(
[
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
]
.filter(Boolean) as string[]
)
).filter(id => id !== invitedUserSteamId)
// ➜ Silent UI Refresh via SSE für die verbleibenden Mitglieder
if (remainingMembers.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: remainingMembers,
})
}
return NextResponse.json({ message: 'Einladung abgelehnt' }) return NextResponse.json({ message: 'Einladung abgelehnt' })
} }
if (action === 'revoke') { if (action === 'revoke') {
await prisma.teamInvite.delete({ where: { id: invitationId } }) await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({ await prisma.notification.updateMany({
where: { steamId, actionData: invitationId }, where: { actionData: invitationId },
data: { read: true, actionType: null, actionData: null }, data: { read: true, actionType: null, actionData: null },
}) })
// 1. Teamdaten laden (inkl. Leader)
const team = await prisma.team.findUnique({ const team = await prisma.team.findUnique({
where: { id: teamId }, where: { id: teamId },
select: { leader: true }, select: { leaderId: true, activePlayers: true, inactivePlayers: true },
}) })
// 2. Admins holen
const admins = await prisma.user.findMany({ const admins = await prisma.user.findMany({
where: { isAdmin: true }, where: { isAdmin: true },
select: { steamId: true }, select: { steamId: true },
}) })
// 3. Zielnutzer: Leader + Admins const targetUserIds = Array.from(
const targetUserIds = [ new Set(
team?.leader, [
...admins.map(admin => admin.steamId), team?.leaderId,
].filter(Boolean) // entfernt null/undefined ...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
...admins.map(a => a.steamId),
].filter(Boolean) as string[]
)
)
// 4. SSE senden
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
teamId, teamId,

View File

@ -7,14 +7,14 @@ import { prisma } from '@/app/lib/prisma'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const session = await getServerSession(authOptions(req)) const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) { const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
} }
// Einladungen inkl. roher Teamdaten (IDs) laden
const invitations = await prisma.teamInvite.findMany({ const invitations = await prisma.teamInvite.findMany({
where: { where: { steamId },
steamId: session.user.steamId,
},
select: { select: {
id: true, id: true,
teamId: true, teamId: true,
@ -22,14 +22,75 @@ export async function GET(req: NextRequest) {
type: true, type: true,
team: { team: {
select: { select: {
id: true,
name: true, name: true,
} logo: true,
} leaderId: true,
activePlayers: true, // Array<string> (steamIds)
inactivePlayers: true, // Array<string> (steamIds)
}, },
orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
}) })
return NextResponse.json({ invitations }) if (invitations.length === 0) {
return NextResponse.json(
{ invitations: [] },
{ headers: { 'Cache-Control': 'no-store' } },
)
}
// Alle benötigten SteamIDs sammeln (aktiv + inaktiv)
const allSteamIds = Array.from(
new Set(
invitations.flatMap(inv => [
...(inv.team?.activePlayers ?? []),
...(inv.team?.inactivePlayers ?? []),
]),
),
)
// Userdaten der Members laden (Name, Avatar)
const users = await prisma.user.findMany({
where: { steamId: { in: allSteamIds } },
select: { steamId: true, name: true, avatar: true },
})
const userMap = new Map(users.map(u => [u.steamId, u]))
// Team-Player-Arrays von IDs -> Objekte mappen (Reihenfolge beibehalten)
const mapIds = (ids: string[] = []) =>
ids.map(id => {
const u = userMap.get(id)
return {
steamId: id,
name: u?.name ?? 'Unbekannt',
avatar: u?.avatar ?? '/assets/img/avatars/default.webp',
}
})
const shaped = invitations.map(inv => {
const t = inv.team
return {
id: inv.id,
teamId: inv.teamId,
createdAt: inv.createdAt,
type: inv.type,
team: t && {
id: t.id,
name: t.name,
logo: t.logo, // ✅ Logo mitgeben
leader: t.leaderId ?? null, // optional je nach Frontend
activePlayers: mapIds(t.activePlayers ?? []),
inactivePlayers: mapIds(t.inactivePlayers ?? []),
},
}
})
return NextResponse.json(
{ invitations: shaped },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) { } catch (err) {
console.error('Fehler beim Laden der Einladungen:', err) console.error('Fehler beim Laden der Einladungen:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })

View File

@ -47,7 +47,7 @@ export default function Card({
<div <div
className={` className={`
flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
dark:bg-neutral-900 dark:border-neutral-700 dark:shadow-neutral-700/70 dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70
${alignClasses} ${widthClasses[maxWidth]} ${alignClasses} ${widthClasses[maxWidth]}
`} `}
> >

View File

@ -1,50 +1,150 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import TeamCard from './TeamCard' import TeamCard from './TeamCard'
import { Team } from '../types/team' import { Team, Player } from '../types/team'
import { useSSEStore } from '@/app/lib/useSSEStore'
/** flache Vergleiche, um unnötiges SetState/Flicker zu vermeiden */
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a,b)=>a.steamId.localeCompare(b.steamId))
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
return true
}
const eqTeam = (a: Team, b: Team) => {
if (a.id !== b.id) return false
if (a.name !== b.name) return false
if (a.logo !== b.logo) return false
if (a.leader !== b.leader) return false
return eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
}
const eqTeamList = (a: Team[], b: Team[]) => {
if (a.length !== b.length) return false
// indexiert nach id
const mapA = new Map(a.map(t => [t.id, t]))
for (const t of b) {
const x = mapA.get(t.id)
if (!x || !eqTeam(x, t)) return false
}
return true
}
const TEAM_EVENTS = new Set([
'team-updated',
'team-leader-changed',
'team-member-joined',
'team-member-left',
'team-renamed',
'team-logo-updated',
])
const INVITE_EVENTS = new Set([
'team-invite',
'team-join-request',
'team-invite-reject',
'team-join-request-reject',
])
export default function NoTeamView() { export default function NoTeamView() {
const { data: session } = useSession() const { data: session } = useSession()
const currentSteamId = session?.user?.steamId || ''
const { lastEvent } = useSSEStore() // nur konsumieren Verbindung macht dein globaler SSEHandler
const [teams, setTeams] = useState<Team[]>([]) const [teams, setTeams] = useState<Team[]>([])
const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>({}) const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const fetchData = async () => { // Dedupe / Throttle
setLoading(true) const inflight = useRef<AbortController | null>(null)
const lastReloadAt = useRef(0)
const cooldownMs = 350
const fetchTeamsAndInvitations = async (withCooldown = false) => {
const now = Date.now()
if (withCooldown && now - lastReloadAt.current < cooldownMs) return
lastReloadAt.current = now
if (inflight.current) inflight.current.abort()
const ac = new AbortController()
inflight.current = ac
try {
setLoading(prev => prev && true) // nur beim ersten Mal Spinner zeigen
const [teamRes, invitesRes] = await Promise.all([ const [teamRes, invitesRes] = await Promise.all([
fetch('/api/team/list'), fetch('/api/team/list', { cache: 'no-store', signal: ac.signal }),
fetch('/api/user/invitations'), fetch('/api/user/invitations', { cache: 'no-store', signal: ac.signal }),
]) ])
if (!teamRes.ok) throw new Error('Teamliste fehlgeschlagen')
if (!invitesRes.ok) throw new Error('Einladungen fehlgeschlagen')
const teamData = await teamRes.json() const teamData = await teamRes.json()
const inviteData = await invitesRes.json() const inviteData = await invitesRes.json()
setTeams(teamData.teams || []) const nextTeams: Team[] = teamData.teams || []
// nur Join-Requests der aktuellen Person mappen (für Button-Label “Angefragt”)
const mapping: Record<string, string> = {} const mapping: Record<string, string> = {}
for (const invite of inviteData?.invitations || []) { for (const inv of inviteData?.invitations || []) {
if (invite.type === 'team-join-request') { if (inv.type === 'team-join-request') {
mapping[invite.teamId] = invite.id mapping[inv.teamId] = inv.id
} }
} }
setTeamToInvitationId(mapping)
// nur setzen, wenn sich wirklich etwas geändert hat
setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams))
setTeamToInvitationId(prev => {
const same =
Object.keys(prev).length === Object.keys(mapping).length &&
Object.keys(prev).every(k => prev[k] === mapping[k])
return same ? prev : mapping
})
} catch (e: any) {
if (e?.name !== 'AbortError') console.error('[NoTeamView] fetch error:', e)
} finally {
if (inflight.current === ac) inflight.current = null
setLoading(false) setLoading(false)
} }
}
// initial
useEffect(() => { fetchTeamsAndInvitations(false) }, [])
// auf SSE reagieren: nur dann nachladen, wenn das Event relevant ist
useEffect(() => { useEffect(() => {
fetchData() if (!lastEvent) return
}, []) const { type, payload } = lastEvent
const updateInvitationMap = (teamId: string, newId: string | null) => { if (TEAM_EVENTS.has(type)) {
setTeamToInvitationId((prev) => { // Wenn teamId fehlt: sicherheitshalber nachladen
if (!payload?.teamId) { fetchTeamsAndInvitations(true); return }
// nur nachladen, wenn dieses Team in unserer Liste vorkommt
if (teams.some(t => t.id === payload.teamId)) {
fetchTeamsAndInvitations(true)
}
return
}
if (INVITE_EVENTS.has(type)) {
// Liste der Join-Requests/Invites aktualisieren (z. B. wenn ein Request erzeugt/gelöscht wurde)
fetchTeamsAndInvitations(true)
}
}, [lastEvent, teams])
const updateInvitationMap = (teamId: string, newValue: string | null | 'pending') => {
setTeamToInvitationId(prev => {
const updated = { ...prev } const updated = { ...prev }
if (newId) updated[teamId] = newId if (!newValue) delete updated[teamId]
else delete updated[teamId] else if (newValue === 'pending') updated[teamId] = updated[teamId] ?? 'pending'
else updated[teamId] = newValue
return updated return updated
}) })
} }
if (loading) return <p>Lade Teams </p> if (loading) return <p>Lade Teams </p>
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -55,11 +155,11 @@ export default function NoTeamView() {
</h2> </h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => ( {teams.map(team => (
<TeamCard <TeamCard
key={team.id} key={team.id}
team={team} team={team}
currentUserSteamId={session?.user?.steamId || ''} currentUserSteamId={currentSteamId}
invitationId={teamToInvitationId[team.id]} invitationId={teamToInvitationId[team.id]}
onUpdateInvitation={updateInvitationMap} onUpdateInvitation={updateInvitationMap}
adminMode={false} adminMode={false}

View File

@ -4,10 +4,9 @@ import { useEffect, useState } from 'react'
import NotificationDropdown from './NotificationDropdown' import NotificationDropdown from './NotificationDropdown'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSSEStore } from '@/app/lib/useSSEStore'
import { NOTIFICATION_EVENTS } from '../lib/sseEvents'
/* ────────────────────────────────────────────────────────── */
/* Typen */
/* ────────────────────────────────────────────────────────── */
type Notification = { type Notification = {
id: string id: string
text: string text: string
@ -17,42 +16,42 @@ type Notification = {
createdAt?: string createdAt?: string
} }
/* ────────────────────────────────────────────────────────── */ type ActionData =
/* Komponente */ | { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
/* ────────────────────────────────────────────────────────── */ | { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
// --- API Helper ---
async function apiJSON(url: string, body?: any, method = 'POST') {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
return res.json().catch(() => ({}))
}
export default function NotificationCenter() { export default function NotificationCenter() {
/* --- Hooks & States ------------------------------------ */
const { data: session } = useSession() const { data: session } = useSession()
const router = useRouter()
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
//const { markAllAsRead, markOneAsRead, handleInviteAction } = useTeamManager({}, null)
const router = useRouter()
const [previewText, setPreviewText] = useState<string | null>(null) const [previewText, setPreviewText] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const [animateBell, setAnimateBell] = useState(false) const [animateBell, setAnimateBell] = useState(false)
/* --- Aktionen beim Klick auf eine Notification ---------- */ // 1) Initial laden
const onNotificationClick = (notification: Notification) => {
if (!notification.actionData) return
try {
const data = JSON.parse(notification.actionData)
if (data.redirectUrl) router.push(data.redirectUrl)
} catch (err) {
console.error('[NotificationCenter] Ungültige actionData:', err)
}
}
/* --- Initiale Daten laden + SSE verbinden --------------- */
useEffect(() => { useEffect(() => {
const steamId = session?.user?.steamId const steamId = session?.user?.steamId
if (!steamId) return if (!steamId) return
;(async () => {
const loadNotifications = async () => {
try { try {
const res = await fetch('/api/notifications/user') const res = await fetch('/api/notifications/user')
if (!res.ok) throw new Error('Fehler beim Laden') if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json() const data = await res.json()
const loaded = data.notifications.map((n: any) => ({ const loaded: Notification[] = data.notifications.map((n: any) => ({
id: n.id, id: n.id,
text: n.message, text: n.message,
read: n.read, read: n.read,
@ -64,121 +63,193 @@ export default function NotificationCenter() {
} catch (err) { } catch (err) {
console.error('[NotificationCenter] Fehler beim Laden:', err) console.error('[NotificationCenter] Fehler beim Laden:', err)
} }
} })()
loadNotifications()
}, [session?.user?.steamId]) }, [session?.user?.steamId])
/* --- Live-Updates über SSE empfangen -------------------- */ // 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
useEffect(() => { useEffect(() => {
return; if (!lastEvent) return
//if (!source) return if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
/* Handler für JEDES eintreffende Paket ------------------ */ const data = lastEvent.payload
const handleEvent = (event: MessageEvent) => { if (data?.type === 'heartbeat') return
try {
const data = JSON.parse(event.data)
if (data.type === 'heartbeat') return // Ping ignorieren
/* Neues Notification-Objekt erzeugen */
const newNotification: Notification = { const newNotification: Notification = {
id : data.id ?? crypto.randomUUID(), id: data?.id ?? crypto.randomUUID(),
text : data.message ?? 'Neue Benachrichtigung', text: data?.message ?? 'Neue Benachrichtigung',
read: false, read: false,
actionType: data.actionType, actionType: data?.actionType,
actionData: data.actionData, actionData: data?.actionData,
createdAt : data.createdAt ?? new Date().toISOString(), createdAt: data?.createdAt ?? new Date().toISOString(),
} }
/* State updaten (immer oben einsortieren) */
setNotifications(prev => [newNotification, ...prev]) setNotifications(prev => [newNotification, ...prev])
setPreviewText(newNotification.text) // <-- nur das hier
}, [lastEvent])
// 2) Timer separat steuern: triggert bei neuem previewText
useEffect(() => {
if (!previewText) return
/* Glocke & Vorschau animieren ---------------------- */
setPreviewText(newNotification.text)
setShowPreview(true) setShowPreview(true)
setAnimateBell(true) setAnimateBell(true)
setTimeout(() => { const PREVIEW_MS = 5000
setShowPreview(false) const CLEAR_DELAY = 300
setTimeout(() => setPreviewText(null), 300)
setAnimateBell(false)
}, 3000)
} catch (err) {
console.error('[SSE] Ungültige Nachricht:', event.data, err)
}
}
/* Liste aller Event-Namen, die der Server schicken kann */ const tHide = window.setTimeout(() => setShowPreview(false), PREVIEW_MS)
const eventNames = [ const tBell = window.setTimeout(() => setAnimateBell(false), PREVIEW_MS)
'notification', const tClear = window.setTimeout(() => setPreviewText(null), PREVIEW_MS + CLEAR_DELAY)
'invitation',
'team-invite',
'team-joined',
'team-member-joined',
'team-kick',
'team-kick-other',
'team-left',
'team-member-left',
'team-leader-changed',
'team-leader-self',
'team-join-request',
'expired-sharecode',
]
/* Named Events abonnieren ------------------------------ */
//eventNames.forEach(evt => source.addEventListener(evt, handleEvent))
/* Fallback: Server sendet evtl. Events ohne „event:“----- */
//source.onmessage = handleEvent
/* Aufräumen bei Unmount -------------------------------- */
return () => { return () => {
//eventNames.forEach(evt => source.removeEventListener(evt, handleEvent)) clearTimeout(tHide)
//source.onmessage = null clearTimeout(tBell)
clearTimeout(tClear)
} }
}, /*[source] */) }, [previewText])
/* ────────────────────────────────────────────────────────── */
/* Render */ // 3) Actions
/* ────────────────────────────────────────────────────────── */ const markAllAsRead = async () => {
await apiJSON('/api/notifications/mark-all-read', undefined, 'POST')
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
}
const markOneAsRead = async (notificationId: string) => {
await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST')
setNotifications(prev =>
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)),
)
}
const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => {
// passende Notification finden (per actionData oder id)
const n = notifications.find(x => x.actionData === refId || x.id === refId)
if (!n) {
console.warn('[NotificationCenter] Keine Notification zu', refId)
return
}
// actionData muss die Referenz tragen
if (!n.actionData) {
console.warn('[NotificationCenter] actionData fehlt für Notification', n.id)
return
}
// actionData parsen: erlaubt JSON {kind, inviteId/requestId, teamId} ODER nackte ID
let kind: 'invite' | 'join-request' | undefined
let invitationId: string | undefined
let requestId: string | undefined
let teamId: string | undefined
try {
const data = JSON.parse(n.actionData) as
| { kind?: 'invite' | 'join-request'; inviteId?: string; requestId?: string; teamId?: string }
| string
if (typeof data === 'object' && data) {
kind = data.kind
invitationId = data.inviteId
requestId = data.requestId
teamId = data.teamId
} else if (typeof data === 'string') {
// nackte ID: sowohl als invitationId als auch requestId nutzbar
invitationId = data
requestId = data
}
} catch {
// non-JSON → nackte ID im actionData-String
invitationId = n.actionData
requestId = n.actionData
}
// Fallback anhand actionType
if (!kind && n.actionType === 'team-invite') kind = 'invite'
if (!kind && n.actionType === 'team-join-request') kind = 'join-request'
// Sicherheitscheck
if (kind === 'invite' && !invitationId) {
console.warn('[NotificationCenter] invitationId fehlt')
return
}
if (kind === 'join-request' && !requestId) {
console.warn('[NotificationCenter] requestId fehlt')
return
}
// Optimistic Update (Buttons ausblenden)
const snapshot = notifications
setNotifications(prev =>
prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)),
)
try {
if (kind === 'invite') {
await apiJSON(`/api/user/invitations/${action}`, {
invitationId,
teamId,
})
setNotifications(prev => prev.filter(x => x.id !== n.id))
if (action === 'accept') router.refresh()
return
}
if (kind === 'join-request') {
if (action === 'accept') {
await apiJSON('/api/team/request-join/accept', { requestId, teamId })
} else {
await apiJSON('/api/team/request-join/reject', { requestId })
}
setNotifications(prev => prev.filter(x => x.id !== n.id))
if (action === 'accept') router.refresh()
return
}
console.warn('[NotificationCenter] Unbekannter Typ:', n.actionType, kind)
setNotifications(snapshot) // rollback
} catch (err) {
console.error('[NotificationCenter] Aktion fehlgeschlagen:', err)
setNotifications(snapshot) // rollback
}
}
const onNotificationClick = (notification: Notification) => {
if (!notification.actionData) return
try {
const data = JSON.parse(notification.actionData)
if (data.redirectUrl) router.push(data.redirectUrl)
} catch (err) {
console.error('[NotificationCenter] Ungültige actionData:', err)
}
}
// 4) Render
return ( return (
<div className="fixed bottom-6 right-6 z-50"> <div className="fixed bottom-6 right-6 z-50">
{/* Glocke -------------------------------------------------- */}
<button <button
type="button" type="button"
onClick={() => setOpen(prev => !prev)} onClick={() => setOpen(prev => !prev)}
className={`relative flex items-center transition-all duration-300 ease-in-out className={`relative flex items-center transition-all duration-300 ease-in-out
${showPreview ? 'w-[320px] pl-4 pr-11' : 'w-[44px] justify-center'} ${showPreview ? 'w-[400px] pl-4 pr-11' : 'w-[44px] justify-center'}
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`} dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
> >
{/* Vorschau-Text --------------------------------------- */}
{previewText && ( {previewText && (
<span className="truncate text-sm text-gray-800 dark:text-white"> <span className="truncate text-sm text-gray-800 dark:text-white">
{previewText} {previewText}
</span> </span>
)} )}
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
{/* Icon & Badge --------------------------------------- */}
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center">
<svg <svg
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`} className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
> >
<path <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
strokeLinecap="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z" />
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z"
/>
</svg> </svg>
{/* Badge (ungelesen) -------------------------------- */}
{notifications.some(n => !n.read) && ( {notifications.some(n => !n.read) && (
<span className="flex absolute top-0 end-0 -mt-1 -me-1"> <span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">
<span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span> <span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span>
<span className="relative inline-flex items-center justify-center size-5 rounded-full text-xs font-bold bg-red-500 text-white"> <span className="relative inline-flex items-center justify-center size-5 rounded-full text-xs font-bold bg-red-500 text-white">
{notifications.filter(n => !n.read).length} {notifications.filter(n => !n.read).length}
@ -188,32 +259,13 @@ export default function NotificationCenter() {
</div> </div>
</button> </button>
{/* Dropdown --------------------------------------------- */}
{open && ( {open && (
<NotificationDropdown <NotificationDropdown
notifications={notifications} notifications={notifications}
markAllAsRead={async () => { markAllAsRead={markAllAsRead}
await markAllAsRead() onSingleRead={markOneAsRead}
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
}}
onSingleRead={async (id) => {
await markOneAsRead(id)
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, read: true } : n)),
)
}}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onAction={async (action, id) => { onAction={handleInviteAction}
await handleInviteAction(action, id)
setNotifications(prev =>
prev.map(n =>
n.actionData === id
? { ...n, read: true, actionType: undefined, actionData: undefined }
: n,
),
)
if (action === 'accept') router.refresh()
}}
onClickNotification={onNotificationClick} onClickNotification={onNotificationClick}
/> />
)} )}

View File

@ -131,8 +131,9 @@ export default function NotificationDropdown({
{needsAction ? ( {needsAction ? (
<> <>
<Button <Button
onClick={() => { onClick={(e) => {
onAction('accept', n.actionData!) e.stopPropagation()
onAction('accept', n.actionData ?? n.id)
onSingleRead(n.id) onSingleRead(n.id)
}} }}
className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700" className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700"
@ -142,8 +143,9 @@ export default function NotificationDropdown({
</Button> </Button>
<Button <Button
onClick={() => { onClick={(e) => {
onAction('reject', n.actionData!) e.stopPropagation()
onAction('reject', n.actionData ?? n.id)
onSingleRead(n.id) onSingleRead(n.id)
}} }}
className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700" className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700"

View File

@ -140,7 +140,22 @@ export default function SidebarFooter() {
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > <svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/> <path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg> </svg>
Mein Profil Profil
</Button>
<Button
onClick={() => router.push(`/team`)}
size='sm'
variant='link'
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${pathname === `/team`
? 'bg-gray-100 dark:bg-neutral-700 text-gray-900 dark:text-white'
: 'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700'
}`}
>
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg>
Team
</Button> </Button>
<Button <Button
onClick={() => router.push('/settings')} onClick={() => router.push('/settings')}

View File

@ -1,18 +1,16 @@
// components/TeamCard.tsx
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Button from './Button' import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
import type { Team, Player } from '../types/team' import type { Team } from '../types/team'
import LoadingSpinner from './LoadingSpinner'
type Props = { type Props = {
team: Team team: Team
currentUserSteamId: string currentUserSteamId: string
invitationId?: string invitationId?: string
onUpdateInvitation: (teamId: string, newValue: string | null) => void onUpdateInvitation: (teamId: string, newValue: string | null | 'pending') => void
adminMode?: boolean adminMode?: boolean
} }
@ -26,14 +24,12 @@ export default function TeamCard({
const router = useRouter() const router = useRouter()
const [joining, setJoining] = useState(false) const [joining, setJoining] = useState(false)
/* ---------- Join / Reject ---------- */
const isRequested = Boolean(invitationId) const isRequested = Boolean(invitationId)
const isDisabled = joining || currentUserSteamId === data.leader const isDisabled = joining || currentUserSteamId === team.leader
const handleClick = async () => { const handleClick = async () => {
if (joining) return if (joining) return
setJoining(true) setJoining(true)
try { try {
if (isRequested) { if (isRequested) {
await fetch('/api/user/invitations/reject', { await fetch('/api/user/invitations/reject', {
@ -41,14 +37,14 @@ export default function TeamCard({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ invitationId }), body : JSON.stringify({ invitationId }),
}) })
onUpdateInvitation(data.id, null) onUpdateInvitation(team.id, null)
} else { } else {
await fetch('/api/team/request-join', { await fetch('/api/team/request-join', {
method : 'POST', method : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ teamId: data.id }), body : JSON.stringify({ teamId: team.id }),
}) })
onUpdateInvitation(data.id, 'pending') onUpdateInvitation(team.id, 'pending')
} }
} catch (err) { } catch (err) {
console.error('[TeamCard] Join/Reject-Fehler:', err) console.error('[TeamCard] Join/Reject-Fehler:', err)
@ -57,44 +53,32 @@ export default function TeamCard({
} }
} }
/* ---------- Ziel-URL berechnen ---------- */ const targetHref = adminMode ? `/admin/teams/${team.id}` : `/team/${team.id}`
const targetHref = adminMode
? `/admin/teams/${data.id}`
: `/team/${data.id}`
/* ---------- Render ---------- */
return ( return (
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => router.push(targetHref)} onClick={() => router.push(targetHref)}
onKeyDown={e => (e.key === 'Enter') && router.push(targetHref)} onKeyDown={e => (e.key === 'Enter') && router.push(targetHref)}
className=" className="p-4 border rounded-lg bg-white dark:bg-neutral-800
p-4 border rounded-lg bg-white dark:bg-neutral-800
dark:border-neutral-700 shadow-sm hover:shadow-md dark:border-neutral-700 shadow-sm hover:shadow-md
transition cursor-pointer focus:outline-none hover:scale-105 hover:bg-neutral-200 hover:dark:bg-neutral-700 transition cursor-pointer focus:outline-none
" hover:scale-105 hover:bg-neutral-200 hover:dark:bg-neutral-700"
> >
{/* Kopfzeile */}
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img <img
src={ src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
data.logo alt={team.name ?? 'Teamlogo'}
? `/assets/img/logos/${data.logo}`
: '/assets/img/logos/cs2.webp'
}
alt={data.name ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border className="w-12 h-12 rounded-full object-cover border
border-gray-200 dark:border-neutral-600" border-gray-200 dark:border-neutral-600"
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200"> <span className="font-medium truncate text-gray-800 dark:text-neutral-200">
{data.name ?? 'Team'} {team.name ?? 'Team'}
</span> </span>
<TeamPremierRankBadge players={team.activePlayers} />
<TeamPremierRankBadge players={players} />
</div> </div>
</div> </div>
@ -103,10 +87,10 @@ export default function TeamCard({
title="Verwalten" title="Verwalten"
size="md" size="md"
color="blue" color="blue"
variant='solid' variant="solid"
onClick={e => { onClick={e => {
e.stopPropagation() // ▼ Navigation hier unterbinden e.stopPropagation()
router.push(`/admin/teams/${data.id}`) router.push(`/admin/teams/${team.id}`)
}} }}
> >
Verwalten Verwalten
@ -117,10 +101,7 @@ export default function TeamCard({
size="sm" size="sm"
color={isRequested ? 'gray' : 'blue'} color={isRequested ? 'gray' : 'blue'}
disabled={isDisabled} disabled={isDisabled}
onClick={e => { onClick={e => { e.stopPropagation(); handleClick() }}
e.stopPropagation() // ▼ verhindert Klick-Weitergabe
handleClick()
}}
> >
{joining ? ( {joining ? (
<> <>
@ -140,9 +121,8 @@ export default function TeamCard({
)} )}
</div> </div>
{/* Avatare */}
<div className="flex -space-x-3"> <div className="flex -space-x-3">
{players.slice(0, 5).map(p => ( {[...team.activePlayers, ...team.inactivePlayers].map(p => (
<img <img
key={p.steamId} key={p.steamId}
src={p.avatar} src={p.avatar}
@ -152,16 +132,6 @@ export default function TeamCard({
dark:border-neutral-800 object-cover" dark:border-neutral-800 object-cover"
/> />
))} ))}
{players.length > 5 && (
<span
key="more"
className="w-8 h-8 flex items-center justify-center
rounded-full bg-gray-200 text-xs"
>
+{players.length - 5}
</span>
)}
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { forwardRef, useEffect, useState } from 'react' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import TeamInvitationView from './TeamInvitationView' import TeamInvitationView from './TeamInvitationView'
@ -8,101 +8,202 @@ import TeamMemberView from './TeamMemberView'
import NoTeamView from './NoTeamView' import NoTeamView from './NoTeamView'
import LoadingSpinner from '@/app/components/LoadingSpinner' import LoadingSpinner from '@/app/components/LoadingSpinner'
import CreateTeamButton from './CreateTeamButton' import CreateTeamButton from './CreateTeamButton'
import type { Player, Team } from '../types/team'
import type { Invitation } from '../types/invitation'
import { useSSEStore } from '@/app/lib/useSSEStore'
import { /** Relevante Event-Gruppen */
acceptInvitation, const TEAM_EVENTS = new Set([
rejectInvitation, 'team-updated',
markOneAsRead 'team-leader-changed',
} from '@/app/lib/sse-actions' 'team-member-joined',
import { Player, Team } from '../types/team' 'team-member-left',
'team-renamed',
'team-logo-updated',
])
type Props = { const SELF_CLEAR_EVENTS = new Set([
refetchKey?: string 'team-kick-self',
'team-left-self',
'user-team-cleared',
])
const INVITE_EVENTS = new Set([
'team-invite',
'team-join-request',
'team-invite-reject',
'team-join-request-reject',
])
type Props = { refetchKey?: string }
/** flache, stabile Equality-Checks, um unnötige setState zu vermeiden */
function eqPlayers(a: Player[] = [], b: Player[] = []) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i].steamId !== b[i].steamId) return false
}
return true
}
function eqTeam(a: Team | null, b: Team | null) {
if (!a && !b) return true
if (!a || !b) return false
if (a.id !== b.id || a.name !== b.name || a.logo !== b.logo || a.leader !== b.leader) {
return false
}
// Spielerlisten flach vergleichen (nach steamId sortiert vergleichen)
const sort = (arr: Player[] = []) => [...arr].sort((x, y) => x.steamId.localeCompare(y.steamId))
return eqPlayers(sort(a.activePlayers), sort(b.activePlayers)) &&
eqPlayers(sort(a.inactivePlayers), sort(b.inactivePlayers))
} }
/* eslintdisable react/displayname */ function TeamCardComponent(_: Props, _ref: any) {
function TeamCardComponent(props: Props, ref: any) {
const { data: session } = useSession() const { data: session } = useSession()
const steamId = session?.user?.steamId ?? '' const steamId = session?.user?.steamId ?? ''
const [refetchKey, setRefetchKey] = useState<string>() const { lastEvent } = useSSEStore() // nur konsumieren Verbindung macht der globale SSEHandler
const [isLoading, setIsLoading] = useState(true)
// State
const [initialLoading, setInitialLoading] = useState(true) // nur beim ersten Load true
const [team, setTeam] = useState<Team | null>(null) const [team, setTeam] = useState<Team | null>(null)
const [activePlayers, setActivePlayers] = useState<Player[]>([]) const [pendingInvitation, setPendingInvitation] = useState<Invitation | null>(null)
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
const [invitedPlayers, setInvitedPlayers] = useState<Player[]>([])
const [pendingInvitation, setPendingInvitation] = useState<any>(null)
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null) const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false) const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false) const [showInviteModal, setShowInviteModal] = useState(false)
const loadTeam = async () => { // Refs für Dedupe/Drossel/Abbruch
setIsLoading(true) const currentTeamIdRef = useRef<string | null>(null)
const inflight = useRef<AbortController | null>(null)
const lastReloadAt = useRef<number>(0)
const lastInviteCheck = useRef<number>(0)
// Hilfs-Fetch: lädt Team und (falls kein Team) auch offene Invites
const fetchData = async (withSpinner: boolean) => {
// Drossel: mehrfache Events in kurzer Zeit zusammenfassen (400ms)
const now = Date.now()
if (!withSpinner && now - lastReloadAt.current < 400) return
lastReloadAt.current = now
// Parallel-Request abbrechen
if (inflight.current) inflight.current.abort()
const ac = new AbortController()
inflight.current = ac
try { try {
const res = await fetch('/api/team') if (withSpinner) setInitialLoading(true)
const res = await fetch('/api/team', { cache: 'no-store', signal: ac.signal })
const data = await res.json() const data = await res.json()
if (data.team) { if (ac.signal.aborted) return
setTeam(data.team)
setActivePlayers(data.team.activePlayers || [])
setInactivePlayers(data.team.inactivePlayers || [])
setInvitedPlayers(data.team.invitedPlayers || [])
} else {
setTeam(null)
}
// Einladung nur laden, wenn kein Team vorhanden if (data.team) {
if (!data.team) { // Nur setzen, wenn sich wirklich etwas geändert hat (verhindert Flackern)
const inviteRes = await fetch('/api/user/invitations') if (!eqTeam(team, data.team)) {
setTeam(data.team)
}
setPendingInvitation(null)
currentTeamIdRef.current = data.team.id
} else {
// Kein Team → optional Invites laden (aber nicht im Millisekundentakt)
currentTeamIdRef.current = null
if (Date.now() - lastInviteCheck.current > 1500) {
lastInviteCheck.current = Date.now()
const inviteRes = await fetch('/api/user/invitations', { cache: 'no-store', signal: ac.signal })
if (inviteRes.ok) { if (inviteRes.ok) {
const inviteData = await inviteRes.json() const inviteData = await inviteRes.json()
const teamInvite = inviteData.invitations?.find( const raw = (inviteData.invitations ?? []).find((i: any) => i.type === 'team-invite')
(i: any) => i.type === 'team-invite' const inv: Invitation | null = raw && raw.team ? { id: raw.id, team: raw.team } : null
) // nur setzen, wenn es sich ändert
setPendingInvitation(teamInvite ?? null) if ((pendingInvitation?.id ?? null) !== (inv?.id ?? null)) {
setPendingInvitation(inv)
} }
} }
} catch (err) { }
console.error('Fehler beim Laden des Teams:', err) if (team !== null) setTeam(null) // nur setzen, wenn nötig
}
} catch (e) {
if ((e as any)?.name !== 'AbortError') {
console.error('fetchData error:', e)
}
} finally { } finally {
setIsLoading(false) if (withSpinner) setInitialLoading(false)
if (inflight.current === ac) inflight.current = null
} }
} }
const handleMarkOneAsRead = async (id: string): Promise<void> => { // Initialer Load mit Spinner
await markOneAsRead(id)
}
useEffect(() => { useEffect(() => {
loadTeam() fetchData(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Parent-Refresh-Trigger (falls genutzt)
const [refetchKey, setRefetchKey] = useState<string>()
useEffect(() => {
if (refetchKey) fetchData(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refetchKey]) }, [refetchKey])
if (isLoading) return <LoadingSpinner /> // Auf SSE-Events reagieren → nur soft reload (kein Spinner)
useEffect(() => {
if (!lastEvent) return
const { type, payload } = lastEvent
// 1. Pending invitation // selbst entfernt/gekickt → reload
if (SELF_CLEAR_EVENTS.has(type)) {
fetchData(false)
return
}
// Team-Events: nur laden, wenn es das aktuelle Team betrifft (oder keine teamId angegeben → fail-safe)
if (TEAM_EVENTS.has(type)) {
fetchData(false)
return
}
// Invite-Events: nur interessant, wenn kein Team
if (INVITE_EVENTS.has(type) && !currentTeamIdRef.current) {
fetchData(false)
return
}
}, [lastEvent])
if (initialLoading) return <LoadingSpinner />
// 1) Pending Team-Einladung anzeigen (nur wenn kein Team vorhanden)
if (!team && pendingInvitation) { if (!team && pendingInvitation) {
const notificationId = pendingInvitation.id
return ( return (
<>
<TeamInvitationView <TeamInvitationView
invitation={pendingInvitation} invitation={pendingInvitation}
notificationId={notificationId} notificationId={pendingInvitation.id}
onMarkAsRead={handleMarkOneAsRead} onMarkAsRead={async () => {}}
onAction={async (action, invitationId) => { onAction={async (action) => {
if (action === 'accept') { try {
await acceptInvitation(invitationId) await fetch(`/api/user/invitations/${action}`, {
} else { method: 'POST',
await rejectInvitation(invitationId) headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invitationId: pendingInvitation.id,
teamId: pendingInvitation.team.id,
}),
})
} catch (e) {
console.error('Invite respond fehlgeschlagen:', e)
} finally {
// nach Aktion erneut prüfen (soft)
await fetchData(false)
} }
await markOneAsRead(notificationId)
await loadTeam()
}} }}
/> />
<NoTeamView />
</>
) )
} }
// 2. Kein Team // 2) Kein Team & keine Einladung
if (!team) { if (!team) {
return ( return (
<div className="p-6 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700 space-y-4"> <div className="p-6 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700 space-y-4">
@ -114,9 +215,9 @@ function TeamCardComponent(props: Props, ref: any) {
) )
} }
// 3. Team vorhanden // 3) Team vorhanden
return ( return (
<div className="p-5 md:p-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700"> <div>
<div className="mb-4 xl:mb-8"> <div className="mb-4 xl:mb-8">
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200"> <h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
Teameinstellungen Teameinstellungen
@ -129,10 +230,6 @@ function TeamCardComponent(props: Props, ref: any) {
<form> <form>
<TeamMemberView <TeamMemberView
team={team} team={team}
activePlayers={activePlayers}
inactivePlayers={inactivePlayers}
setactivePlayers={setActivePlayers}
setInactivePlayers={setInactivePlayers}
currentUserSteamId={steamId} currentUserSteamId={steamId}
adminMode={false} adminMode={false}
activeDragItem={activeDragItem} activeDragItem={activeDragItem}

View File

@ -1,8 +1,12 @@
// TeamInvitationView.tsx
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Button from './Button' import Button from './Button'
import { Invitation } from '../hooks/useTeamManager' import TeamPremierRankBadge from './TeamPremierRankBadge'
import type { Invitation } from '../types/invitation'
import type { Team } from '../types/team'
type Props = { type Props = {
invitation: Invitation invitation: Invitation
@ -17,44 +21,161 @@ export default function TeamInvitationView({
onAction, onAction,
onMarkAsRead, onMarkAsRead,
}: Props) { }: Props) {
const [isSubmitting, setIsSubmitting] = useState(false) const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState<'accept' | 'reject' | null>(null)
if (!invitation) return null if (!invitation?.team) return null
const team: Team = invitation.team
const targetHref = `/team/${team.id}`
const active = team.activePlayers ?? []
const inactive = team.inactivePlayers ?? []
const badgePlayers = active.length ? active : inactive
const handleRespond = async (action: 'accept' | 'reject') => { const handleRespond = async (action: 'accept' | 'reject') => {
if (isSubmitting) return
try { try {
setIsSubmitting(true) setIsSubmitting(action)
await onAction(action, invitation.id) await onAction(action, invitation.id)
await onMarkAsRead(notificationId) await onMarkAsRead(notificationId)
} catch (err) { } catch (err) {
console.error(`Fehler beim ${action === 'accept' ? 'Annehmen' : 'Ablehnen'} der Einladung:`, err) console.error(`[TeamInvitationView] ${action} failed:`, err)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(null)
} }
} }
// Klassen als Ausdruck (keine mehrzeiligen String-Literals im JSX)
const cardClasses =
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
'dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer ' +
'focus:outline-none ring-1 ring-green-500/15 hover:ring-green-500/25 mb-5';
return ( return (
<div className="p-4 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700"> <div
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4"> role="button"
Du wurdest eingeladen! tabIndex={0}
</h2> onClick={() => router.push(targetHref)}
<p className="text-gray-600 dark:text-gray-300 mb-6">{invitation.teamName}</p> onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
<div className="flex gap-2"> className={cardClasses}
<Button
onClick={() => handleRespond('accept')}
className="px-2 py-1 text-sm font-medium rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
disabled={isSubmitting}
> >
Annehmen {/* animierter, dezenter grüner Gradient */}
<div aria-hidden className="absolute inset-0 z-0 pointer-events-none invitationGradient" />
{/* Inhalt */}
<div className="relative z-[1] p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-3">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt={team.name ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
/>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
{team.name ?? 'Team'}
</span>
{badgePlayers.length > 0 && <TeamPremierRankBadge players={badgePlayers} />}
</div>
<span className="text-xs text-gray-600 dark:text-neutral-400">
Du wurdest in dieses Team eingeladen.
</span>
</div>
{/* Teammitglieder */}
<div className="flex -space-x-3">
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].map((p) => (
<img
key={p.steamId}
src={p.avatar}
alt={p.name}
title={p.name}
className="w-12 h-12 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
/>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Button
title="Ablehnen"
size="sm"
color="gray"
variant="ghost"
disabled={isSubmitting !== null}
onClick={(e) => {
e.stopPropagation()
handleRespond('reject')
}}
>
{isSubmitting === 'reject' ? (
<>
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
Ablehnen
</>
) : (
'Ablehnen'
)}
</Button> </Button>
<Button <Button
onClick={() => handleRespond('reject')} title="Annehmen"
className="px-2 py-1 text-sm font-medium rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50" size="sm"
disabled={isSubmitting} color="green"
variant="solid"
disabled={isSubmitting !== null}
onClick={(e) => {
e.stopPropagation()
handleRespond('accept')
}}
> >
Ablehnen {isSubmitting === 'accept' ? (
<>
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
Annehmen
</>
) : (
'Annehmen'
)}
</Button> </Button>
</div> </div>
</div> </div>
</div>
<style jsx>{`
/* kontinuierlich nach rechts schieben */
@keyframes slide-x {
from { background-position-x: 0%; }
to { background-position-x: 200%; }
}
.invitationGradient {
/* weicher, dezenter Verlauf */
background-image: repeating-linear-gradient(
90deg,
rgba(16,185,129,0.10) 0%,
rgba(16,185,129,0.06) 50%,
rgba(16,185,129,0.10) 100%
);
background-size: 200% 100%;
background-repeat: repeat-x; /* nahtlos, kein “Sprung” am Loop */
animation: slide-x 20s linear infinite; /* langsam, konstant, endlos */
}
:global(.dark) .invitationGradient {
background-image: repeating-linear-gradient(
90deg,
rgba(16,185,129,0.18) 0%,
rgba(16,185,129,0.08) 50%,
rgba(16,185,129,0.18) 100%
);
}
@media (prefers-reduced-motion: reduce) {
.invitationGradient { animation: none; }
}
`}</style>
</div>
) )
} }

View File

@ -1,7 +1,6 @@
// TeamMemberView.tsx
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core' import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { DroppableZone } from './DroppableZone' import { DroppableZone } from './DroppableZone'
@ -11,23 +10,19 @@ import SortableMiniCard from './SortableMiniCard'
import LeaveTeamModal from './LeaveTeamModal' import LeaveTeamModal from './LeaveTeamModal'
import InvitePlayersModal from './InvitePlayersModal' import InvitePlayersModal from './InvitePlayersModal'
import Modal from './Modal' import Modal from './Modal'
import { Player, Team } from '../types/team' import { Player } from '../types/team'
import { useSession } from 'next-auth/react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { import { leaveTeam, reloadTeam, renameTeam, revokeInvitation } from '@/app/lib/sse-actions'
leaveTeam,
reloadTeam,
renameTeam,
revokeInvitation,
} from '@/app/lib/sse-actions'
import Button from './Button' import Button from './Button'
import Image from 'next/image' import Image from 'next/image'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
import Link from 'next/link' import Link from 'next/link'
import { Team } from '../types/team'
import { useTeamStore } from '../lib/stores' import { useTeamStore } from '../lib/stores'
import { useSSEStore } from '@/app/lib/useSSEStore'
type Props = { type Props = {
team: Team team?: Team
activeDragItem: Player | null activeDragItem: Player | null
isDragging: boolean isDragging: boolean
showLeaveModal: boolean showLeaveModal: boolean
@ -40,14 +35,15 @@ type Props = {
adminMode?: boolean adminMode?: boolean
} }
type InvitedPlayer = Player & { invitationId: string } type InvitedPlayer = Player & { invitationId?: string }
export default function TeamMemberView({ export default function TeamMemberView({
team, team: teamProp,
activeDragItem, activeDragItem,
isDragging, isDragging,
showLeaveModal, showLeaveModal,
showInviteModal, showInviteModal,
currentUserSteamId,
setShowLeaveModal, setShowLeaveModal,
setShowInviteModal, setShowInviteModal,
setActiveDragItem, setActiveDragItem,
@ -55,55 +51,146 @@ export default function TeamMemberView({
adminMode = false, adminMode = false,
}: Props) { }: Props) {
const { data: session } = useSession() const { team: storeTeam, setTeam } = useTeamStore()
const currentUserSteamId = session?.user?.steamId || '' const team = teamProp ?? storeTeam
const isLeader = currentUserSteamId === team?.leader if (!team) return null
const isLeader = currentUserSteamId === team.leader
const canManage = adminMode || isLeader const canManage = adminMode || isLeader
const canInvite = isLeader && !adminMode const canInvite = isLeader && !adminMode
const canAddDirect= 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, disconnect, lastEvent, isConnected } = useSSEStore()
const [activePlayers, setActivePlayers] = useState<Player[]>([]) const [activePlayers, setActivePlayers] = useState<Player[]>([])
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([]) const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
const [invitedPlayers, setInvitedPlayers] = useState<InvitedPlayer[]>([])
const [kickCandidate, setKickCandidate] = useState<Player | null>(null) const [kickCandidate, setKickCandidate] = useState<Player | null>(null)
const [promoteCandidate, setPromoteCandidate] = useState<Player | null>(null) const [promoteCandidate, setPromoteCandidate] = useState<Player | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isEditingName, setIsEditingName] = useState(false) const [isEditingName, setIsEditingName] = useState(false)
const [editedName, setEditedName] = useState(team?.name || '') const [editedName, setEditedName] = useState(team.name || '')
const [teamState, setTeamState] = useState<Team | null>(team)
const [saveSuccess, setSaveSuccess] = useState(false) const [saveSuccess, setSaveSuccess] = useState(false)
const [invitedPlayers, setInvitedPlayers] = useState<InvitedPlayer[]>([])
console.log('[TeamMemberView] team from store:', team) // 1) SSE-Verbindung sicherstellen
console.log('[TeamMemberView] teamState:', teamState) useEffect(() => {
console.log('[TeamMemberView] activePlayers:', activePlayers) if (!currentUserSteamId) return
console.log('[TeamMemberView] inactivePlayers:', inactivePlayers) if (!isConnected) connect(currentUserSteamId)
return () => {
// Falls du global verbunden bleiben willst: disconnect() hier weglassen
// disconnect()
}
}, [currentUserSteamId, connect, isConnected])
useEffect(() => { useEffect(() => {
if (!team || !team.id) return if (!team) return
setTeamState(team) const nextActive = (team.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
setEditedName(team.name || '') const nextInactive = (team.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
setActivePlayers(team.activePlayers) const nextInvited = Array.from(
setInactivePlayers(team.inactivePlayers)
const uniqueInvites = Array.from(
new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values() new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values()
) ).sort((a,b)=>a.name.localeCompare(b.name))
setInvitedPlayers(uniqueInvites.sort((a, b) => a.name.localeCompare(b.name)))
}, [team?.id]) // wenn nichts geändert: nichts tun (vermeidet Flicker)
const unchanged =
eqByIds(activePlayers, nextActive) &&
eqByIds(inactivePlayers, nextInactive) &&
eqByIds(invitedPlayers, nextInvited)
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])
// 2) Auf ALLE relevanten Events reagieren
useEffect(() => {
if (!lastEvent || !team?.id) return
const RELEVANT = new Set([
'team-updated',
'team-leader-changed',
'team-member-joined',
'team-member-left',
'team-kick',
'team-kick-other',
'team-left',
'team-renamed',
'team-logo-updated',
])
if (!RELEVANT.has(lastEvent.type)) return
const payload = lastEvent.payload ?? {}
// meistens ist teamId dabei falls einzelne Events andere Felder nutzen,
// hier entsprechend ergänzen (z. B. payload.oldTeamId/payload.newTeamId).
if (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) {
// 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)
setRemountKey(k => k + 1)
})()
}, [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 handleDragStart = (event: any) => {
const id = event.active.id const id = event.active.id
const item = activePlayers.find(p => p.steamId === id) || inactivePlayers.find(p => p.steamId === id) const item =
activePlayers.find(p => p.steamId === id) ||
inactivePlayers.find(p => p.steamId === id)
if (item) { if (item) {
setActiveDragItem(item) setActiveDragItem(item)
setIsDragging(true) setIsDragging(true)
isDraggingRef.current = true
} }
} }
const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => { const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => {
try { try {
await fetch('/api/team/update-players', { const res = await fetch('/api/team/update-players', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -112,82 +199,94 @@ export default function TeamMemberView({
inactivePlayers: inactive.map(p => p.steamId), inactivePlayers: inactive.map(p => p.steamId),
}), }),
}) })
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) { } catch (err) {
console.error('Fehler beim Aktualisieren:', err) console.error('Fehler beim Aktualisieren:', err)
} }
} }
const handleDragEnd = async (event: any) => { const handleDragEnd = async (event: any) => {
setActiveDragItem(null) setActiveDragItem(null)
setIsDragging(false) setIsDragging(false)
isDraggingRef.current = false
const { active, over } = event const { active, over } = event
if (!over) return if (!over) return
const activeId = String(active.id)
const activeId = active.id const overId = String(over.id)
const overId = over.id
if (!activeId || !overId) return
const movingItem = [...activePlayers, ...inactivePlayers].find(p => p.steamId === activeId) const movingItem = [...activePlayers, ...inactivePlayers].find(p => p.steamId === activeId)
if (!movingItem) return if (!movingItem) return
const isInActiveZone = activePlayers.some(p => p.steamId === activeId) const dropToActive =
const isInInactiveZone = inactivePlayers.some(p => p.steamId === activeId) overId === 'active' || activePlayers.some(p => p.steamId === overId)
const targetIsActiveZone = overId === 'active' || activePlayers.some(p => p.steamId === overId) let nextActive = [...activePlayers]
const targetIsInactiveZone = overId === 'inactive' || inactivePlayers.some(p => p.steamId === overId) let nextInactive = [...inactivePlayers]
if ((isInActiveZone && targetIsActiveZone) || (isInInactiveZone && targetIsInactiveZone)) return if (dropToActive) {
if (nextActive.length >= 5) return
let newActive = [...activePlayers] nextInactive = nextInactive.filter(p => p.steamId !== activeId)
let newInactive = [...inactivePlayers] if (!nextActive.some(p => p.steamId === activeId)) nextActive.push(movingItem)
if (targetIsActiveZone) {
if (newActive.length >= 5) return
newInactive = newInactive.filter(p => p.steamId !== activeId)
if (!newActive.some(p => p.steamId === activeId)) newActive.push(movingItem)
} else { } else {
newActive = newActive.filter(p => p.steamId !== activeId) nextActive = nextActive.filter(p => p.steamId !== activeId)
if (!newInactive.some(p => p.steamId === activeId)) newInactive.push(movingItem) if (!nextInactive.some(p => p.steamId === activeId)) nextInactive.push(movingItem)
} }
newActive.sort((a, b) => a.name.localeCompare(b.name)) nextActive.sort((a,b)=>a.name.localeCompare(b.name))
newInactive.sort((a, b) => a.name.localeCompare(b.name)) nextInactive.sort((a,b)=>a.name.localeCompare(b.name))
// ✅ Optimistisches UI: nur lokale DnD-States updaten
setActivePlayers(nextActive)
setInactivePlayers(nextInactive)
// 🔔 Server informieren (SSE triggert andere Clients)
updateTeamMembers(team.id, nextActive, nextInactive).catch(console.error)
setActivePlayers(newActive)
setInactivePlayers(newInactive)
await updateTeamMembers(team!.id, newActive, newInactive)
setSaveSuccess(true) setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 3000) // 3 Sekunden sichtbar setTimeout(()=>setSaveSuccess(false), 3000)
// 📨 Falls während des Drags ein Remote-Update kam → jetzt anwenden
if (pendingRemote) {
// nur übernehmen, wenn abweichend (optional)
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)
}
} }
// Wenn du von hier manuell reloaden willst, dann Store updaten (nicht lokalen State)
const handleReload = async () => { const handleReload = async () => {
if (!teamState?.id) return const updated = await reloadTeam(team.id)
const updated = await reloadTeam(teamState.id) if (updated) setTeam(updated)
if (!updated) return
setTeamState(updated)
setActivePlayers(updated.activePlayers)
setInactivePlayers(updated.inactivePlayers)
setInvitedPlayers(updated.invitedPlayers)
} }
const confirmKick = async () => { const confirmKick = async () => {
if (!kickCandidate) return if (!kickCandidate) return
const newActive = activePlayers.filter(p => p.steamId !== kickCandidate.steamId) const newActive = activePlayers.filter(p => p.steamId !== kickCandidate.steamId)
const newInactive = inactivePlayers.filter(p => p.steamId !== kickCandidate.steamId) const newInactive = inactivePlayers.filter(p => p.steamId !== kickCandidate.steamId)
setActivePlayers(newActive) setActivePlayers(newActive)
setInactivePlayers(newInactive) setInactivePlayers(newInactive)
await fetch('/api/team/kick', { await fetch('/api/team/kick', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steamId: kickCandidate.steamId, teamId: team!.id }), body: JSON.stringify({ steamId: kickCandidate.steamId, teamId: team.id }),
}) })
await updateTeamMembers(team.id, newActive, newInactive)
await updateTeamMembers(team!.id, newActive, newInactive)
setKickCandidate(null) setKickCandidate(null)
} }
@ -196,25 +295,22 @@ export default function TeamMemberView({
const res = await fetch('/api/team/transfer-leader', { const res = await fetch('/api/team/transfer-leader', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team!.id, newLeaderSteamId: newLeaderId }), body: JSON.stringify({ teamId: team.id, newLeaderSteamId: newLeaderId }),
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
console.error('Fehler bei Leader-Übertragung:', data.message) console.error('Fehler bei Leader-Übertragung:', data.message)
return return
} }
await handleReload() await handleReload()
} catch (err) { } catch (err) {
console.error('Fehler bei Leader-Übertragung:', err) console.error('Fehler bei Leader-Übertragung:', err)
} }
} }
if (!teamState) return null
if (!adminMode && !currentUserSteamId) return null if (!adminMode && !currentUserSteamId) return null
const manageSteam: string = adminMode ? teamState.leader ?? '' : currentUserSteamId const manageSteam: string = adminMode ? (team.leader ?? '') : currentUserSteamId
const renderMemberList = (players: Player[]) => ( const renderMemberList = (players: Player[]) => (
<AnimatePresence> <AnimatePresence>
@ -227,24 +323,18 @@ export default function TeamMemberView({
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className='max-w-[160px]' className='max-w-[160px]'
> >
{/* ✨ Link zur Profil-Seite */}
<Link <Link
href={`/profile/${player.steamId}`} // ⇦ dein Profil-Slug href={`/profile/${player.steamId}`}
passHref // nur nötig, falls du <a> verwendest passHref
onClick={e => { onClick={e => { if (isDragging) e.preventDefault() }}
/* Wenn gerade gezogen wird → Navigation verhindern */
if (isDragging) e.preventDefault()
}}
> >
{/* Wichtig: SortableMiniCard selbst bleibt Drag-Handle,
deshalb keinen weiteren Wrapper mehr einziehen. */}
<SortableMiniCard <SortableMiniCard
player={player} player={player}
onKick={setKickCandidate} onKick={setKickCandidate}
onPromote={() => setPromoteCandidate(player)} onPromote={() => setPromoteCandidate(player)}
currentUserSteamId={manageSteam} currentUserSteamId={manageSteam}
teamLeaderSteamId={teamState.leader} teamLeaderSteamId={team.leader}
isAdmin={!!session?.user?.isAdmin} isAdmin={adminMode}
isDraggingGlobal={isDragging} isDraggingGlobal={isDragging}
hideOverlay={isDragging} hideOverlay={isDragging}
matchParentBg matchParentBg
@ -259,14 +349,13 @@ export default function TeamMemberView({
<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={`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 justify-between items-center mb-6 flex-wrap gap-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Teamlogo mit Fallback */}
<div className="relative group"> <div className="relative group">
<div <div
className="relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600 cursor-pointer" 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 && document.getElementById('logoUpload')?.click()}
> >
<Image <Image
src={teamState.logo ? `/assets/img/logos/${teamState.logo}` : `/assets/img/logos/cs2.webp`} src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt="Teamlogo" alt="Teamlogo"
fill fill
sizes="64px" sizes="64px"
@ -274,23 +363,16 @@ export default function TeamMemberView({
className="object-cover" className="object-cover"
priority={false} priority={false}
/> />
{/* Overlay beim Hover */}
{canManage && ( {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"> <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">
<svg {/* Icon */}
xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 mb-1" viewBox="0 0 576 512" fill="currentColor">
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"/> <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> </svg>
</div> </div>
)} )}
</div> </div>
{/* Hidden file input */}
{canManage && ( {canManage && (
<input <input
type="file" type="file"
@ -300,27 +382,17 @@ export default function TeamMemberView({
onChange={async (e) => { onChange={async (e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
const formData = new FormData() const formData = new FormData()
formData.append('logo', file) formData.append('logo', file)
formData.append('teamId', teamState.id) formData.append('teamId', team.id)
const res = await fetch('/api/team/upload-logo', { method: 'POST', body: formData })
const res = await fetch('/api/team/upload-logo', { if (res.ok) await handleReload()
method: 'POST', else alert('Fehler beim Hochladen des Logos.')
body: formData,
})
if (res.ok) {
await handleReload()
} else {
alert('Fehler beim Hochladen des Logos.')
}
}} }}
/> />
)} )}
</div> </div>
{/* Teamname + Bearbeiten */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isEditingName ? ( {isEditingName ? (
<> <>
@ -330,36 +402,20 @@ export default function TeamMemberView({
onChange={(e) => setEditedName(e.target.value)} 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" className="py-1.5 px-3 border rounded-lg text-sm dark:bg-neutral-800 dark:border-neutral-700 dark:text-white"
/> />
{/* ✔ Übernehmen */}
<Button <Button
title="Übernehmen" title="Übernehmen"
color="green" color="green"
size="sm" size="sm"
variant="soft" variant="soft"
onClick={async () => { onClick={async () => {
await renameTeam(teamState.id, editedName) await renameTeam(team.id, editedName)
setTeamState((prev) => prev ? { ...prev, teamname: editedName } : prev)
setIsEditingName(false) setIsEditingName(false)
await handleReload() await handleReload()
}} }}
className="h-[34px] px-3 flex items-center justify-center" className="h-[34px] px-3 flex items-center justify-center"
> >
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 00-1.414 0L8 12.586 4.707 9.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 000-1.414z"
clipRule="evenodd"
/>
</svg>
</Button> </Button>
{/* ✖ Abbrechen */}
<Button <Button
title="Abbrechen" title="Abbrechen"
color="red" color="red"
@ -367,29 +423,18 @@ export default function TeamMemberView({
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setIsEditingName(false) setIsEditingName(false)
setEditedName(teamState.name ?? '') setEditedName(team.name ?? '')
}} }}
className="h-[34px] px-3 flex items-center justify-center" className="h-[34px] px-3 flex items-center justify-center"
> >
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white"> <h2 className="text-lg font-semibold text-gray-800 dark:text-white">
{teamState.name ?? 'Team'} {team.name ?? 'Team'}
</h2> </h2>
<TeamPremierRankBadge players={activePlayers} /> <TeamPremierRankBadge players={activePlayers} />
</div> </div>
@ -401,18 +446,11 @@ export default function TeamMemberView({
variant="soft" variant="soft"
onClick={() => { onClick={() => {
setIsEditingName(true) setIsEditingName(true)
setEditedName(teamState.name || '') setEditedName(team.name || '')
}} }}
className="h-[34px] px-3 flex items-center justify-center" className="h-[34px] px-3 flex items-center justify-center"
> >
<svg Bearbeiten
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M17.414 2.586a2 2 0 010 2.828l-9.793 9.793a1 1 0 01-.293.207l-4 2a1 1 0 01-1.32-1.32l2-4a1 1 0 01.207-.293l9.793-9.793a2 2 0 012.828 0zM15.586 4L6 13.586l-1.293 2.586L7.414 14 17 4.414 15.586 4z" />
</svg>
</Button> </Button>
)} )}
</> </>
@ -420,7 +458,6 @@ export default function TeamMemberView({
</div> </div>
</div> </div>
{/* Aktionen */}
<div className="flex gap-2"> <div className="flex gap-2">
{canManage && ( {canManage && (
<button <button
@ -437,11 +474,7 @@ export default function TeamMemberView({
if (isLeader) { if (isLeader) {
setShowLeaveModal(true) setShowLeaveModal(true)
} else { } else {
try { try { await leaveTeam(currentUserSteamId) } catch (err) { console.error('Fehler beim Verlassen:', err) }
await leaveTeam(currentUserSteamId)
} catch (err) {
console.error('Fehler beim Verlassen:', err)
}
} }
}} }}
className="text-sm px-3 py-1.5 bg-gray-200 text-black rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600" className="text-sm px-3 py-1.5 bg-gray-200 text-black rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600"
@ -451,16 +484,16 @@ export default function TeamMemberView({
</div> </div>
</div> </div>
<DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <DndContext key={`dnd-${team.id}-${remountKey}`} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8"> <div className="space-y-8">
<DroppableZone id="active" label={`Aktive Spieler (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}> <DroppableZone id="active" label={`Aktive Spieler (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<SortableContext items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}> <SortableContext key={`sc-active-${remountKey}-${activePlayers.map(p=>p.steamId).join(',')}`} items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(activePlayers)} {renderMemberList(activePlayers)}
</SortableContext> </SortableContext>
</DroppableZone> </DroppableZone>
<DroppableZone id="inactive" label="Inaktive Spieler" activeDragItem={activeDragItem} saveSuccess={saveSuccess}> <DroppableZone id="inactive" label="Inaktive Spieler" activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}> <SortableContext key={`sc-inactive-${remountKey}-${inactivePlayers.map(p=>p.steamId).join(',')}`} items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(inactivePlayers)} {renderMemberList(inactivePlayers)}
{canManage && ( {canManage && (
<motion.div key="mini-card-dummy" initial={{ opacity: 0 }} animate={{ opacity: 1}} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}> <motion.div key="mini-card-dummy" initial={{ opacity: 0 }} animate={{ opacity: 1}} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
@ -472,7 +505,7 @@ export default function TeamMemberView({
}} }}
> >
<div className="flex items-center justify-center w-16 h-16 bg-white rounded-full"> <div className="flex items-center justify-center w-16 h-16 bg-white rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8 text-black" fill="currentColor" viewBox="0 0 640 512"> <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" /> <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> </svg>
</div> </div>
@ -491,13 +524,7 @@ export default function TeamMemberView({
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]"> <div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
<AnimatePresence> <AnimatePresence>
{invitedPlayers.map((player: InvitedPlayer) => ( {invitedPlayers.map((player: InvitedPlayer) => (
<motion.div <motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
key={player.steamId}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<MiniCard <MiniCard
steamId={player.steamId} steamId={player.steamId}
title={player.name} title={player.name}
@ -507,12 +534,12 @@ export default function TeamMemberView({
onSelect={() => {}} onSelect={() => {}}
draggable={false} draggable={false}
currentUserSteamId={currentUserSteamId} currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader} teamLeaderSteamId={team.leader}
isSelectable={false} isSelectable={false}
isInvite={true} isInvite={true}
rank={player.premierRank} rank={player.premierRank}
onKick={revokeInvitation} onKick={revokeInvitation}
invitationId={player.invitationId} invitationId={(player as any).invitationId}
/> />
</motion.div> </motion.div>
))} ))}
@ -528,8 +555,8 @@ export default function TeamMemberView({
<SortableMiniCard <SortableMiniCard
player={activeDragItem} player={activeDragItem}
currentUserSteamId={currentUserSteamId} currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader} teamLeaderSteamId={team.leader}
isAdmin={!!session?.user?.isAdmin} isAdmin={adminMode}
hideOverlay hideOverlay
matchParentBg matchParentBg
/> />
@ -537,37 +564,33 @@ export default function TeamMemberView({
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
{/* Modal(s) */}
{canInvite && ( {canInvite && (
<InvitePlayersModal <InvitePlayersModal
show={showInviteModal} show={showInviteModal}
onClose={() => setShowInviteModal(false)} onClose={() => setShowInviteModal(false)}
onSuccess={() => {}} onSuccess={() => {}}
team={teamState} team={team}
/> />
)} )}
{canAddDirect && ( {canAddDirect && (
<InvitePlayersModal <InvitePlayersModal
show={showInviteModal} show={showInviteModal}
onClose={() => setShowInviteModal(false)} onClose={() => setShowInviteModal(false)}
onSuccess={() => {}} onSuccess={() => {}}
team={teamState} team={team}
directAdd directAdd
/> />
)} )}
{/* Leader-spezifische Modale (z. B. Team verlassen) */}
{isLeader && ( {isLeader && (
<LeaveTeamModal <LeaveTeamModal
show={showLeaveModal} show={showLeaveModal}
onClose={() => setShowLeaveModal(false)} onClose={() => setShowLeaveModal(false)}
onSuccess={() => setShowLeaveModal(false)} onSuccess={() => setShowLeaveModal(false)}
team={teamState} team={team}
/> />
)} )}
{canManage && promoteCandidate && ( {canManage && promoteCandidate && (
<Modal <Modal
id={`modal-promote-player-${promoteCandidate.steamId}`} id={`modal-promote-player-${promoteCandidate.steamId}`}
@ -581,7 +604,6 @@ export default function TeamMemberView({
closeButtonTitle="Übertragen" closeButtonTitle="Übertragen"
closeButtonColor="blue" closeButtonColor="blue"
> >
{/* ► PlayerCard des Kandidaten */}
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<MiniCard <MiniCard
steamId={promoteCandidate.steamId} steamId={promoteCandidate.steamId}
@ -592,12 +614,11 @@ export default function TeamMemberView({
onSelect={() => {}} onSelect={() => {}}
draggable={false} draggable={false}
currentUserSteamId={currentUserSteamId} currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader} teamLeaderSteamId={team.leader}
hideActions hideActions
isSelectable={false} isSelectable={false}
/> />
</div> </div>
<p className="text-sm text-gray-700 dark:text-neutral-300"> <p className="text-sm text-gray-700 dark:text-neutral-300">
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen? Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
</p> </p>
@ -614,7 +635,6 @@ export default function TeamMemberView({
closeButtonTitle="Entfernen" closeButtonTitle="Entfernen"
closeButtonColor="red" closeButtonColor="red"
> >
{/* ► PlayerCard des Kandidaten */}
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<MiniCard <MiniCard
steamId={kickCandidate.steamId} steamId={kickCandidate.steamId}
@ -625,12 +645,11 @@ export default function TeamMemberView({
onSelect={() => {}} onSelect={() => {}}
draggable={false} draggable={false}
currentUserSteamId={currentUserSteamId} currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader} teamLeaderSteamId={team.leader}
hideActions hideActions
isSelectable={false} isSelectable={false}
/> />
</div> </div>
<p className="text-sm text-gray-700 dark:text-neutral-300"> <p className="text-sm text-gray-700 dark:text-neutral-300">
Möchtest du <strong>{kickCandidate.name}</strong> wirklich aus dem Team entfernen? Möchtest du <strong>{kickCandidate.name}</strong> wirklich aus dem Team entfernen?
</p> </p>
@ -647,7 +666,7 @@ export default function TeamMemberView({
await fetch('/api/team/delete', { await fetch('/api/team/delete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: teamState.id }), body: JSON.stringify({ teamId: team.id }),
}) })
setShowDeleteModal(false) setShowDeleteModal(false)
window.location.href = '/' window.location.href = '/'

View File

@ -75,9 +75,9 @@ export default function AdminTeamsView() {
/* ─────────────────────────── Render ─────────────────────────── */ /* ─────────────────────────── Render ─────────────────────────── */
if (loading) { if (loading) {
return ( return (
<p className="text-gray-500 dark:text-gray-400"> <div className="text-gray-500 dark:text-gray-400">
<LoadingSpinner /> <LoadingSpinner />
</p> </div>
) )
} }
@ -96,9 +96,9 @@ export default function AdminTeamsView() {
{/* Team-Grid */} {/* Team-Grid */}
{teams.length === 0 ? ( {teams.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400"> <div className="text-gray-500 dark:text-gray-400">
Es wurden noch keine Teams erstellt. Es wurden noch keine Teams erstellt.
</p> </div>
) : ( ) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{teams.map(t => ( {teams.map(t => (

View File

@ -8,7 +8,7 @@ import LatestKnownCodeSettings from "./account/ShareCodeSettings"
export default function AccountSettings() { export default function AccountSettings() {
return ( return (
<>{/* Account Card */} <>{/* Account Card */}
<div className="p-5 md:p-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700"> <div className="">
{/* Title */} {/* Title */}
<div className="mb-4 xl:mb-8"> <div className="mb-4 xl:mb-8">
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200"> <h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">

View File

@ -1,56 +1,97 @@
// SSEHandler.tsx
'use client' 'use client'
import { useEffect } from 'react' import { useEffect, useRef, startTransition } from 'react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { reloadTeam } from '@/app/lib/sse-actions' import { reloadTeam } from '@/app/lib/sse-actions'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useTeamStore } from '@/app/lib/stores' import { useTeamStore } from '@/app/lib/stores'
import { TEAM_EVENTS, SELF_EVENTS, SSEEventType } from '@/app/lib/sseEvents'
export default function SSEHandler() { export default function SSEHandler() {
const { data: session } = useSession() const { data: session, status } = useSession()
const steamId = session?.user?.steamId const steamId = session?.user?.steamId ?? null
const router = useRouter() const router = useRouter()
const { setTeam } = useTeamStore() const { setTeam, team } = useTeamStore()
const { connect, disconnect, lastEvent, source } = useSSEStore()
const { connect, lastEvent } = useSSEStore()
// nur verbinden, wenn eingeloggt & steamId vorhanden
const prevSteamId = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
if (steamId) connect(steamId) if (status !== 'authenticated' || !steamId) {
}, [steamId]) // getrennt halten, wenn ausgeloggt / keine ID
if (source) disconnect()
prevSteamId.current = null
return
}
if (prevSteamId.current !== steamId) {
connect(steamId) // useSSEStore verhindert Doppel-Connect
prevSteamId.current = steamId
}
// bewusst KEIN disconnect() im Cleanup, damit global verbunden bleibt
}, [status, steamId, connect, disconnect, source])
// parallele Reloads pro Team vermeiden
const reloadInFlight = useRef<Set<string>>(new Set())
// alte/duplizierte Events filtern
const lastHandledTs = useRef<number>(0)
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
const { type, payload, ts } = lastEvent
if (ts && ts <= lastHandledTs.current) return
lastHandledTs.current = ts ?? Date.now()
const { type, payload } = lastEvent const teamId: string | undefined = payload?.teamId
const handleEvent = async () => { const reloadIfNeeded = async (tid?: string) => {
switch (type) { if (!tid) return
case 'team-updated': // nur reloaden, wenn es auch das aktuell angezeigte Team ist
if (payload.teamId) { if (team?.id && tid !== team.id) return
const updated = await reloadTeam(payload.teamId) if (reloadInFlight.current.has(tid)) return
reloadInFlight.current.add(tid)
try {
const updated = await reloadTeam(tid)
if (updated) { if (updated) {
setTeam(updated) // ✅ zentral setzen // nicht render-blockierend updaten
startTransition(() => setTeam(updated))
} }
} } catch (e) {
break console.error('[SSE] reloadTeam failed:', e)
} finally {
case 'team-kick-other': reloadInFlight.current.delete(tid)
console.log('[SSE] Du wurdest gekickt')
router.push('/')
break
case 'team-left':
console.log('[SSE] Jemand hat das Team verlassen')
break
default:
console.log('[SSE] Unbehandeltes Event:', type, payload)
} }
} }
handleEvent() const handleSelfExit = () => {
}, [lastEvent]) // nur handeln, wenn wir noch ein Team im Store haben
if (team) {
return null // kein UI startTransition(() => setTeam(null as any))
// replace statt push, um History nicht zu fluten
router.replace('/team')
}
}
(async () => {
// 1) Team-relevante Events → evtl. Reload
if (TEAM_EVENTS.has(type as SSEEventType)) {
await reloadIfNeeded(teamId)
return
}
// 2) Self-Events → Store leeren + redirect
if (SELF_EVENTS.has(type as SSEEventType)) {
handleSelfExit()
return
}
// 3) alles andere (Notifications etc.) hier ignorieren
// -> werden in anderen Komponenten ausgewertet
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEvent, team?.id, setTeam, router])
return null
} }

View File

@ -6,15 +6,20 @@ import { Player, Team, InvitedPlayer } from '../types/team'
export async function reloadTeam(teamId: string): Promise<Team | null> { export async function reloadTeam(teamId: string): Promise<Team | null> {
try { try {
console.log("reloadTeam"); console.log("reloadTeam");
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}`) const res = await fetch(
`/api/team/${encodeURIComponent(teamId)}?t=${Date.now()}`,
{ cache: 'no-store' }
)
if (!res.ok) throw new Error('Fehler beim Abrufen des Teams') if (!res.ok) throw new Error('Fehler beim Abrufen des Teams')
const data = await res.json() const data = await res.json()
const team = data.team ?? data const team = data.team ?? data
if (!team) return null if (!team) return null
const sortByName = <T extends Player>(players: T[]): T[] => const sortByName = <T extends Player>(arr: T[]) =>
players.sort((a, b) => a.name.localeCompare(b.name)) [...arr].sort((a, b) => a.name.localeCompare(b.name));
console.log("reloadTeam:", data);
return { return {
id: team.id, id: team.id,

105
src/app/lib/sseEvents.ts Normal file
View File

@ -0,0 +1,105 @@
// sseEvents.ts
export const SSE_EVENT_TYPES = [
// Kanonisch
'team-updated',
'team-leader-changed',
'team-leader-self', // ⬅️ neu
'team-renamed',
'team-logo-updated',
'team-member-joined',
'team-member-left',
'team-member-kicked',
'team-kick-self',
'team-left-self',
'user-team-cleared',
'notification',
'invitation',
'team-invite',
'team-join-request',
'team-joined', // ⬅️ neu (eigene Bestätigung)
'expired-sharecode',
// optional/robust, nur falls noch emittiert:
// 'team-invite-reject',
// 'team-join-request-reject',
] as const;
export type SSEEventType = typeof SSE_EVENT_TYPES[number];
/** Legacy-Namen, die noch von alten Endpunkten kommen können */
export const SSE_LEGACY_EVENT_TYPES = [
'team-kick',
'team-kick-other',
'team-left',
] as const;
export type SSELegacyEventType = typeof SSE_LEGACY_EVENT_TYPES[number];
/** Type Guards */
export function isSseEventType(x: unknown): x is SSEEventType {
return typeof x === 'string' && (SSE_EVENT_TYPES as readonly string[]).includes(x as any);
}
export function isLegacyType(x: unknown): x is SSELegacyEventType {
return typeof x === 'string' && (SSE_LEGACY_EVENT_TYPES as readonly string[]).includes(x as any);
}
/** Sinnvolle Gruppen */
export const TEAM_EVENTS = new Set<SSEEventType>([
'team-updated',
'team-leader-changed',
'team-renamed',
'team-logo-updated',
'team-member-joined',
'team-member-left',
'team-member-kicked',
]);
export const SELF_EVENTS = new Set<SSEEventType>([
'team-kick-self',
'team-left-self',
'user-team-cleared',
'team-leader-self', // ⬅️ neu (nur für den neuen Leader relevant)
]);
// Nur die Event-Typen, die das NotificationCenter betreffen (Preview/Live-Dropdown)
export const NOTIFICATION_EVENTS = new Set([
'notification',
'invitation',
'team-invite',
'team-join-request',
'expired-sharecode',
// ⬇️ damit „… ist deinem Team beigetreten“ & Leader-Änderungen live erscheinen
'team-member-joined',
'team-joined',
'team-leader-changed',
'team-leader-self',
// optional/robust:
// 'team-invite-reject',
// 'team-join-request-reject',
]);
/** Legacy → Kanonisch normalisieren */
export function normalizeEventType(
incoming: string,
payload: any,
selfSteamId?: string | null,
): SSEEventType | null {
if (isSseEventType(incoming)) return incoming;
if (!isLegacyType(incoming)) return null;
switch (incoming) {
case 'team-kick':
return 'team-kick-self';
case 'team-kick-other':
return 'team-member-kicked';
case 'team-left':
if (payload?.userId && selfSteamId && payload.userId === selfSteamId) {
return 'team-left-self';
}
return 'team-member-left';
default:
return null;
}
}

View File

@ -1,86 +1,115 @@
// useSSEStore.ts // useSSEStore.ts
import { create } from 'zustand' 'use client';
import { create } from 'zustand';
import {
SSE_EVENT_TYPES,
SSEEventType,
isSseEventType,
normalizeEventType,
} from '@/app/lib/sseEvents';
type SSEEvent = { type SSEEvent = {
type: string type: SSEEventType;
payload: any payload: any;
} ts: number;
};
type SSEState = { type SSEState = {
source: EventSource | null source: EventSource | null;
isConnected: boolean isConnected: boolean;
lastEvent: SSEEvent | null steamId: string | null;
connect: (steamId: string) => void lastEvent: SSEEvent | null;
disconnect: () => void connect: (steamId: string) => void;
} disconnect: () => void;
};
export const useSSEStore = create<SSEState>((set, get) => { export const useSSEStore = create<SSEState>((set, get) => {
let reconnectTimeout: NodeJS.Timeout | null = null let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
const connect = (steamId: string) => { const clearReconnect = () => {
if (get().source) return if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
const source = new EventSource(`http://localhost:3001/events?steamId=${steamId}`) reconnectTimeout = null;
const eventTypes = [
'team-updated',
'team-leader-changed',
'team-member-joined',
'team-member-left',
'team-kick',
'team-kick-other',
'team-left',
'team-renamed',
'team-logo-updated',
// ... beliebig erweiterbar
]
for (const type of eventTypes) {
source.addEventListener(type, (event) => {
try {
const data = JSON.parse((event as MessageEvent).data)
console.log(`[SSE] ${type}:`, data)
set({ lastEvent: { type, payload: data } })
} catch (err) {
console.error(`[SSE] Fehler beim Parsen von ${type}:`, err)
}
})
}
source.onerror = (err) => {
console.warn('[SSE] Fehler, versuche Reconnect...')
source.close()
set({ source: null, isConnected: false })
if (!reconnectTimeout) {
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null
connect(steamId)
}, 3000)
}
}
source.onopen = () => {
console.log('[SSE] Verbunden!')
set({ source, isConnected: true })
}
set({ source })
} }
};
const disconnect = () => { const disconnect = () => {
get().source?.close() try { get().source?.close(); } catch {}
if (reconnectTimeout) clearTimeout(reconnectTimeout) clearReconnect();
set({ source: null, isConnected: false }) set({ source: null, isConnected: false, steamId: null });
};
const connect = (steamId: string) => {
const prev = get();
if (prev.source && prev.steamId === steamId) return;
if (prev.source && prev.steamId && prev.steamId !== steamId) {
try { prev.source.close(); } catch {}
set({ source: null, isConnected: false });
} }
const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001';
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`;
const source = new EventSource(url);
const pushEvent = (rawType: string, data: any) => {
if (data?.type === 'heartbeat') return;
// Kanonisieren
const canonical = normalizeEventType(rawType, data, get().steamId);
if (!canonical) return;
set({ lastEvent: { type: canonical, payload: data, ts: Date.now() } });
};
// Named Events (nur kanonische registrieren; Legacy kommt über onmessage oder wird
// vom Server ggf. trotzdem als named geschickt → wir normalisieren in pushEvent)
for (const type of SSE_EVENT_TYPES) {
source.addEventListener(type, (ev) => {
try {
pushEvent(type, JSON.parse((ev as MessageEvent).data));
} catch (e) {
console.error(`[SSE] parse ${type}:`, e);
}
});
}
// Fallback: Events ohne "event:"-Header → onmessage
source.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
const rawType = data?.type;
if (typeof rawType !== 'string') return;
pushEvent(rawType, data);
} catch (e) {
console.error('[SSE] onmessage parse error:', e);
}
};
source.onerror = () => {
console.warn('[SSE] Fehler, reconnect…');
try { source.close(); } catch {}
set({ source: null, isConnected: false });
clearReconnect();
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
if (!get().steamId || get().steamId === steamId) connect(steamId);
}, 1500 + Math.floor(Math.random() * 500));
};
source.onopen = () => set({ source, isConnected: true, steamId });
// sofort setzen, onopen kommt ggf. später
set({ source, steamId });
};
return { return {
source: null, source: null,
isConnected: false, isConnected: false,
steamId: null,
lastEvent: null, lastEvent: null,
connect, connect,
disconnect, disconnect,
} };
}) });

View File

@ -7,7 +7,7 @@ import CreateTeamButton from '@/app/components/CreateTeamButton'
import TeamCardComponent from '@/app/components/TeamCardComponent' import TeamCardComponent from '@/app/components/TeamCardComponent'
import AccountSettings from '@/app/components/settings/AccountSettings' import AccountSettings from '@/app/components/settings/AccountSettings'
export default function Page({ params }: { params: Promise<{ tab: string }> }) { export default function SettingsTabPage({ params }: { params: Promise<{ tab: string }> }) {
const { tab } = use(params) const { tab } = use(params)
const renderTabContent = () => { const renderTabContent = () => {
@ -22,12 +22,6 @@ export default function Page({ params }: { params: Promise<{ tab: string }> }) {
return ( return (
<Card maxWidth='auto' /> <Card maxWidth='auto' />
) )
case 'team':
return (
<Card maxWidth='auto'>
<TeamCardComponent />
</Card>
)
default: default:
return notFound() return notFound()
} }

View File

@ -7,7 +7,6 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
<Tabs> <Tabs>
<Tab name="Account" href="/settings/account" /> <Tab name="Account" href="/settings/account" />
<Tab name="Datenschutz" href="/settings/privacy" /> <Tab name="Datenschutz" href="/settings/privacy" />
<Tab name="Team" href="/settings/team" />
</Tabs> </Tabs>
<div className="mt-6"> <div className="mt-6">
{children} {children}

View File

@ -1,5 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
export default function Page() { export default function SettingPage() {
redirect('/settings/account') redirect('/settings/account')
} }

10
src/app/team/layout.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Tabs } from '@/app/components/Tabs'
import Tab from '@/app/components/Tab'
export default function TeamLayout({ children }: { children: React.ReactNode }) {
return (
<div className="container mx-auto">
{children}
</div>
)
}

11
src/app/team/page.tsx Normal file
View File

@ -0,0 +1,11 @@
import Card from "../components/Card";
import TeamCardComponent from "../components/TeamCardComponent";
export default function Team() {
return (
<Card maxWidth='auto'>
<TeamCardComponent />
</Card>
);
}

View File

@ -0,0 +1,8 @@
// types/invitation.ts
import { Team } from "./team"
export interface Invitation {
id: string
team: Team
}

View File

@ -1,18 +1,13 @@
// types/next-auth.d.ts (oder z.B. in src/types/next-auth.d.ts) // types/next-auth.d.ts
import { DefaultSession, DefaultUser } from 'next-auth' import { DefaultSession, DefaultUser } from 'next-auth'
declare module 'next-auth' { declare module 'next-auth' {
interface Session { interface Session {
user: { user: DefaultSession['user'] & {
steamId: string steamId?: string
isAdmin: boolean isAdmin?: boolean
team: string | null team?: string | null
id?: string
name?: string
image?: string
} }
id?: string
} }
interface User extends DefaultUser { interface User extends DefaultUser {
@ -29,6 +24,5 @@ declare module 'next-auth/jwt' {
team?: string | null team?: string | null
name?: string name?: string
image?: string image?: string
id?: string
} }
} }

View File

@ -1,3 +1,4 @@
// sse-server.js
const http = require('http') const http = require('http')
const url = require('url') const url = require('url')
@ -35,16 +36,26 @@ const server = http.createServer((req, res) => {
}) })
res.write('\n') // Verbindung offen halten res.write('\n') // Verbindung offen halten
// 🔄 Client speichern
clients.set(steamId, res) clients.set(steamId, res)
//console.log(`[SSE] Verbunden: steamId=${steamId}`)
// 🫀 Heartbeat alle 25s
res.write('retry: 2000\n\n') // optional: Client-Reconnect-Vorschlag
const interval = setInterval(() => {
res.write(`event: ping\n`)
res.write(`data: {}\n\n`)
}, 25000)
req.on('close', () => { req.on('close', () => {
clearInterval(interval) // 💡 sonst läuft dein setInterval ewig
clients.delete(steamId) clients.delete(steamId)
//console.log(`[SSE] Verbindung geschlossen: steamId=${steamId}`)
}) })
return return
} }
// Nachricht senden (POST) // Nachricht senden (POST)
if (req.method === 'POST' && req.url === '/send') { if (req.method === 'POST' && req.url === '/send') {
let body = '' let body = ''