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'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState, useRef } from 'react'
import { useSession } from 'next-auth/react'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import TeamMemberView from '@/app/components/TeamMemberView'
import { useTeamStore } from '@/app/lib/stores'
import { reloadTeam } from '@/app/lib/sse-actions'
import type { Player } from '@/app/types/team'
export default function TeamAdminClient({ teamId }: { teamId: string }) {
const [loading, setLoading] = useState(true)
const { data: session } = useSession()
type Props = { teamId: string }
export default function TeamAdminClient({ teamId }: Props) {
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 result = await reloadTeam(teamId)
console.log('[TeamAdminClient] reloadTeam returned:', result)
if (result) {
setTeam(result)
}
setLoading(false)
}
if (teamId) fetch()
const fetchTeam = useCallback(async () => {
const result = await reloadTeam(teamId)
if (result) setTeam(result)
setLoading(false)
}, [teamId, setTeam])
if (loading || !team) {
return <LoadingSpinner />
}
useEffect(() => {
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 (
<div className="max-w-5xl mx-auto">
<TeamMemberView
team={team}
currentUserSteamId={session?.user?.steamId ?? ''}
adminMode={true}
activeDragItem={null}
isDragging={false}
showLeaveModal={false}
showInviteModal={false}
setShowLeaveModal={() => {}}
setShowInviteModal={() => {}}
setActiveDragItem={() => {}}
setIsDragging={() => {}}
key={
team
? `${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(',')}`
: 'no-team'
}
currentUserSteamId={currentUserSteamId}
adminMode
activeDragItem={activeDragItem}
isDragging={isDragging}
showLeaveModal={showLeaveModal}
showInviteModal={showInviteModal}
setShowLeaveModal={setShowLeaveModal}
setShowInviteModal={setShowInviteModal}
setActiveDragItem={setActiveDragItem}
setIsDragging={setIsDragging}
/>
</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 { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
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) {
const session = await getServerSession(authOptions(req))
try {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
if (!session?.user?.steamId) {
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({
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 },
})
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 })
}
await prisma.notification.updateMany({
where: { steamId: session.user.steamId, read: false },
data: { read: true },
})
return NextResponse.json({ message: 'Alle Notifications als gelesen markiert' })
}

View File

@ -1,39 +1,32 @@
// app/api/notifications/mark-read/[id]/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
const param = await params
const notificationId = param.id
try {
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
})
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
if (!notification || notification.steamId !== session.user.steamId) {
const notification = await prisma.notification.findUnique({
where: { id: params.id },
})
if (!notification || notification.steamId !== steamId) {
return NextResponse.json({ error: 'Nicht gefunden oder nicht erlaubt' }, { status: 403 })
}
await prisma.notification.update({
where: { id: notificationId },
where: { id: params.id },
data: { read: true },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('[Notification] Fehler beim Markieren:', error)
console.error('[Notification] mark-one-read:', error)
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 type { Player } from '@/app/types/team'
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export async function GET(
_req: NextRequest,
{ params }: { params: { teamId: string } },
@ -87,7 +91,11 @@ export async function GET(
invitedPlayers,
}
return NextResponse.json(result)
return NextResponse.json(result, {
headers: {
'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate',
},
})
} catch (error) {
console.error('GET /api/team/[teamId] failed:', error)
return NextResponse.json(

View File

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

View File

@ -1,21 +1,20 @@
// /pages/api/team/change-logo.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
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 {
await prisma.team.update({
where: { id: teamId },
data: { logo: logoUrl },
})
await prisma.team.update({ 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 })
} catch (err) {

View File

@ -1,3 +1,4 @@
// src/app/api/team/kick/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
@ -7,90 +8,135 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
/* ───────── 1) Payload prüfen ───────── */
/* 1) Payload prüfen */
const { teamId, steamId } = await req.json()
if (!teamId || !steamId) {
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 } })
if (!team) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
const user = await prisma.user.findUnique({
where : { steamId },
select: { name: true },
select: { name: true, teamId: true },
})
const userName = user?.name ?? 'Ein Mitglied'
const teamName = team.name ?? 'Unbekanntes Team'
/* ───────── 3) Spieler aus Team-Arrays entfernen ───────── */
const active = team.activePlayers.filter(id => id !== steamId)
const inactive = team.inactivePlayers.filter(id => id !== steamId)
// Mitglied vor Kick?
const wasMember =
team.activePlayers.includes(steamId) || team.inactivePlayers.includes(steamId)
await prisma.team.update({
where: { id: teamId },
data : {
activePlayers : { set: active },
inactivePlayers : { set: inactive },
},
/* 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 },
data : {
activePlayers : { set: nextActive },
inactivePlayers : { set: nextInactive },
},
})
// User->Team trennen (idempotent)
await tx.user.update({
where: { steamId },
data : { teamId: null },
})
// Offene Team-Invites für diesen User aufräumen
await tx.teamInvite.deleteMany({
where: { teamId, steamId },
})
return { active: nextActive, inactive: nextInactive }
})
/* ───────── 4) User vom Team lösen ───────── */
await prisma.user.update({ where: { steamId }, data: { teamId: null } })
/* 4) Spieler aus offenen Matches entfernen */
if (wasMember) {
await removePlayerFromMatches(teamId, steamId)
}
/* ───────── 5) Spieler aus offenen Matches werfen ───────── */
await removePlayerFromMatches(teamId, steamId)
/* 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)
/* ───────── 6) Notifications & SSE ───────── */
/* an gekickten User */
// a) an den Gekickten: sichtbare Notification + Self-Event (UI räumen)
const kickedN = await prisma.notification.create({
data: {
user : { connect: { steamId } },
title : 'Team verlassen',
message : `Du wurdest aus dem Team „${teamName}“ geworfen.`,
actionType : 'team-kick',
message : `Du wurdest aus dem Team „${teamName}” entfernt.`,
actionType : 'team-kick-self',
},
})
// sichtbare Notification (NotificationCenter hört auf type: 'notification')
await sendServerSSEMessage({
type : kickedN.actionType ?? 'notification',
type: 'notification',
targetUserIds: [steamId],
id : kickedN.id,
message : kickedN.message,
createdAt : kickedN.createdAt.toISOString(),
message: kickedN.message,
id: kickedN.id,
actionType: kickedN.actionType ?? undefined,
createdAt: kickedN.createdAt.toISOString(),
})
/* an verbleibende Mitglieder */
const remaining = [...active, ...inactive]
await Promise.all(
remaining.map(async uid => {
const n = await prisma.notification.create({
data: {
user : { connect: { steamId: uid } },
title : 'Team-Update',
message : `${userName} wurde aus dem Team „${teamName}“ gekickt.`,
actionType : 'team-kick-other',
},
})
await sendServerSSEMessage({
type : n.actionType ?? 'notification',
targetUserIds: [uid],
id : n.id,
message : n.message,
createdAt : n.createdAt.toISOString(),
})
}),
)
/* ► UI neu laden lassen */
// Self-Event für Store-Reset/Redirect
await sendServerSSEMessage({
type : 'team-updated',
type: 'team-kick-self',
teamId,
targetUserIds : remaining,
targetUserIds: [steamId],
})
return NextResponse.json({ message: 'Mitglied entfernt' })
// b) an verbleibende Mitglieder: sichtbare Info in Echtzeit
if (remaining.length) {
// DB-Notifications erzeugen
const created = await Promise.all(
remaining.map(uid =>
prisma.notification.create({
data: {
user : { connect: { steamId: uid } },
title : 'Team-Update',
message : `${userName} wurde aus dem Team „${teamName}” entfernt.`,
actionType : 'team-member-kicked',
},
})
)
)
// sofort zustellen (type: 'notification' → NotificationCenter zeigt Toast)
await Promise.all(
created.map(n =>
sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
createdAt: n.createdAt.toISOString(),
})
)
)
}
// c) Ein einziges team-updated an ALLE verbleibenden (inkl. Leader) für UI-Refresh
if (allMembersAfter.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: allMembersAfter,
})
}
return NextResponse.json(
{ message: 'Mitglied entfernt' },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) {
console.error('[KICK] Fehler:', err)
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 { prisma } from '@/app/lib/prisma'
import { removePlayerFromTeam } from '@/app/lib/removePlayerFromTeam'
import { removePlayerFromMatches } from '@/app/lib/removePlayerFromMatches'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
@ -13,96 +13,149 @@ export async function POST(req: NextRequest) {
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({
where: {
OR: [
{ activePlayers : { has: steamId } },
{ activePlayers: { has: steamId } },
{ inactivePlayers: { has: steamId } },
],
},
})
if (!team) return NextResponse.json({ message: 'Kein Team gefunden' }, { status: 404 })
const { activePlayers, inactivePlayers, leader } = removePlayerFromTeam(
{ activePlayers: team.activePlayers, inactivePlayers: team.inactivePlayers, leader: team.leaderId },
steamId,
)
/* ───────── 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,
},
})
}
/* ───────── 3) User lösen ───────── */
await prisma.user.update({ where: { steamId }, data: { teamId: null } })
/* ───────── 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 teamName = team.name ?? 'Dein Team'
const remaining = [...activePlayers, ...inactivePlayers].filter(id => id !== steamId)
/* an leavenden User */
const leaveN = await prisma.notification.create({
data: {
user : { connect: { steamId } },
title : 'Teamupdate',
message : `Du hast das Team „${teamName}“ verlassen.`,
actionType : 'team-left',
select: {
id: true,
name: true,
leaderId: true,
activePlayers: true,
inactivePlayers: true,
},
})
await sendServerSSEMessage({
type : leaveN.actionType ?? 'notification',
targetUserIds: [steamId],
id : leaveN.id,
message : leaveN.message,
createdAt : leaveN.createdAt.toISOString(),
if (!team) {
return NextResponse.json(
{ message: 'Kein Team gefunden' },
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
)
}
const user = await prisma.user.findUnique({
where: { steamId },
select: { name: true },
})
const userName = user?.name ?? 'Ein Spieler'
const teamName = team.name ?? 'Dein Team'
/* 2) Atomar: User aus Team entfernen, User.teamId null, Invites aufräumen */
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: {
activePlayers: { set: nextActive },
inactivePlayers: { set: nextInactive },
},
})
// User vom Team lösen (idempotent)
await tx.user.update({
where: { steamId },
data: { teamId: null },
})
// Eventuelle Einladungen zu diesem Team für den User aufräumen
await tx.teamInvite.deleteMany({
where: { teamId: team.id, steamId },
})
return { nextActive, nextInactive }
})
/* an verbleibende Mitglieder */
await Promise.all(
remaining.map(async uid => {
const n = await prisma.notification.create({
data: {
user : { connect: { steamId: uid } },
title : 'Teamupdate',
message : `${userName} hat das Team verlassen.`,
actionType : 'team-member-left',
},
})
await sendServerSSEMessage({
type : n.actionType ?? 'notification',
targetUserIds: [uid],
id : n.id,
message : n.message,
createdAt : n.createdAt.toISOString(),
})
}),
)
/* 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: {
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)
/* ► UI neu laden lassen */
if (remaining.length) {
const created = await Promise.all(
remaining.map((uid) =>
prisma.notification.create({
data: {
steamId: uid,
title: 'Teamupdate',
message: `${userName} hat das Team verlassen.`,
actionType: 'team-member-left',
actionData: steamId,
},
}),
),
)
// sofort zustellen (sichtbar im NotificationCenter)
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({
type : 'team-updated',
teamId : team.id,
targetUserIds : remaining,
type: 'team-updated',
teamId: team.id,
targetUserIds: remaining,
})
}
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) {
console.error('[LEAVE] Fehler:', err)
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 { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const { teamId, newName } = await req.json()
const name = (newName ?? '').trim()
if (!teamId || !newName) {
if (!teamId || !name) {
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 },
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({
type: 'team-renamed',
title: 'Team umbenannt!',
message: `Das Team wurde umbenannt in "${newName}".`,
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) {
console.error('Fehler beim Umbenennen:', err)
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 { 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) {
try {
const { teamId, newLeaderSteamId } = await req.json()
/* ────────────── Parameter prüfen ────────────── */
if (!teamId || !newLeaderSteamId) {
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
}
/* ────────────── Team holen ───────────────────── */
const team = await prisma.team.findUnique({ where: { id: teamId } })
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 })
}
/* ────────────── Mitgliedschaft prüfen ────────── */
const allPlayerIds = Array.from(
new Set([
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
]),
)
const allPlayerIds = Array.from(new Set([
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
team.leaderId, // alter Leader (kann null sein)
].filter(Boolean) as string[]))
// Neuer Leader muss Mitglied sein
if (!allPlayerIds.includes(newLeaderSteamId)) {
return NextResponse.json({
message: 'Neuer Leader ist kein Teammitglied.',
}, { status: 400 })
return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 })
}
/* ────────────── Leader setzen ────────────────── */
// Leader setzen
await prisma.team.update({
where: { id: teamId },
data : { leaderId: newLeaderSteamId },
})
/* ────────────── Namen des neuen Leaders ───────── */
// Namen neuer Leader
const newLeader = await prisma.user.findUnique({
where : { steamId: newLeaderSteamId },
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({
data: {
steamId : newLeaderSteamId,
@ -53,9 +57,8 @@ export async function POST(req: NextRequest) {
actionData: teamId,
},
})
await sendServerSSEMessage({
type : leaderNote.actionType ?? 'notification',
type : 'notification',
targetUserIds: [newLeaderSteamId],
message : leaderNote.message,
id : leaderNote.id,
@ -64,55 +67,60 @@ export async function POST(req: NextRequest) {
createdAt : leaderNote.createdAt.toISOString(),
})
/* ────────── 2) Info an alle anderen ───────────── */
const others: string[] = [
...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)
// 2) Info an alle anderen (sichtbar + live)
const others = allPlayerIds.filter(id => id !== newLeaderSteamId)
if (others.length) {
const text =
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
const notes = await Promise.all(
others.map(steamId =>
prisma.notification.create({
data: {
steamId,
title : 'Neuer Teamleader',
message: text,
title: 'Neuer Teamleader',
message: textForOthers,
actionType: 'team-leader-changed',
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({
type : 'team-leader-changed',
type: 'team-leader-changed',
targetUserIds: others,
message : text,
id : notes[0].id, // eine Referenz-ID reicht
actionType : 'team-leader-changed',
actionData : newLeaderSteamId,
createdAt : notes[0].createdAt.toISOString(),
teamId,
message: textForOthers,
actionData: newLeaderSteamId,
})
}
// 3) Zielgerichtetes “team-updated” an ALLE (inkl. neuem Leader)
const reloadTargets = Array.from(new Set([...allPlayerIds, newLeaderSteamId]))
if (reloadTargets.length) {
await sendServerSSEMessage({
type: 'team-updated',
targetUserIds: reloadTargets,
teamId,
})
}
/* ── 3) Globales “team-updated” an ALLE ──────────────── */
await sendServerSSEMessage({
type : 'team-updated',
targetUserIds: allPlayerIds,
teamId,
})
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
} catch (error) {
console.error('Fehler beim Leaderwechsel:', error)
return NextResponse.json({
message: 'Serverfehler beim Leaderwechsel.',
}, { status: 500 })
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
}
}

View File

@ -6,58 +6,61 @@ import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
try {
const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
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({
where: { id: teamId },
data: { activePlayers, inactivePlayers },
})
// 🔄 Einladungen synchronisieren
// 3) Invites syncen (wie gehabt)
if (Array.isArray(invitedPlayers)) {
// Zuerst: Bestehende Einladungen für dieses Team laden
const existingInvites = await prisma.teamInvite.findMany({
where: { teamId },
})
const existingSteamIds = existingInvites.map((invite) => invite.steamId)
const toAdd = invitedPlayers.filter((id: string) => !existingSteamIds.includes(id))
const existingInvites = await prisma.teamInvite.findMany({ where: { teamId } })
const existingSteamIds = existingInvites.map(i => i.steamId)
const toAdd = invitedPlayers.filter((id: string) => !existingSteamIds.includes(id))
const toRemove = existingSteamIds.filter((id) => !invitedPlayers.includes(id))
// Neue Einladungen erstellen
await prisma.teamInvite.createMany({
data: toAdd.map((steamId: string) => ({
teamId,
steamId,
type: 'invite',
})),
skipDuplicates: true,
if (toAdd.length) await prisma.teamInvite.createMany({
data: toAdd.map((steamId: string) => ({ teamId, steamId, type: 'invite' })), skipDuplicates: true,
})
// Nicht mehr gelistete Einladungen löschen
await prisma.teamInvite.deleteMany({
where: {
teamId,
steamId: { in: toRemove },
},
if (toRemove.length) 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({
type: 'team-updated',
teamId,
targetUserIds: allSteamIds,
})
// 🔔 spezifische Events (Broadcast)
if (joined.length) {
await sendServerSSEMessage({ type: 'team-member-joined', teamId, users: joined })
}
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) {
console.error('Fehler beim Aktualisieren der Team-Mitglieder:', error)
return NextResponse.json({ error: 'Serverfehler beim Aktualisieren' }, { status: 500 })
}
}
}

View File

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

View File

@ -7,14 +7,14 @@ import { prisma } from '@/app/lib/prisma'
export async function GET(req: NextRequest) {
try {
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 })
}
// Einladungen inkl. roher Teamdaten (IDs) laden
const invitations = await prisma.teamInvite.findMany({
where: {
steamId: session.user.steamId,
},
where: { steamId },
select: {
id: true,
teamId: true,
@ -22,14 +22,75 @@ export async function GET(req: NextRequest) {
type: true,
team: {
select: {
id: 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) {
console.error('Fehler beim Laden der Einladungen:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })

View File

@ -47,7 +47,7 @@ export default function Card({
<div
className={`
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]}
`}
>

View File

@ -1,50 +1,150 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useSession } from 'next-auth/react'
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() {
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 [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const fetchData = async () => {
setLoading(true)
const [teamRes, invitesRes] = await Promise.all([
fetch('/api/team/list'),
fetch('/api/user/invitations'),
])
const teamData = await teamRes.json()
const inviteData = await invitesRes.json()
// Dedupe / Throttle
const inflight = useRef<AbortController | null>(null)
const lastReloadAt = useRef(0)
const cooldownMs = 350
setTeams(teamData.teams || [])
const mapping: Record<string, string> = {}
for (const invite of inviteData?.invitations || []) {
if (invite.type === 'team-join-request') {
mapping[invite.teamId] = invite.id
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([
fetch('/api/team/list', { cache: 'no-store', signal: ac.signal }),
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 inviteData = await invitesRes.json()
const nextTeams: Team[] = teamData.teams || []
// nur Join-Requests der aktuellen Person mappen (für Button-Label “Angefragt”)
const mapping: Record<string, string> = {}
for (const inv of inviteData?.invitations || []) {
if (inv.type === 'team-join-request') {
mapping[inv.teamId] = inv.id
}
}
// 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)
}
setTeamToInvitationId(mapping)
setLoading(false)
}
useEffect(() => {
fetchData()
}, [])
// initial
useEffect(() => { fetchTeamsAndInvitations(false) }, [])
const updateInvitationMap = (teamId: string, newId: string | null) => {
setTeamToInvitationId((prev) => {
// auf SSE reagieren: nur dann nachladen, wenn das Event relevant ist
useEffect(() => {
if (!lastEvent) return
const { type, payload } = lastEvent
if (TEAM_EVENTS.has(type)) {
// 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 }
if (newId) updated[teamId] = newId
else delete updated[teamId]
if (!newValue) delete updated[teamId]
else if (newValue === 'pending') updated[teamId] = updated[teamId] ?? 'pending'
else updated[teamId] = newValue
return updated
})
}
if (loading) return <p>Lade Teams </p>
if (loading) return <p>Lade Teams </p>
return (
<div className="space-y-6">
@ -55,11 +155,11 @@ export default function NoTeamView() {
</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => (
{teams.map(team => (
<TeamCard
key={team.id}
team={team}
currentUserSteamId={session?.user?.steamId || ''}
currentUserSteamId={currentSteamId}
invitationId={teamToInvitationId[team.id]}
onUpdateInvitation={updateInvitationMap}
adminMode={false}
@ -68,4 +168,4 @@ export default function NoTeamView() {
</div>
</div>
)
}
}

View File

@ -4,10 +4,9 @@ import { useEffect, useState } from 'react'
import NotificationDropdown from './NotificationDropdown'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useSSEStore } from '@/app/lib/useSSEStore'
import { NOTIFICATION_EVENTS } from '../lib/sseEvents'
/* ────────────────────────────────────────────────────────── */
/* Typen */
/* ────────────────────────────────────────────────────────── */
type Notification = {
id: string
text: string
@ -17,21 +16,204 @@ type Notification = {
createdAt?: string
}
/* ────────────────────────────────────────────────────────── */
/* Komponente */
/* ────────────────────────────────────────────────────────── */
type ActionData =
| { 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() {
/* --- Hooks & States ------------------------------------ */
const { data: session } = useSession()
const router = useRouter()
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
const [notifications, setNotifications] = useState<Notification[]>([])
const [open, setOpen] = useState(false)
//const { markAllAsRead, markOneAsRead, handleInviteAction } = useTeamManager({}, null)
const router = useRouter()
const [previewText, setPreviewText] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [animateBell, setAnimateBell] = useState(false)
/* --- Aktionen beim Klick auf eine Notification ---------- */
// 1) Initial laden
useEffect(() => {
const steamId = session?.user?.steamId
if (!steamId) return
;(async () => {
try {
const res = await fetch('/api/notifications/user')
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
const loaded: Notification[] = data.notifications.map((n: any) => ({
id: n.id,
text: n.message,
read: n.read,
actionType: n.actionType,
actionData: n.actionData,
createdAt: n.createdAt,
}))
setNotifications(loaded)
} catch (err) {
console.error('[NotificationCenter] Fehler beim Laden:', err)
}
})()
}, [session?.user?.steamId])
// 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
useEffect(() => {
if (!lastEvent) return
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
const data = lastEvent.payload
if (data?.type === 'heartbeat') return
const newNotification: Notification = {
id: data?.id ?? crypto.randomUUID(),
text: data?.message ?? 'Neue Benachrichtigung',
read: false,
actionType: data?.actionType,
actionData: data?.actionData,
createdAt: data?.createdAt ?? new Date().toISOString(),
}
setNotifications(prev => [newNotification, ...prev])
setPreviewText(newNotification.text) // <-- nur das hier
}, [lastEvent])
// 2) Timer separat steuern: triggert bei neuem previewText
useEffect(() => {
if (!previewText) return
setShowPreview(true)
setAnimateBell(true)
const PREVIEW_MS = 5000
const CLEAR_DELAY = 300
const tHide = window.setTimeout(() => setShowPreview(false), PREVIEW_MS)
const tBell = window.setTimeout(() => setAnimateBell(false), PREVIEW_MS)
const tClear = window.setTimeout(() => setPreviewText(null), PREVIEW_MS + CLEAR_DELAY)
return () => {
clearTimeout(tHide)
clearTimeout(tBell)
clearTimeout(tClear)
}
}, [previewText])
// 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 {
@ -42,143 +224,32 @@ export default function NotificationCenter() {
}
}
/* --- Initiale Daten laden + SSE verbinden --------------- */
useEffect(() => {
const steamId = session?.user?.steamId
if (!steamId) return
const loadNotifications = async () => {
try {
const res = await fetch('/api/notifications/user')
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
const loaded = data.notifications.map((n: any) => ({
id : n.id,
text : n.message,
read : n.read,
actionType: n.actionType,
actionData: n.actionData,
createdAt : n.createdAt,
}))
setNotifications(loaded)
} catch (err) {
console.error('[NotificationCenter] Fehler beim Laden:', err)
}
}
loadNotifications()
}, [session?.user?.steamId])
/* --- Live-Updates über SSE empfangen -------------------- */
useEffect(() => {
return;
//if (!source) return
/* Handler für JEDES eintreffende Paket ------------------ */
const handleEvent = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'heartbeat') return // Ping ignorieren
/* Neues Notification-Objekt erzeugen */
const newNotification: Notification = {
id : data.id ?? crypto.randomUUID(),
text : data.message ?? 'Neue Benachrichtigung',
read : false,
actionType: data.actionType,
actionData: data.actionData,
createdAt : data.createdAt ?? new Date().toISOString(),
}
/* State updaten (immer oben einsortieren) */
setNotifications(prev => [newNotification, ...prev])
/* Glocke & Vorschau animieren ---------------------- */
setPreviewText(newNotification.text)
setShowPreview(true)
setAnimateBell(true)
setTimeout(() => {
setShowPreview(false)
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 eventNames = [
'notification',
'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 () => {
//eventNames.forEach(evt => source.removeEventListener(evt, handleEvent))
//source.onmessage = null
}
}, /*[source] */)
/* ────────────────────────────────────────────────────────── */
/* Render */
/* ────────────────────────────────────────────────────────── */
// 4) Render
return (
<div className="fixed bottom-6 right-6 z-50">
{/* Glocke -------------------------------------------------- */}
<button
type="button"
onClick={() => setOpen(prev => !prev)}
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
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
>
{/* Vorschau-Text --------------------------------------- */}
{previewText && (
<span className="truncate text-sm text-gray-800 dark:text-white">
{previewText}
</span>
)}
{/* Icon & Badge --------------------------------------- */}
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center">
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
<svg
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path
strokeLinecap="round"
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"
/>
<path strokeLinecap="round" 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>
{/* Badge (ungelesen) -------------------------------- */}
{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="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}
@ -188,32 +259,13 @@ export default function NotificationCenter() {
</div>
</button>
{/* Dropdown --------------------------------------------- */}
{open && (
<NotificationDropdown
notifications={notifications}
markAllAsRead={async () => {
await markAllAsRead()
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)),
)
}}
markAllAsRead={markAllAsRead}
onSingleRead={markOneAsRead}
onClose={() => setOpen(false)}
onAction={async (action, id) => {
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()
}}
onAction={handleInviteAction}
onClickNotification={onNotificationClick}
/>
)}

View File

@ -131,8 +131,9 @@ export default function NotificationDropdown({
{needsAction ? (
<>
<Button
onClick={() => {
onAction('accept', n.actionData!)
onClick={(e) => {
e.stopPropagation()
onAction('accept', n.actionData ?? n.id)
onSingleRead(n.id)
}}
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
onClick={() => {
onAction('reject', n.actionData!)
onClick={(e) => {
e.stopPropagation()
onAction('reject', n.actionData ?? n.id)
onSingleRead(n.id)
}}
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" >
<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>
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
onClick={() => router.push('/settings')}

View File

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

View File

@ -1,6 +1,6 @@
'use client'
import { forwardRef, useEffect, useState } from 'react'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useSession } from 'next-auth/react'
import TeamInvitationView from './TeamInvitationView'
@ -8,101 +8,202 @@ import TeamMemberView from './TeamMemberView'
import NoTeamView from './NoTeamView'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import CreateTeamButton from './CreateTeamButton'
import type { Player, Team } from '../types/team'
import type { Invitation } from '../types/invitation'
import { useSSEStore } from '@/app/lib/useSSEStore'
import {
acceptInvitation,
rejectInvitation,
markOneAsRead
} from '@/app/lib/sse-actions'
import { Player, Team } from '../types/team'
/** Relevante Event-Gruppen */
const TEAM_EVENTS = new Set([
'team-updated',
'team-leader-changed',
'team-member-joined',
'team-member-left',
'team-renamed',
'team-logo-updated',
])
type Props = {
refetchKey?: string
const SELF_CLEAR_EVENTS = new Set([
'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: Props, ref: any) {
function TeamCardComponent(_: Props, _ref: any) {
const { data: session } = useSession()
const steamId = session?.user?.steamId ?? ''
const [refetchKey, setRefetchKey] = useState<string>()
const [isLoading, setIsLoading] = useState(true)
const { lastEvent } = useSSEStore() // nur konsumieren Verbindung macht der globale SSEHandler
// State
const [initialLoading, setInitialLoading] = useState(true) // nur beim ersten Load true
const [team, setTeam] = useState<Team | null>(null)
const [activePlayers, setActivePlayers] = useState<Player[]>([])
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
const [invitedPlayers, setInvitedPlayers] = useState<Player[]>([])
const [pendingInvitation, setPendingInvitation] = useState<any>(null)
const [pendingInvitation, setPendingInvitation] = useState<Invitation | null>(null)
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
const loadTeam = async () => {
setIsLoading(true)
// Refs für Dedupe/Drossel/Abbruch
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 {
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()
if (data.team) {
setTeam(data.team)
setActivePlayers(data.team.activePlayers || [])
setInactivePlayers(data.team.inactivePlayers || [])
setInvitedPlayers(data.team.invitedPlayers || [])
} else {
setTeam(null)
}
if (ac.signal.aborted) return
// Einladung nur laden, wenn kein Team vorhanden
if (!data.team) {
const inviteRes = await fetch('/api/user/invitations')
if (inviteRes.ok) {
const inviteData = await inviteRes.json()
const teamInvite = inviteData.invitations?.find(
(i: any) => i.type === 'team-invite'
)
setPendingInvitation(teamInvite ?? null)
if (data.team) {
// Nur setzen, wenn sich wirklich etwas geändert hat (verhindert Flackern)
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) {
const inviteData = await inviteRes.json()
const raw = (inviteData.invitations ?? []).find((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
if ((pendingInvitation?.id ?? null) !== (inv?.id ?? null)) {
setPendingInvitation(inv)
}
}
}
if (team !== null) setTeam(null) // nur setzen, wenn nötig
}
} catch (e) {
if ((e as any)?.name !== 'AbortError') {
console.error('fetchData error:', e)
}
} catch (err) {
console.error('Fehler beim Laden des Teams:', err)
} finally {
setIsLoading(false)
if (withSpinner) setInitialLoading(false)
if (inflight.current === ac) inflight.current = null
}
}
const handleMarkOneAsRead = async (id: string): Promise<void> => {
await markOneAsRead(id)
}
// Initialer Load mit Spinner
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])
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) {
const notificationId = pendingInvitation.id
return (
<TeamInvitationView
invitation={pendingInvitation}
notificationId={notificationId}
onMarkAsRead={handleMarkOneAsRead}
onAction={async (action, invitationId) => {
if (action === 'accept') {
await acceptInvitation(invitationId)
} else {
await rejectInvitation(invitationId)
}
await markOneAsRead(notificationId)
await loadTeam()
}}
/>
<>
<TeamInvitationView
invitation={pendingInvitation}
notificationId={pendingInvitation.id}
onMarkAsRead={async () => {}}
onAction={async (action) => {
try {
await fetch(`/api/user/invitations/${action}`, {
method: 'POST',
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)
}
}}
/>
<NoTeamView />
</>
)
}
// 2. Kein Team
// 2) Kein Team & keine Einladung
if (!team) {
return (
<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 (
<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">
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
Teameinstellungen
@ -129,10 +230,6 @@ function TeamCardComponent(props: Props, ref: any) {
<form>
<TeamMemberView
team={team}
activePlayers={activePlayers}
inactivePlayers={inactivePlayers}
setactivePlayers={setActivePlayers}
setInactivePlayers={setInactivePlayers}
currentUserSteamId={steamId}
adminMode={false}
activeDragItem={activeDragItem}

View File

@ -1,8 +1,12 @@
// TeamInvitationView.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
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 = {
invitation: Invitation
@ -17,44 +21,161 @@ export default function TeamInvitationView({
onAction,
onMarkAsRead,
}: 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') => {
if (isSubmitting) return
try {
setIsSubmitting(true)
setIsSubmitting(action)
await onAction(action, invitation.id)
await onMarkAsRead(notificationId)
} catch (err) {
console.error(`Fehler beim ${action === 'accept' ? 'Annehmen' : 'Ablehnen'} der Einladung:`, err)
console.error(`[TeamInvitationView] ${action} failed:`, err)
} 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 (
<div className="p-4 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
Du wurdest eingeladen!
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">{invitation.teamName}</p>
<div className="flex gap-2">
<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
</Button>
<Button
onClick={() => handleRespond('reject')}
className="px-2 py-1 text-sm font-medium rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
disabled={isSubmitting}
>
Ablehnen
</Button>
<div
role="button"
tabIndex={0}
onClick={() => router.push(targetHref)}
onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
className={cardClasses}
>
{/* 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
title="Annehmen"
size="sm"
color="green"
variant="solid"
disabled={isSubmitting !== null}
onClick={(e) => {
e.stopPropagation()
handleRespond('accept')
}}
>
{isSubmitting === 'accept' ? (
<>
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
Annehmen
</>
) : (
'Annehmen'
)}
</Button>
</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>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -75,9 +75,9 @@ export default function AdminTeamsView() {
/* ─────────────────────────── Render ─────────────────────────── */
if (loading) {
return (
<p className="text-gray-500 dark:text-gray-400">
<div className="text-gray-500 dark:text-gray-400">
<LoadingSpinner />
</p>
</div>
)
}
@ -96,9 +96,9 @@ export default function AdminTeamsView() {
{/* Team-Grid */}
{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.
</p>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{teams.map(t => (

View File

@ -8,7 +8,7 @@ import LatestKnownCodeSettings from "./account/ShareCodeSettings"
export default function AccountSettings() {
return (
<>{/* 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 */}
<div className="mb-4 xl:mb-8">
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">

View File

@ -1,56 +1,97 @@
// SSEHandler.tsx
'use client'
import { useEffect } from 'react'
import { useEffect, useRef, startTransition } from 'react'
import { useSSEStore } from '@/app/lib/useSSEStore'
import { useSession } from 'next-auth/react'
import { reloadTeam } from '@/app/lib/sse-actions'
import { useRouter } from 'next/navigation'
import { useTeamStore } from '@/app/lib/stores'
import { TEAM_EVENTS, SELF_EVENTS, SSEEventType } from '@/app/lib/sseEvents'
export default function SSEHandler() {
const { data: session } = useSession()
const steamId = session?.user?.steamId
const { data: session, status } = useSession()
const steamId = session?.user?.steamId ?? null
const router = useRouter()
const { setTeam } = useTeamStore()
const { connect, lastEvent } = useSSEStore()
const { setTeam, team } = useTeamStore()
const { connect, disconnect, lastEvent, source } = useSSEStore()
// nur verbinden, wenn eingeloggt & steamId vorhanden
const prevSteamId = useRef<string | null>(null)
useEffect(() => {
if (steamId) connect(steamId)
}, [steamId])
if (status !== 'authenticated' || !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(() => {
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 () => {
switch (type) {
case 'team-updated':
if (payload.teamId) {
const updated = await reloadTeam(payload.teamId)
if (updated) {
setTeam(updated) // ✅ zentral setzen
}
}
break
case 'team-kick-other':
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)
const reloadIfNeeded = async (tid?: string) => {
if (!tid) return
// nur reloaden, wenn es auch das aktuell angezeigte Team ist
if (team?.id && tid !== team.id) return
if (reloadInFlight.current.has(tid)) return
reloadInFlight.current.add(tid)
try {
const updated = await reloadTeam(tid)
if (updated) {
// nicht render-blockierend updaten
startTransition(() => setTeam(updated))
}
} catch (e) {
console.error('[SSE] reloadTeam failed:', e)
} finally {
reloadInFlight.current.delete(tid)
}
}
handleEvent()
}, [lastEvent])
const handleSelfExit = () => {
// nur handeln, wenn wir noch ein Team im Store haben
if (team) {
startTransition(() => setTeam(null as any))
// replace statt push, um History nicht zu fluten
router.replace('/team')
}
}
return null // kein UI
(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,16 +6,21 @@ import { Player, Team, InvitedPlayer } from '../types/team'
export async function reloadTeam(teamId: string): Promise<Team | null> {
try {
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')
const data = await res.json()
const team = data.team ?? data
if (!team) return null
const sortByName = <T extends Player>(players: T[]): T[] =>
players.sort((a, b) => a.name.localeCompare(b.name))
const sortByName = <T extends Player>(arr: T[]) =>
[...arr].sort((a, b) => a.name.localeCompare(b.name));
console.log("reloadTeam:", data);
return {
id: team.id,
name: team.name,

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
import { create } from 'zustand'
'use client';
import { create } from 'zustand';
import {
SSE_EVENT_TYPES,
SSEEventType,
isSseEventType,
normalizeEventType,
} from '@/app/lib/sseEvents';
type SSEEvent = {
type: string
payload: any
}
type: SSEEventType;
payload: any;
ts: number;
};
type SSEState = {
source: EventSource | null
isConnected: boolean
lastEvent: SSEEvent | null
connect: (steamId: string) => void
disconnect: () => void
}
source: EventSource | null;
isConnected: boolean;
steamId: string | null;
lastEvent: SSEEvent | null;
connect: (steamId: string) => void;
disconnect: () => void;
};
export const useSSEStore = create<SSEState>((set, get) => {
let reconnectTimeout: NodeJS.Timeout | null = null
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
const connect = (steamId: string) => {
if (get().source) return
const source = new EventSource(`http://localhost:3001/events?steamId=${steamId}`)
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)
}
})
const clearReconnect = () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
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 = () => {
get().source?.close()
if (reconnectTimeout) clearTimeout(reconnectTimeout)
set({ source: null, isConnected: false })
}
try { get().source?.close(); } catch {}
clearReconnect();
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 {
source: null,
isConnected: false,
steamId: null,
lastEvent: null,
connect,
disconnect,
}
})
};
});

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'
export default function Page() {
export default function SettingPage() {
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'
declare module 'next-auth' {
interface Session {
user: {
steamId: string
isAdmin: boolean
team: string | null
id?: string
name?: string
image?: string
user: DefaultSession['user'] & {
steamId?: string
isAdmin?: boolean
team?: string | null
}
id?: string
}
interface User extends DefaultUser {
@ -29,6 +24,5 @@ declare module 'next-auth/jwt' {
team?: string | null
name?: string
image?: string
id?: string
}
}

View File

@ -1,3 +1,4 @@
// sse-server.js
const http = require('http')
const url = require('url')
@ -35,16 +36,26 @@ const server = http.createServer((req, res) => {
})
res.write('\n') // Verbindung offen halten
// 🔄 Client speichern
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', () => {
clearInterval(interval) // 💡 sonst läuft dein setInterval ewig
clients.delete(steamId)
//console.log(`[SSE] Verbindung geschlossen: steamId=${steamId}`)
})
return
}
// Nachricht senden (POST)
if (req.method === 'POST' && req.url === '/send') {
let body = ''