update
This commit is contained in:
parent
903d898a0a
commit
55a12c1f68
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@ -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>
|
||||
)
|
||||
|
||||
@ -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' })
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 })
|
||||
|
||||
213
src/app/api/team/request-join/[action]/route.ts
Normal file
213
src/app/api/team/request-join/[action]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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]}
|
||||
`}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
/* eslint‑disable react/display‑name */
|
||||
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}
|
||||
|
||||
@ -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
@ -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 => (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
105
src/app/lib/sseEvents.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
10
src/app/team/layout.tsx
Normal 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
11
src/app/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
8
src/app/types/invitation.ts
Normal file
8
src/app/types/invitation.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// types/invitation.ts
|
||||
import { Team } from "./team"
|
||||
|
||||
export interface Invitation {
|
||||
id: string
|
||||
team: Team
|
||||
}
|
||||
|
||||
16
src/app/types/next-auth.d.ts
vendored
16
src/app/types/next-auth.d.ts
vendored
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user