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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||||
import TeamMemberView from '@/app/components/TeamMemberView'
|
import TeamMemberView from '@/app/components/TeamMemberView'
|
||||||
import { useTeamStore } from '@/app/lib/stores'
|
import { useTeamStore } from '@/app/lib/stores'
|
||||||
import { reloadTeam } from '@/app/lib/sse-actions'
|
import { reloadTeam } from '@/app/lib/sse-actions'
|
||||||
|
import type { Player } from '@/app/types/team'
|
||||||
|
|
||||||
export default function TeamAdminClient({ teamId }: { teamId: string }) {
|
type Props = { teamId: string }
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const { data: session } = useSession()
|
export default function TeamAdminClient({ teamId }: Props) {
|
||||||
const { team, setTeam } = useTeamStore()
|
const { team, setTeam } = useTeamStore()
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const currentUserSteamId = session?.user?.steamId || ''
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
||||||
|
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetch = async () => {
|
const fetchTeam = useCallback(async () => {
|
||||||
const result = await reloadTeam(teamId)
|
const result = await reloadTeam(teamId)
|
||||||
console.log('[TeamAdminClient] reloadTeam returned:', result)
|
if (result) setTeam(result)
|
||||||
if (result) {
|
|
||||||
setTeam(result)
|
|
||||||
}
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
|
||||||
|
|
||||||
if (teamId) fetch()
|
|
||||||
}, [teamId, setTeam])
|
}, [teamId, setTeam])
|
||||||
|
|
||||||
if (loading || !team) {
|
useEffect(() => {
|
||||||
return <LoadingSpinner />
|
if (teamId) fetchTeam()
|
||||||
|
}, [teamId, fetchTeam])
|
||||||
|
|
||||||
|
// 👇 WICHTIG: subscribe by steamId (passt zu deinem SSE-Server)
|
||||||
|
useEffect(() => {
|
||||||
|
const steamId = session?.user?.steamId
|
||||||
|
if (!steamId) return
|
||||||
|
|
||||||
|
// ggf. .env nutzen: z. B. NEXT_PUBLIC_SSE_URL=http://localhost:3001
|
||||||
|
const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001'
|
||||||
|
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`
|
||||||
|
let es: EventSource | null = new EventSource(url, { withCredentials: false })
|
||||||
|
|
||||||
|
const onTeamUpdated = (ev: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data)
|
||||||
|
if (msg.teamId === teamId) {
|
||||||
|
fetchTeam()
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SSE] parse error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
es.addEventListener('team-updated', onTeamUpdated)
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
// sanftes Reconnect
|
||||||
|
es?.close()
|
||||||
|
es = null
|
||||||
|
setTimeout(() => {
|
||||||
|
// neuer EventSource
|
||||||
|
const next = new EventSource(url, { withCredentials: false })
|
||||||
|
next.addEventListener('team-updated', onTeamUpdated)
|
||||||
|
next.onerror = () => { next.close() }
|
||||||
|
es = next
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es?.removeEventListener('team-updated', onTeamUpdated as any)
|
||||||
|
es?.close()
|
||||||
|
}
|
||||||
|
}, [session?.user?.steamId, teamId, fetchTeam])
|
||||||
|
|
||||||
|
if (loading || !team) return <LoadingSpinner />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<TeamMemberView
|
<TeamMemberView
|
||||||
team={team}
|
key={
|
||||||
currentUserSteamId={session?.user?.steamId ?? ''}
|
team
|
||||||
adminMode={true}
|
? `${team.id}|A:${team.activePlayers.map(p=>p.steamId).join(',')}|I:${team.inactivePlayers.map(p=>p.steamId).join(',')}|V:${team.invitedPlayers.map(p=>p.steamId).join(',')}`
|
||||||
activeDragItem={null}
|
: 'no-team'
|
||||||
isDragging={false}
|
}
|
||||||
showLeaveModal={false}
|
currentUserSteamId={currentUserSteamId}
|
||||||
showInviteModal={false}
|
adminMode
|
||||||
setShowLeaveModal={() => {}}
|
activeDragItem={activeDragItem}
|
||||||
setShowInviteModal={() => {}}
|
isDragging={isDragging}
|
||||||
setActiveDragItem={() => {}}
|
showLeaveModal={showLeaveModal}
|
||||||
setIsDragging={() => {}}
|
showInviteModal={showInviteModal}
|
||||||
|
setShowLeaveModal={setShowLeaveModal}
|
||||||
|
setShowInviteModal={setShowInviteModal}
|
||||||
|
setActiveDragItem={setActiveDragItem}
|
||||||
|
setIsDragging={setIsDragging}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,20 +1,148 @@
|
|||||||
// /api/notifications/mark-all-read/route.ts
|
// app/api/notifications/mark-all-read/route.ts
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/app/lib/auth'
|
import { authOptions } from '@/app/lib/auth'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
const session = await getServerSession(authOptions(req))
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const steamId = session?.user?.steamId
|
||||||
if (!session?.user?.steamId) {
|
if (!steamId) {
|
||||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1) Unread-Notifications mit Handlungsbedarf einsammeln
|
||||||
|
const actionable = await prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
steamId,
|
||||||
|
read: false,
|
||||||
|
actionType: { in: ['team-invite', 'team-join-request', 'invitation'] },
|
||||||
|
actionData: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true, actionType: true, actionData: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Kandidaten-IDs extrahieren
|
||||||
|
const inviteIds = actionable
|
||||||
|
.map(n => n.actionData!)
|
||||||
|
.filter((x): x is string => typeof x === 'string' && x.length > 0)
|
||||||
|
|
||||||
|
if (inviteIds.length) {
|
||||||
|
// 2) Passende TeamInvite-Objekte holen
|
||||||
|
const invites = await prisma.teamInvite.findMany({
|
||||||
|
where: { id: { in: inviteIds } },
|
||||||
|
select: { id: true, type: true, teamId: true, steamId: true },
|
||||||
|
})
|
||||||
|
const byId = new Map(invites.map(i => [i.id, i]))
|
||||||
|
|
||||||
|
// 3) Für jede actionable Notification ablehnen (idempotent & silent)
|
||||||
|
await Promise.allSettled(
|
||||||
|
actionable.map(async n => {
|
||||||
|
const inv = byId.get(n.actionData!)
|
||||||
|
if (!inv) {
|
||||||
|
// Stale → nur die Notification aufräumen
|
||||||
await prisma.notification.updateMany({
|
await prisma.notification.updateMany({
|
||||||
where: { steamId: session.user.steamId, read: false },
|
where: { actionData: n.actionData! },
|
||||||
|
data: { read: true, actionType: null, actionData: null },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typen unterscheiden:
|
||||||
|
// - team-invite / invitation: Einladung an MICH -> darf ich ablehnen
|
||||||
|
// - team-join-request: Anfrage an den Leader -> darf ich ablehnen, wenn ich Leader bin
|
||||||
|
if (
|
||||||
|
(n.actionType === 'team-invite' || n.actionType === 'invitation' || inv.type === 'team-invite')
|
||||||
|
) {
|
||||||
|
// Einladung an mich? Nur dann löschen
|
||||||
|
if (inv.steamId !== steamId) return
|
||||||
|
|
||||||
|
await prisma.teamInvite.delete({ where: { id: inv.id } })
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { actionData: inv.id },
|
||||||
|
data: { read: true, actionType: null, actionData: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
// stilles UI-Update für (Leader + Mitglieder), Eingeladene ist noch kein Mitglied
|
||||||
|
if (inv.teamId) {
|
||||||
|
const team = await prisma.team.findUnique({
|
||||||
|
where: { id: inv.teamId },
|
||||||
|
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
|
})
|
||||||
|
const targetUserIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
team?.leaderId,
|
||||||
|
...(team?.activePlayers ?? []),
|
||||||
|
...(team?.inactivePlayers ?? []),
|
||||||
|
].filter(Boolean) as string[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (targetUserIds.length) {
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'team-updated',
|
||||||
|
teamId: inv.teamId,
|
||||||
|
targetUserIds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.actionType === 'team-join-request' || inv.type === 'team-join-request') {
|
||||||
|
// nur der Leader darf eine Beitrittsanfrage ablehnen
|
||||||
|
if (!inv.teamId) return
|
||||||
|
const team = await prisma.team.findUnique({
|
||||||
|
where: { id: inv.teamId },
|
||||||
|
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
|
})
|
||||||
|
if (!team || team.leaderId !== steamId) return
|
||||||
|
|
||||||
|
await prisma.teamInvite.delete({ where: { id: inv.id } })
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { actionData: inv.id },
|
||||||
|
data: { read: true, actionType: null, actionData: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
// stilles UI-Update an Leader + Mitglieder (Requester ist noch kein Mitglied)
|
||||||
|
const targetUserIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
team.leaderId,
|
||||||
|
...(team.activePlayers ?? []),
|
||||||
|
...(team.inactivePlayers ?? []),
|
||||||
|
].filter(Boolean) as string[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (targetUserIds.length) {
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'team-updated',
|
||||||
|
teamId: inv.teamId,
|
||||||
|
targetUserIds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Danach ALLE ungelesenen Nachrichten als gelesen markieren (auch die ohne Actions)
|
||||||
|
const result = await prisma.notification.updateMany({
|
||||||
|
where: { steamId, read: false },
|
||||||
data: { read: true },
|
data: { read: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Alle Notifications als gelesen markiert' })
|
return NextResponse.json(
|
||||||
|
{ success: true, updated: result.count },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Notification] mark-all-read:', error)
|
||||||
|
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,32 @@
|
|||||||
// app/api/notifications/mark-read/[id]/route.ts
|
// app/api/notifications/mark-read/[id]/route.ts
|
||||||
|
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/app/lib/auth'
|
import { authOptions } from '@/app/lib/auth'
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import type { NextRequest } from 'next/server'
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
const session = await getServerSession(authOptions(req))
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const steamId = session?.user?.steamId
|
||||||
if (!session?.user?.steamId) {
|
if (!steamId) {
|
||||||
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const param = await params
|
|
||||||
const notificationId = param.id
|
|
||||||
|
|
||||||
try {
|
|
||||||
const notification = await prisma.notification.findUnique({
|
const notification = await prisma.notification.findUnique({
|
||||||
where: { id: notificationId },
|
where: { id: params.id },
|
||||||
})
|
})
|
||||||
|
if (!notification || notification.steamId !== steamId) {
|
||||||
if (!notification || notification.steamId !== session.user.steamId) {
|
|
||||||
return NextResponse.json({ error: 'Nicht gefunden oder nicht erlaubt' }, { status: 403 })
|
return NextResponse.json({ error: 'Nicht gefunden oder nicht erlaubt' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.notification.update({
|
await prisma.notification.update({
|
||||||
where: { id: notificationId },
|
where: { id: params.id },
|
||||||
data: { read: true },
|
data: { read: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Notification] Fehler beim Markieren:', error)
|
console.error('[Notification] mark-one-read:', error)
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,10 @@ import { NextResponse, type NextRequest } from 'next/server'
|
|||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import type { Player } from '@/app/types/team'
|
import type { Player } from '@/app/types/team'
|
||||||
|
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: { teamId: string } },
|
{ params }: { params: { teamId: string } },
|
||||||
@ -87,7 +91,11 @@ export async function GET(
|
|||||||
invitedPlayers,
|
invitedPlayers,
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(result)
|
return NextResponse.json(result, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate',
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GET /api/team/[teamId] failed:', error)
|
console.error('GET /api/team/[teamId] failed:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -37,10 +37,15 @@ export async function POST(req: NextRequest) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
/* ▸ SSE-Push --------------------------------------------------------------- */
|
/* ▸ SSE-Push --------------------------------------------------------------- */
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type : 'team-member-joined',
|
||||||
|
teamId,
|
||||||
|
users : steamIds,
|
||||||
|
})
|
||||||
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type : 'team-updated',
|
type : 'team-updated',
|
||||||
teamId : team.id,
|
teamId,
|
||||||
targetUserIds : allPlayers,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
|
|||||||
@ -1,21 +1,20 @@
|
|||||||
// /pages/api/team/change-logo.ts
|
// /pages/api/team/change-logo.ts
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method !== 'POST') return res.status(405).end()
|
if (req.method !== 'POST') return res.status(405).end()
|
||||||
|
|
||||||
const { teamId, logoUrl } = req.body
|
const { teamId, logoUrl } = req.body
|
||||||
|
if (!teamId || !logoUrl) return res.status(400).json({ error: 'Team-ID oder Logo-URL fehlt' })
|
||||||
if (!teamId || !logoUrl) {
|
|
||||||
return res.status(400).json({ error: 'Team-ID oder Logo-URL fehlt' })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.team.update({
|
await prisma.team.update({ where: { id: teamId }, data: { logo: logoUrl } })
|
||||||
where: { id: teamId },
|
|
||||||
data: { logo: logoUrl },
|
// 🔔 spezifisch
|
||||||
})
|
await sendServerSSEMessage({ type: 'team-logo-updated', teamId })
|
||||||
|
// 🔔 generisch
|
||||||
|
await sendServerSSEMessage({ type: 'team-updated', teamId })
|
||||||
|
|
||||||
return res.status(200).json({ success: true })
|
return res.status(200).json({ success: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/app/api/team/kick/route.ts
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
@ -7,90 +8,135 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
/* ───────── 1) Payload prüfen ───────── */
|
/* 1) Payload prüfen */
|
||||||
const { teamId, steamId } = await req.json()
|
const { teamId, steamId } = await req.json()
|
||||||
if (!teamId || !steamId) {
|
if (!teamId || !steamId) {
|
||||||
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
|
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── 2) Team + User laden ─────── */
|
/* 2) Team + User laden */
|
||||||
const team = await prisma.team.findUnique({ where: { id: teamId } })
|
const team = await prisma.team.findUnique({ where: { id: teamId } })
|
||||||
if (!team) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
if (!team) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where : { steamId },
|
where : { steamId },
|
||||||
select: { name: true },
|
select: { name: true, teamId: true },
|
||||||
})
|
})
|
||||||
const userName = user?.name ?? 'Ein Mitglied'
|
const userName = user?.name ?? 'Ein Mitglied'
|
||||||
const teamName = team.name ?? 'Unbekanntes Team'
|
const teamName = team.name ?? 'Unbekanntes Team'
|
||||||
|
|
||||||
/* ───────── 3) Spieler aus Team-Arrays entfernen ───────── */
|
// Mitglied vor Kick?
|
||||||
const active = team.activePlayers.filter(id => id !== steamId)
|
const wasMember =
|
||||||
const inactive = team.inactivePlayers.filter(id => id !== steamId)
|
team.activePlayers.includes(steamId) || team.inactivePlayers.includes(steamId)
|
||||||
|
|
||||||
await prisma.team.update({
|
/* 3) Änderungen atomar durchführen */
|
||||||
|
const { active, inactive } = await prisma.$transaction(async (tx) => {
|
||||||
|
const nextActive = team.activePlayers.filter(id => id !== steamId)
|
||||||
|
const nextInactive = team.inactivePlayers.filter(id => id !== steamId)
|
||||||
|
|
||||||
|
// Team-Arrays aktualisieren
|
||||||
|
await tx.team.update({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
data : {
|
data : {
|
||||||
activePlayers : { set: active },
|
activePlayers : { set: nextActive },
|
||||||
inactivePlayers : { set: inactive },
|
inactivePlayers : { set: nextInactive },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ───────── 4) User vom Team lösen ───────── */
|
// User->Team trennen (idempotent)
|
||||||
await prisma.user.update({ where: { steamId }, data: { teamId: null } })
|
await tx.user.update({
|
||||||
|
where: { steamId },
|
||||||
|
data : { teamId: null },
|
||||||
|
})
|
||||||
|
|
||||||
/* ───────── 5) Spieler aus offenen Matches werfen ───────── */
|
// Offene Team-Invites für diesen User aufräumen
|
||||||
|
await tx.teamInvite.deleteMany({
|
||||||
|
where: { teamId, steamId },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { active: nextActive, inactive: nextInactive }
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 4) Spieler aus offenen Matches entfernen */
|
||||||
|
if (wasMember) {
|
||||||
await removePlayerFromMatches(teamId, steamId)
|
await removePlayerFromMatches(teamId, steamId)
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────── 6) Notifications & SSE ───────── */
|
/* 5) Notifications & SSE */
|
||||||
|
// Zielgruppen bestimmen
|
||||||
|
const allMembersAfter = Array.from(new Set(
|
||||||
|
[team.leaderId, ...active, ...inactive].filter(Boolean) as string[]
|
||||||
|
))
|
||||||
|
const remaining = allMembersAfter.filter(id => id !== steamId)
|
||||||
|
|
||||||
/* an gekickten User */
|
// a) an den Gekickten: sichtbare Notification + Self-Event (UI räumen)
|
||||||
const kickedN = await prisma.notification.create({
|
const kickedN = await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
user : { connect: { steamId } },
|
user : { connect: { steamId } },
|
||||||
title : 'Team verlassen',
|
title : 'Team verlassen',
|
||||||
message : `Du wurdest aus dem Team „${teamName}“ geworfen.`,
|
message : `Du wurdest aus dem Team „${teamName}” entfernt.`,
|
||||||
actionType : 'team-kick',
|
actionType : 'team-kick-self',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// sichtbare Notification (NotificationCenter hört auf type: 'notification')
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type : kickedN.actionType ?? 'notification',
|
type: 'notification',
|
||||||
targetUserIds: [steamId],
|
targetUserIds: [steamId],
|
||||||
id : kickedN.id,
|
|
||||||
message: kickedN.message,
|
message: kickedN.message,
|
||||||
|
id: kickedN.id,
|
||||||
|
actionType: kickedN.actionType ?? undefined,
|
||||||
createdAt: kickedN.createdAt.toISOString(),
|
createdAt: kickedN.createdAt.toISOString(),
|
||||||
})
|
})
|
||||||
|
// Self-Event für Store-Reset/Redirect
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'team-kick-self',
|
||||||
|
teamId,
|
||||||
|
targetUserIds: [steamId],
|
||||||
|
})
|
||||||
|
|
||||||
/* an verbleibende Mitglieder */
|
// b) an verbleibende Mitglieder: sichtbare Info in Echtzeit
|
||||||
const remaining = [...active, ...inactive]
|
if (remaining.length) {
|
||||||
await Promise.all(
|
// DB-Notifications erzeugen
|
||||||
remaining.map(async uid => {
|
const created = await Promise.all(
|
||||||
const n = await prisma.notification.create({
|
remaining.map(uid =>
|
||||||
|
prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
user : { connect: { steamId: uid } },
|
user : { connect: { steamId: uid } },
|
||||||
title : 'Team-Update',
|
title : 'Team-Update',
|
||||||
message : `${userName} wurde aus dem Team „${teamName}“ gekickt.`,
|
message : `${userName} wurde aus dem Team „${teamName}” entfernt.`,
|
||||||
actionType : 'team-kick-other',
|
actionType : 'team-member-kicked',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await sendServerSSEMessage({
|
)
|
||||||
type : n.actionType ?? 'notification',
|
)
|
||||||
targetUserIds: [uid],
|
// sofort zustellen (type: 'notification' → NotificationCenter zeigt Toast)
|
||||||
id : n.id,
|
await Promise.all(
|
||||||
|
created.map(n =>
|
||||||
|
sendServerSSEMessage({
|
||||||
|
type: 'notification',
|
||||||
|
targetUserIds: [n.steamId],
|
||||||
message: n.message,
|
message: n.message,
|
||||||
|
id: n.id,
|
||||||
|
actionType: n.actionType ?? undefined,
|
||||||
createdAt: n.createdAt.toISOString(),
|
createdAt: n.createdAt.toISOString(),
|
||||||
})
|
})
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ► UI neu laden lassen */
|
// c) Ein einziges team-updated an ALLE verbleibenden (inkl. Leader) für UI-Refresh
|
||||||
|
if (allMembersAfter.length) {
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-updated',
|
type: 'team-updated',
|
||||||
teamId,
|
teamId,
|
||||||
targetUserIds : remaining,
|
targetUserIds: allMembersAfter,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Mitglied entfernt' })
|
return NextResponse.json(
|
||||||
|
{ message: 'Mitglied entfernt' },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[KICK] Fehler:', err)
|
console.error('[KICK] Fehler:', err)
|
||||||
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
// src/app/api/team/leave/route.ts
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import { removePlayerFromTeam } from '@/app/lib/removePlayerFromTeam'
|
|
||||||
import { removePlayerFromMatches } from '@/app/lib/removePlayerFromMatches'
|
import { removePlayerFromMatches } from '@/app/lib/removePlayerFromMatches'
|
||||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ message: 'Steam-ID fehlt' }, { status: 400 })
|
return NextResponse.json({ message: 'Steam-ID fehlt' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── 1) Team ermitteln ───────── */
|
/* 1) Team ermitteln, in dem der User aktuell ist (inkl. Leader) */
|
||||||
const team = await prisma.team.findFirst({
|
const team = await prisma.team.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
@ -21,80 +21,130 @@ export async function POST(req: NextRequest) {
|
|||||||
{ inactivePlayers: { has: steamId } },
|
{ inactivePlayers: { has: steamId } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
select: {
|
||||||
if (!team) return NextResponse.json({ message: 'Kein Team gefunden' }, { status: 404 })
|
id: true,
|
||||||
|
name: true,
|
||||||
const { activePlayers, inactivePlayers, leader } = removePlayerFromTeam(
|
leaderId: true,
|
||||||
{ activePlayers: team.activePlayers, inactivePlayers: team.inactivePlayers, leader: team.leaderId },
|
activePlayers: true,
|
||||||
steamId,
|
inactivePlayers: true,
|
||||||
)
|
|
||||||
|
|
||||||
/* ───────── 2) Team anpassen / löschen ───────── */
|
|
||||||
if (!leader) {
|
|
||||||
await prisma.team.delete({ where: { id: team.id } })
|
|
||||||
} else {
|
|
||||||
await prisma.team.update({
|
|
||||||
where: { id: team.id },
|
|
||||||
data : {
|
|
||||||
leader: { connect: { steamId: leader } },
|
|
||||||
activePlayers,
|
|
||||||
inactivePlayers,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (!team) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Kein Team gefunden' },
|
||||||
|
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── 3) User lösen ───────── */
|
const user = await prisma.user.findUnique({
|
||||||
await prisma.user.update({ where: { steamId }, data: { teamId: null } })
|
where: { steamId },
|
||||||
|
select: { name: true },
|
||||||
/* ───────── 4) Spieler aus Matches entfernen ───────── */
|
})
|
||||||
await removePlayerFromMatches(team.id, steamId)
|
|
||||||
|
|
||||||
/* ───────── 5) Notifications ───────── */
|
|
||||||
const user = await prisma.user.findUnique({ where: { steamId }, select: { name: true } })
|
|
||||||
const userName = user?.name ?? 'Ein Spieler'
|
const userName = user?.name ?? 'Ein Spieler'
|
||||||
const teamName = team.name ?? 'Dein Team'
|
const teamName = team.name ?? 'Dein Team'
|
||||||
const remaining = [...activePlayers, ...inactivePlayers].filter(id => id !== steamId)
|
|
||||||
|
|
||||||
/* an leavenden User */
|
/* 2) Atomar: User aus Team entfernen, User.teamId null, Invites aufräumen */
|
||||||
const leaveN = await prisma.notification.create({
|
const txResult = await prisma.$transaction(async (tx) => {
|
||||||
|
const nextActive = team.activePlayers.filter((id) => id !== steamId)
|
||||||
|
const nextInactive = team.inactivePlayers.filter((id) => id !== steamId)
|
||||||
|
|
||||||
|
// Team-Listen aktualisieren (keine automatische Leader-Änderung)
|
||||||
|
await tx.team.update({
|
||||||
|
where: { id: team.id },
|
||||||
data: {
|
data: {
|
||||||
user : { connect: { steamId } },
|
activePlayers: { set: nextActive },
|
||||||
title : 'Teamupdate',
|
inactivePlayers: { set: nextInactive },
|
||||||
message : `Du hast das Team „${teamName}“ verlassen.`,
|
|
||||||
actionType : 'team-left',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await sendServerSSEMessage({
|
|
||||||
type : leaveN.actionType ?? 'notification',
|
// User vom Team lösen (idempotent)
|
||||||
targetUserIds: [steamId],
|
await tx.user.update({
|
||||||
id : leaveN.id,
|
where: { steamId },
|
||||||
message : leaveN.message,
|
data: { teamId: null },
|
||||||
createdAt : leaveN.createdAt.toISOString(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* an verbleibende Mitglieder */
|
// Eventuelle Einladungen zu diesem Team für den User aufräumen
|
||||||
await Promise.all(
|
await tx.teamInvite.deleteMany({
|
||||||
remaining.map(async uid => {
|
where: { teamId: team.id, steamId },
|
||||||
const n = await prisma.notification.create({
|
})
|
||||||
|
|
||||||
|
return { nextActive, nextInactive }
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 3) Spieler aus offenen Matches entfernen */
|
||||||
|
await removePlayerFromMatches(team.id, steamId)
|
||||||
|
|
||||||
|
/* 4) Notifications & SSE in Echtzeit */
|
||||||
|
|
||||||
|
// a) an den Leaver: sichtbare Notification + Self-Event (UI räumen)
|
||||||
|
const leaveN = await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
user : { connect: { steamId: uid } },
|
steamId,
|
||||||
|
title: 'Teamupdate',
|
||||||
|
message: `Du hast das Team „${teamName}“ verlassen.`,
|
||||||
|
actionType: 'team-left-self',
|
||||||
|
actionData: team.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// sichtbare Toast/Dropdown-Notification
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'notification',
|
||||||
|
targetUserIds: [steamId],
|
||||||
|
message: leaveN.message,
|
||||||
|
id: leaveN.id,
|
||||||
|
actionType: leaveN.actionType ?? undefined,
|
||||||
|
actionData: leaveN.actionData ?? undefined,
|
||||||
|
createdAt: leaveN.createdAt.toISOString(),
|
||||||
|
})
|
||||||
|
// Self-Event für Store-Reset/Redirect
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'team-left-self',
|
||||||
|
teamId: team.id,
|
||||||
|
targetUserIds: [steamId],
|
||||||
|
})
|
||||||
|
|
||||||
|
// b) an die Verbleibenden (inkl. Leader): sichtbare Info in Echtzeit
|
||||||
|
const remaining = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
team.leaderId,
|
||||||
|
...(txResult.nextActive ?? []),
|
||||||
|
...(txResult.nextInactive ?? []),
|
||||||
|
].filter(Boolean) as string[],
|
||||||
|
),
|
||||||
|
).filter((id) => id !== steamId)
|
||||||
|
|
||||||
|
if (remaining.length) {
|
||||||
|
const created = await Promise.all(
|
||||||
|
remaining.map((uid) =>
|
||||||
|
prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
steamId: uid,
|
||||||
title: 'Teamupdate',
|
title: 'Teamupdate',
|
||||||
message: `${userName} hat das Team verlassen.`,
|
message: `${userName} hat das Team verlassen.`,
|
||||||
actionType: 'team-member-left',
|
actionType: 'team-member-left',
|
||||||
|
actionData: steamId,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await sendServerSSEMessage({
|
|
||||||
type : n.actionType ?? 'notification',
|
|
||||||
targetUserIds: [uid],
|
|
||||||
id : n.id,
|
|
||||||
message : n.message,
|
|
||||||
createdAt : n.createdAt.toISOString(),
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ► UI neu laden lassen */
|
// sofort zustellen (sichtbar im NotificationCenter)
|
||||||
if (remaining.length) {
|
await Promise.all(
|
||||||
|
created.map((n) =>
|
||||||
|
sendServerSSEMessage({
|
||||||
|
type: 'notification',
|
||||||
|
targetUserIds: [n.steamId],
|
||||||
|
message: n.message,
|
||||||
|
id: n.id,
|
||||||
|
actionType: n.actionType ?? undefined,
|
||||||
|
actionData: n.actionData ?? undefined,
|
||||||
|
createdAt: n.createdAt.toISOString(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// zentrales UI-Refresh-Signal
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-updated',
|
type: 'team-updated',
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
@ -102,7 +152,10 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Erfolgreich aus dem Team entfernt' })
|
return NextResponse.json(
|
||||||
|
{ message: 'Erfolgreich aus dem Team ausgetreten' },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[LEAVE] Fehler:', err)
|
console.error('[LEAVE] Fehler:', err)
|
||||||
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
||||||
|
|||||||
@ -3,28 +3,104 @@ import { NextResponse, type NextRequest } from 'next/server'
|
|||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { teamId, newName } = await req.json()
|
const { teamId, newName } = await req.json()
|
||||||
|
const name = (newName ?? '').trim()
|
||||||
|
|
||||||
if (!teamId || !newName) {
|
if (!teamId || !name) {
|
||||||
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
|
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.team.update({
|
// Team + Member laden (für Zielgruppe)
|
||||||
|
const teamBefore = await prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
data: { name: newName },
|
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
})
|
})
|
||||||
|
if (!teamBefore) {
|
||||||
|
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
// 🔔 SSE Nachricht an alle User (global)
|
// umbenennen (Unique-Name beachten)
|
||||||
|
let updated
|
||||||
|
try {
|
||||||
|
updated = await prisma.team.update({
|
||||||
|
where: { id: teamId },
|
||||||
|
data: { name },
|
||||||
|
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'P2002') {
|
||||||
|
return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 })
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zielnutzer (Leader + aktive + inaktive)
|
||||||
|
const targets = Array.from(new Set(
|
||||||
|
[
|
||||||
|
updated.leaderId,
|
||||||
|
...(updated.activePlayers ?? []),
|
||||||
|
...(updated.inactivePlayers ?? []),
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
))
|
||||||
|
|
||||||
|
const text = `Team wurde umbenannt in "${updated.name}".`
|
||||||
|
|
||||||
|
// Optional: persistente Notifications (sichtbar im Dropdown + Live via SSE)
|
||||||
|
if (targets.length) {
|
||||||
|
const created = await Promise.all(
|
||||||
|
targets.map(steamId =>
|
||||||
|
prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
steamId,
|
||||||
|
title: 'Team umbenannt',
|
||||||
|
message: text,
|
||||||
|
actionType: 'team-renamed',
|
||||||
|
actionData: updated.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// live zustellen als sichtbare Notification
|
||||||
|
await Promise.all(
|
||||||
|
created.map(n =>
|
||||||
|
sendServerSSEMessage({
|
||||||
|
type: 'notification',
|
||||||
|
targetUserIds: [n.steamId],
|
||||||
|
message: n.message,
|
||||||
|
id: n.id,
|
||||||
|
actionType: n.actionType ?? undefined,
|
||||||
|
actionData: n.actionData ?? undefined,
|
||||||
|
createdAt: n.createdAt.toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team-Event (für SSEHandler → soft reload, keine Broadcasts)
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-renamed',
|
type: 'team-renamed',
|
||||||
title: 'Team umbenannt!',
|
|
||||||
message: `Das Team wurde umbenannt in "${newName}".`,
|
|
||||||
teamId,
|
teamId,
|
||||||
|
targetUserIds: targets,
|
||||||
|
message: text,
|
||||||
|
newName: updated.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
// Generisches Reload-Signal (failsafe)
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'team-updated',
|
||||||
|
teamId,
|
||||||
|
targetUserIds: targets,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, team: { id: updated.id, name: updated.name } },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Umbenennen:', err)
|
console.error('Fehler beim Umbenennen:', err)
|
||||||
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
||||||
|
|||||||
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 { prisma } from '@/app/lib/prisma'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { teamId, newLeaderSteamId } = await req.json()
|
const { teamId, newLeaderSteamId } = await req.json()
|
||||||
|
|
||||||
/* ────────────── Parameter prüfen ────────────── */
|
|
||||||
if (!teamId || !newLeaderSteamId) {
|
if (!teamId || !newLeaderSteamId) {
|
||||||
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────── Team holen ───────────────────── */
|
const team = await prisma.team.findUnique({
|
||||||
const team = await prisma.team.findUnique({ where: { id: teamId } })
|
where: { id: teamId },
|
||||||
|
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
|
})
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
|
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────── Mitgliedschaft prüfen ────────── */
|
const allPlayerIds = Array.from(new Set([
|
||||||
const allPlayerIds = Array.from(
|
|
||||||
new Set([
|
|
||||||
...(team.activePlayers ?? []),
|
...(team.activePlayers ?? []),
|
||||||
...(team.inactivePlayers ?? []),
|
...(team.inactivePlayers ?? []),
|
||||||
]),
|
team.leaderId, // alter Leader (kann null sein)
|
||||||
)
|
].filter(Boolean) as string[]))
|
||||||
|
|
||||||
|
// Neuer Leader muss Mitglied sein
|
||||||
if (!allPlayerIds.includes(newLeaderSteamId)) {
|
if (!allPlayerIds.includes(newLeaderSteamId)) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 })
|
||||||
message: 'Neuer Leader ist kein Teammitglied.',
|
|
||||||
}, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────── Leader setzen ────────────────── */
|
// Leader setzen
|
||||||
await prisma.team.update({
|
await prisma.team.update({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
data : { leaderId: newLeaderSteamId },
|
data : { leaderId: newLeaderSteamId },
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ────────────── Namen des neuen Leaders ───────── */
|
// Namen neuer Leader
|
||||||
const newLeader = await prisma.user.findUnique({
|
const newLeader = await prisma.user.findUnique({
|
||||||
where : { steamId: newLeaderSteamId },
|
where : { steamId: newLeaderSteamId },
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ────────── 1) Notification an neuen Leader ───── */
|
const textForOthers =
|
||||||
|
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
|
||||||
|
|
||||||
|
// 1) Notification an neuen Leader (sichtbar + live)
|
||||||
const leaderNote = await prisma.notification.create({
|
const leaderNote = await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
steamId : newLeaderSteamId,
|
steamId : newLeaderSteamId,
|
||||||
@ -53,9 +57,8 @@ export async function POST(req: NextRequest) {
|
|||||||
actionData: teamId,
|
actionData: teamId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type : leaderNote.actionType ?? 'notification',
|
type : 'notification',
|
||||||
targetUserIds: [newLeaderSteamId],
|
targetUserIds: [newLeaderSteamId],
|
||||||
message : leaderNote.message,
|
message : leaderNote.message,
|
||||||
id : leaderNote.id,
|
id : leaderNote.id,
|
||||||
@ -64,55 +67,60 @@ export async function POST(req: NextRequest) {
|
|||||||
createdAt : leaderNote.createdAt.toISOString(),
|
createdAt : leaderNote.createdAt.toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ────────── 2) Info an alle anderen ───────────── */
|
// 2) Info an alle anderen (sichtbar + live)
|
||||||
const others: string[] = [
|
const others = allPlayerIds.filter(id => id !== newLeaderSteamId)
|
||||||
...allPlayerIds,
|
|
||||||
team.leaderId ?? undefined, // alter Leader (kann null sein)
|
|
||||||
]
|
|
||||||
/* Type-Guard: nur echte Strings behalten */
|
|
||||||
.filter((id): id is string => typeof id === 'string' && id !== newLeaderSteamId)
|
|
||||||
|
|
||||||
if (others.length) {
|
if (others.length) {
|
||||||
const text =
|
|
||||||
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
|
|
||||||
|
|
||||||
const notes = await Promise.all(
|
const notes = await Promise.all(
|
||||||
others.map(steamId =>
|
others.map(steamId =>
|
||||||
prisma.notification.create({
|
prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
steamId,
|
steamId,
|
||||||
title: 'Neuer Teamleader',
|
title: 'Neuer Teamleader',
|
||||||
message: text,
|
message: textForOthers,
|
||||||
actionType: 'team-leader-changed',
|
actionType: 'team-leader-changed',
|
||||||
actionData: newLeaderSteamId,
|
actionData: newLeaderSteamId,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
notes.map(n =>
|
||||||
|
sendServerSSEMessage({
|
||||||
|
type: 'notification',
|
||||||
|
targetUserIds: [n.steamId],
|
||||||
|
message: n.message,
|
||||||
|
id: n.id,
|
||||||
|
actionType: n.actionType ?? undefined,
|
||||||
|
actionData: n.actionData ?? undefined,
|
||||||
|
createdAt: n.createdAt.toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// zusätzliches Team-Event (für SSEHandler → soft reload)
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-leader-changed',
|
type: 'team-leader-changed',
|
||||||
targetUserIds: others,
|
targetUserIds: others,
|
||||||
message : text,
|
teamId,
|
||||||
id : notes[0].id, // eine Referenz-ID reicht
|
message: textForOthers,
|
||||||
actionType : 'team-leader-changed',
|
|
||||||
actionData: newLeaderSteamId,
|
actionData: newLeaderSteamId,
|
||||||
createdAt : notes[0].createdAt.toISOString(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 3) Globales “team-updated” an ALLE ──────────────── */
|
// 3) Zielgerichtetes “team-updated” an ALLE (inkl. neuem Leader)
|
||||||
|
const reloadTargets = Array.from(new Set([...allPlayerIds, newLeaderSteamId]))
|
||||||
|
if (reloadTargets.length) {
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-updated',
|
type: 'team-updated',
|
||||||
targetUserIds: allPlayerIds,
|
targetUserIds: reloadTargets,
|
||||||
teamId,
|
teamId,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
|
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Leaderwechsel:', error)
|
console.error('Fehler beim Leaderwechsel:', error)
|
||||||
return NextResponse.json({
|
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
|
||||||
message: 'Serverfehler beim Leaderwechsel.',
|
|
||||||
}, { status: 500 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,56 +6,59 @@ import { NextResponse, type NextRequest } from 'next/server'
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
|
const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
|
||||||
|
|
||||||
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
|
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
|
||||||
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
|
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🟢 Team-Update: aktive & inaktive Spieler
|
// 1) Altzustand
|
||||||
|
const prev = await prisma.team.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
include: { invites: true },
|
||||||
|
})
|
||||||
|
const prevMembers = new Set([...(prev?.activePlayers ?? []), ...(prev?.inactivePlayers ?? [])])
|
||||||
|
|
||||||
|
// 2) ✏️ Neues speichern
|
||||||
await prisma.team.update({
|
await prisma.team.update({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
data: { activePlayers, inactivePlayers },
|
data: { activePlayers, inactivePlayers },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 🔄 Einladungen synchronisieren
|
// 3) Invites syncen (wie gehabt)
|
||||||
if (Array.isArray(invitedPlayers)) {
|
if (Array.isArray(invitedPlayers)) {
|
||||||
// Zuerst: Bestehende Einladungen für dieses Team laden
|
const existingInvites = await prisma.teamInvite.findMany({ where: { teamId } })
|
||||||
const existingInvites = await prisma.teamInvite.findMany({
|
const existingSteamIds = existingInvites.map(i => i.steamId)
|
||||||
where: { teamId },
|
|
||||||
})
|
|
||||||
|
|
||||||
const existingSteamIds = existingInvites.map((invite) => invite.steamId)
|
|
||||||
const toAdd = invitedPlayers.filter((id: string) => !existingSteamIds.includes(id))
|
const toAdd = invitedPlayers.filter((id: string) => !existingSteamIds.includes(id))
|
||||||
const toRemove = existingSteamIds.filter((id) => !invitedPlayers.includes(id))
|
const toRemove = existingSteamIds.filter((id) => !invitedPlayers.includes(id))
|
||||||
|
if (toAdd.length) await prisma.teamInvite.createMany({
|
||||||
// Neue Einladungen erstellen
|
data: toAdd.map((steamId: string) => ({ teamId, steamId, type: 'invite' })), skipDuplicates: true,
|
||||||
await prisma.teamInvite.createMany({
|
|
||||||
data: toAdd.map((steamId: string) => ({
|
|
||||||
teamId,
|
|
||||||
steamId,
|
|
||||||
type: 'invite',
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
})
|
||||||
|
if (toRemove.length) await prisma.teamInvite.deleteMany({
|
||||||
// Nicht mehr gelistete Einladungen löschen
|
where: { teamId, steamId: { in: toRemove } },
|
||||||
await prisma.teamInvite.deleteMany({
|
|
||||||
where: {
|
|
||||||
teamId,
|
|
||||||
steamId: { in: toRemove },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSteamIds = [...activePlayers, ...inactivePlayers, ...(invitedPlayers || [])]
|
// 4) Diff ermitteln (joined/left)
|
||||||
|
const nextMembers = new Set<string>([...activePlayers, ...inactivePlayers])
|
||||||
|
const joined: string[] = []
|
||||||
|
const left: string[] = []
|
||||||
|
for (const id of nextMembers) if (!prevMembers.has(id)) joined.push(id)
|
||||||
|
for (const id of prevMembers) if (!nextMembers.has(id)) left.push(id)
|
||||||
|
|
||||||
await sendServerSSEMessage({
|
// 🔔 spezifische Events (Broadcast)
|
||||||
type: 'team-updated',
|
if (joined.length) {
|
||||||
teamId,
|
await sendServerSSEMessage({ type: 'team-member-joined', teamId, users: joined })
|
||||||
targetUserIds: allSteamIds,
|
}
|
||||||
})
|
if (left.length) {
|
||||||
|
await sendServerSSEMessage({ type: 'team-member-left', teamId, users: left })
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Team-Mitglieder erfolgreich aktualisiert' })
|
// 🔔 generisch: Broadcast-Reload
|
||||||
|
await sendServerSSEMessage({ type: 'team-updated', teamId })
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Team-Mitglieder erfolgreich aktualisiert' },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Aktualisieren der Team-Mitglieder:', error)
|
console.error('Fehler beim Aktualisieren der Team-Mitglieder:', error)
|
||||||
return NextResponse.json({ error: 'Serverfehler beim Aktualisieren' }, { status: 500 })
|
return NextResponse.json({ error: 'Serverfehler beim Aktualisieren' }, { status: 500 })
|
||||||
|
|||||||
@ -7,11 +7,10 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { action: string } }
|
{ params }: { params: { action: 'accept' | 'reject' | 'revoke' } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const param = await params
|
const { action } = params
|
||||||
const action = param.action
|
|
||||||
const { invitationId } = await req.json()
|
const { invitationId } = await req.json()
|
||||||
|
|
||||||
if (!invitationId) {
|
if (!invitationId) {
|
||||||
@ -23,144 +22,171 @@ export async function POST(
|
|||||||
return NextResponse.json({ message: 'Einladung existiert nicht mehr' }, { status: 404 })
|
return NextResponse.json({ message: 'Einladung existiert nicht mehr' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { steamId, teamId, type } = invitation
|
const { steamId: invitedUserSteamId, teamId } = invitation
|
||||||
|
|
||||||
if (action === 'accept') {
|
if (action === 'accept') {
|
||||||
await prisma.user.update({ where: { steamId }, data: { teamId } })
|
await prisma.user.update({ where: { steamId: invitedUserSteamId }, data: { teamId } })
|
||||||
|
|
||||||
|
const teamBefore = await prisma.team.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
select: { name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextInactive = Array.from(new Set([...(teamBefore?.inactivePlayers ?? []), invitedUserSteamId]))
|
||||||
|
|
||||||
await prisma.team.update({
|
await prisma.team.update({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
data: { inactivePlayers: { push: steamId } },
|
data: { inactivePlayers: nextInactive },
|
||||||
})
|
})
|
||||||
|
|
||||||
await prisma.teamInvite.delete({ where: { id: invitationId } })
|
await prisma.teamInvite.delete({ where: { id: invitationId } })
|
||||||
await prisma.notification.updateMany({
|
await prisma.notification.updateMany({
|
||||||
where: { steamId, actionType: 'team-invite', actionData: invitationId },
|
where: { actionData: invitationId },
|
||||||
data: { read: true, actionType: null, actionData: null },
|
data: { read: true, actionType: null, actionData: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
select: { name: true, activePlayers: true, inactivePlayers: true },
|
select: { name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const allSteamIds = Array.from(new Set([
|
const allMembers = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
team?.leaderId,
|
||||||
...(team?.activePlayers ?? []),
|
...(team?.activePlayers ?? []),
|
||||||
...(team?.inactivePlayers ?? []),
|
...(team?.inactivePlayers ?? []),
|
||||||
]))
|
].filter(Boolean) as string[]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const notification = await prisma.notification.create({
|
const joinedNotif = await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
steamId,
|
steamId: invitedUserSteamId,
|
||||||
title: 'Teambeitritt',
|
title: 'Teambeitritt',
|
||||||
message: `Du bist dem Team "${team?.name ?? 'Unbekannt'}" beigetreten.`,
|
message: `Du bist dem Team "${team?.name ?? 'Unbekannt'}" beigetreten.`,
|
||||||
actionType: 'team-joined',
|
actionType: 'team-joined',
|
||||||
actionData: teamId,
|
actionData: teamId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: notification.actionType ?? 'notification',
|
type: joinedNotif.actionType ?? 'notification',
|
||||||
targetUserIds: [steamId],
|
targetUserIds: [invitedUserSteamId],
|
||||||
message: notification.message,
|
message: joinedNotif.message,
|
||||||
id: notification.id,
|
id: joinedNotif.id,
|
||||||
actionType: notification.actionType ?? undefined,
|
actionType: joinedNotif.actionType ?? undefined,
|
||||||
actionData: notification.actionData ?? undefined,
|
actionData: joinedNotif.actionData ?? undefined,
|
||||||
createdAt: notification.createdAt.toISOString(),
|
createdAt: joinedNotif.createdAt.toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const joiningUser = await prisma.user.findUnique({
|
const joiningUser = await prisma.user.findUnique({
|
||||||
where: { steamId },
|
where: { steamId: invitedUserSteamId },
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
})
|
})
|
||||||
|
const others = allMembers.filter(id => id !== invitedUserSteamId)
|
||||||
const otherUserIds = allSteamIds.filter(id => id !== steamId)
|
if (others.length) {
|
||||||
await Promise.all(
|
const created = await Promise.all(
|
||||||
otherUserIds.map(async (otherUserId) => {
|
others.map(uid =>
|
||||||
const notification = await prisma.notification.create({
|
prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
steamId: otherUserId,
|
steamId: uid,
|
||||||
title: 'Neues Mitglied',
|
title: 'Neues Mitglied',
|
||||||
message: `${joiningUser?.name ?? 'Ein Spieler'} ist deinem Team beigetreten.`,
|
message: `${joiningUser?.name ?? 'Ein Spieler'} ist deinem Team beigetreten.`,
|
||||||
actionType: 'team-member-joined',
|
actionType: 'team-member-joined',
|
||||||
actionData: steamId,
|
actionData: invitedUserSteamId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
)
|
||||||
await sendServerSSEMessage({
|
)
|
||||||
type: notification.actionType ?? 'notification',
|
await Promise.all(
|
||||||
targetUserIds: [otherUserId],
|
created.map(n =>
|
||||||
message: notification.message,
|
sendServerSSEMessage({
|
||||||
id: notification.id,
|
type: n.actionType ?? 'notification',
|
||||||
actionType: notification.actionType ?? undefined,
|
targetUserIds: [n.steamId],
|
||||||
actionData: notification.actionData ?? undefined,
|
message: n.message,
|
||||||
createdAt: notification.createdAt.toISOString(),
|
id: n.id,
|
||||||
|
actionType: n.actionType ?? undefined,
|
||||||
|
actionData: n.actionData ?? undefined,
|
||||||
|
createdAt: n.createdAt.toISOString(),
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-updated',
|
type: 'team-updated',
|
||||||
teamId,
|
teamId,
|
||||||
targetUserIds: allSteamIds,
|
targetUserIds: allMembers,
|
||||||
})
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Einladung angenommen' })
|
return NextResponse.json({ message: 'Einladung angenommen' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'reject') {
|
if (action === 'reject') {
|
||||||
const team = await prisma.team.findUnique({
|
// Einladung löschen & zugehörige Notifications aufräumen (keine sichtbare Nachricht)
|
||||||
where: { id: teamId },
|
|
||||||
select: { name: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.teamInvite.delete({ where: { id: invitationId } })
|
await prisma.teamInvite.delete({ where: { id: invitationId } })
|
||||||
|
|
||||||
await prisma.notification.updateMany({
|
await prisma.notification.updateMany({
|
||||||
where: { steamId, actionData: invitationId },
|
where: { actionData: invitationId },
|
||||||
data: { read: true, actionType: null, actionData: null },
|
data: { read: true, actionType: null, actionData: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const eventType = type === 'team-join-request'
|
// ➜ Team-Mitglieder ermitteln (Leader + aktive + inaktive), ohne die eingeladene Person
|
||||||
? 'team-join-request-reject'
|
const team = await prisma.team.findUnique({
|
||||||
: 'team-invite-reject'
|
where: { id: teamId },
|
||||||
|
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
await sendServerSSEMessage({
|
|
||||||
type: eventType,
|
|
||||||
targetUserIds: [steamId],
|
|
||||||
message: `Einladung zu Team "${team?.name}" wurde abgelehnt.`,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const remainingMembers = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
team?.leaderId,
|
||||||
|
...(team?.activePlayers ?? []),
|
||||||
|
...(team?.inactivePlayers ?? []),
|
||||||
|
]
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
)
|
||||||
|
).filter(id => id !== invitedUserSteamId)
|
||||||
|
|
||||||
|
// ➜ Silent UI Refresh via SSE für die verbleibenden Mitglieder
|
||||||
|
if (remainingMembers.length) {
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'team-updated',
|
||||||
|
teamId,
|
||||||
|
targetUserIds: remainingMembers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Einladung abgelehnt' })
|
return NextResponse.json({ message: 'Einladung abgelehnt' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'revoke') {
|
if (action === 'revoke') {
|
||||||
await prisma.teamInvite.delete({ where: { id: invitationId } })
|
await prisma.teamInvite.delete({ where: { id: invitationId } })
|
||||||
|
|
||||||
await prisma.notification.updateMany({
|
await prisma.notification.updateMany({
|
||||||
where: { steamId, actionData: invitationId },
|
where: { actionData: invitationId },
|
||||||
data: { read: true, actionType: null, actionData: null },
|
data: { read: true, actionType: null, actionData: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// 1. Teamdaten laden (inkl. Leader)
|
|
||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
select: { leader: true },
|
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Admins holen
|
|
||||||
const admins = await prisma.user.findMany({
|
const admins = await prisma.user.findMany({
|
||||||
where: { isAdmin: true },
|
where: { isAdmin: true },
|
||||||
select: { steamId: true },
|
select: { steamId: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. Zielnutzer: Leader + Admins
|
const targetUserIds = Array.from(
|
||||||
const targetUserIds = [
|
new Set(
|
||||||
team?.leader,
|
[
|
||||||
...admins.map(admin => admin.steamId),
|
team?.leaderId,
|
||||||
].filter(Boolean) // entfernt null/undefined
|
...(team?.activePlayers ?? []),
|
||||||
|
...(team?.inactivePlayers ?? []),
|
||||||
|
...admins.map(a => a.steamId),
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// 4. SSE senden
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-updated',
|
type: 'team-updated',
|
||||||
teamId,
|
teamId,
|
||||||
|
|||||||
@ -7,14 +7,14 @@ import { prisma } from '@/app/lib/prisma'
|
|||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions(req))
|
const session = await getServerSession(authOptions(req))
|
||||||
if (!session?.user?.steamId) {
|
const steamId = session?.user?.steamId
|
||||||
|
if (!steamId) {
|
||||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Einladungen inkl. roher Teamdaten (IDs) laden
|
||||||
const invitations = await prisma.teamInvite.findMany({
|
const invitations = await prisma.teamInvite.findMany({
|
||||||
where: {
|
where: { steamId },
|
||||||
steamId: session.user.steamId,
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
teamId: true,
|
teamId: true,
|
||||||
@ -22,14 +22,75 @@ export async function GET(req: NextRequest) {
|
|||||||
type: true,
|
type: true,
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
}
|
logo: true,
|
||||||
}
|
leaderId: true,
|
||||||
|
activePlayers: true, // Array<string> (steamIds)
|
||||||
|
inactivePlayers: true, // Array<string> (steamIds)
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' }
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ invitations })
|
if (invitations.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ invitations: [] },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle benötigten SteamIDs sammeln (aktiv + inaktiv)
|
||||||
|
const allSteamIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
invitations.flatMap(inv => [
|
||||||
|
...(inv.team?.activePlayers ?? []),
|
||||||
|
...(inv.team?.inactivePlayers ?? []),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Userdaten der Members laden (Name, Avatar)
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { steamId: { in: allSteamIds } },
|
||||||
|
select: { steamId: true, name: true, avatar: true },
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map(u => [u.steamId, u]))
|
||||||
|
|
||||||
|
// Team-Player-Arrays von IDs -> Objekte mappen (Reihenfolge beibehalten)
|
||||||
|
const mapIds = (ids: string[] = []) =>
|
||||||
|
ids.map(id => {
|
||||||
|
const u = userMap.get(id)
|
||||||
|
return {
|
||||||
|
steamId: id,
|
||||||
|
name: u?.name ?? 'Unbekannt',
|
||||||
|
avatar: u?.avatar ?? '/assets/img/avatars/default.webp',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const shaped = invitations.map(inv => {
|
||||||
|
const t = inv.team
|
||||||
|
return {
|
||||||
|
id: inv.id,
|
||||||
|
teamId: inv.teamId,
|
||||||
|
createdAt: inv.createdAt,
|
||||||
|
type: inv.type,
|
||||||
|
team: t && {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
logo: t.logo, // ✅ Logo mitgeben
|
||||||
|
leader: t.leaderId ?? null, // optional je nach Frontend
|
||||||
|
activePlayers: mapIds(t.activePlayers ?? []),
|
||||||
|
inactivePlayers: mapIds(t.inactivePlayers ?? []),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ invitations: shaped },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Laden der Einladungen:', err)
|
console.error('Fehler beim Laden der Einladungen:', err)
|
||||||
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export default function Card({
|
|||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
|
flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
|
||||||
dark:bg-neutral-900 dark:border-neutral-700 dark:shadow-neutral-700/70
|
dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70
|
||||||
${alignClasses} ${widthClasses[maxWidth]}
|
${alignClasses} ${widthClasses[maxWidth]}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,50 +1,150 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import TeamCard from './TeamCard'
|
import TeamCard from './TeamCard'
|
||||||
import { Team } from '../types/team'
|
import { Team, Player } from '../types/team'
|
||||||
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
|
|
||||||
|
/** flache Vergleiche, um unnötiges SetState/Flicker zu vermeiden */
|
||||||
|
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a,b)=>a.steamId.localeCompare(b.steamId))
|
||||||
|
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const eqTeam = (a: Team, b: Team) => {
|
||||||
|
if (a.id !== b.id) return false
|
||||||
|
if (a.name !== b.name) return false
|
||||||
|
if (a.logo !== b.logo) return false
|
||||||
|
if (a.leader !== b.leader) return false
|
||||||
|
return eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
|
||||||
|
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
|
||||||
|
}
|
||||||
|
const eqTeamList = (a: Team[], b: Team[]) => {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
// indexiert nach id
|
||||||
|
const mapA = new Map(a.map(t => [t.id, t]))
|
||||||
|
for (const t of b) {
|
||||||
|
const x = mapA.get(t.id)
|
||||||
|
if (!x || !eqTeam(x, t)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEAM_EVENTS = new Set([
|
||||||
|
'team-updated',
|
||||||
|
'team-leader-changed',
|
||||||
|
'team-member-joined',
|
||||||
|
'team-member-left',
|
||||||
|
'team-renamed',
|
||||||
|
'team-logo-updated',
|
||||||
|
])
|
||||||
|
|
||||||
|
const INVITE_EVENTS = new Set([
|
||||||
|
'team-invite',
|
||||||
|
'team-join-request',
|
||||||
|
'team-invite-reject',
|
||||||
|
'team-join-request-reject',
|
||||||
|
])
|
||||||
|
|
||||||
export default function NoTeamView() {
|
export default function NoTeamView() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
|
const currentSteamId = session?.user?.steamId || ''
|
||||||
|
const { lastEvent } = useSSEStore() // nur konsumieren – Verbindung macht dein globaler SSEHandler
|
||||||
|
|
||||||
const [teams, setTeams] = useState<Team[]>([])
|
const [teams, setTeams] = useState<Team[]>([])
|
||||||
const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>({})
|
const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const fetchData = async () => {
|
// Dedupe / Throttle
|
||||||
setLoading(true)
|
const inflight = useRef<AbortController | null>(null)
|
||||||
|
const lastReloadAt = useRef(0)
|
||||||
|
const cooldownMs = 350
|
||||||
|
|
||||||
|
const fetchTeamsAndInvitations = async (withCooldown = false) => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (withCooldown && now - lastReloadAt.current < cooldownMs) return
|
||||||
|
lastReloadAt.current = now
|
||||||
|
|
||||||
|
if (inflight.current) inflight.current.abort()
|
||||||
|
const ac = new AbortController()
|
||||||
|
inflight.current = ac
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(prev => prev && true) // nur beim ersten Mal Spinner zeigen
|
||||||
|
|
||||||
const [teamRes, invitesRes] = await Promise.all([
|
const [teamRes, invitesRes] = await Promise.all([
|
||||||
fetch('/api/team/list'),
|
fetch('/api/team/list', { cache: 'no-store', signal: ac.signal }),
|
||||||
fetch('/api/user/invitations'),
|
fetch('/api/user/invitations', { cache: 'no-store', signal: ac.signal }),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if (!teamRes.ok) throw new Error('Teamliste fehlgeschlagen')
|
||||||
|
if (!invitesRes.ok) throw new Error('Einladungen fehlgeschlagen')
|
||||||
|
|
||||||
const teamData = await teamRes.json()
|
const teamData = await teamRes.json()
|
||||||
const inviteData = await invitesRes.json()
|
const inviteData = await invitesRes.json()
|
||||||
|
|
||||||
setTeams(teamData.teams || [])
|
const nextTeams: Team[] = teamData.teams || []
|
||||||
|
// nur Join-Requests der aktuellen Person mappen (für Button-Label “Angefragt”)
|
||||||
const mapping: Record<string, string> = {}
|
const mapping: Record<string, string> = {}
|
||||||
for (const invite of inviteData?.invitations || []) {
|
for (const inv of inviteData?.invitations || []) {
|
||||||
if (invite.type === 'team-join-request') {
|
if (inv.type === 'team-join-request') {
|
||||||
mapping[invite.teamId] = invite.id
|
mapping[inv.teamId] = inv.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTeamToInvitationId(mapping)
|
|
||||||
|
// nur setzen, wenn sich wirklich etwas geändert hat
|
||||||
|
setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams))
|
||||||
|
setTeamToInvitationId(prev => {
|
||||||
|
const same =
|
||||||
|
Object.keys(prev).length === Object.keys(mapping).length &&
|
||||||
|
Object.keys(prev).every(k => prev[k] === mapping[k])
|
||||||
|
return same ? prev : mapping
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== 'AbortError') console.error('[NoTeamView] fetch error:', e)
|
||||||
|
} finally {
|
||||||
|
if (inflight.current === ac) inflight.current = null
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial
|
||||||
|
useEffect(() => { fetchTeamsAndInvitations(false) }, [])
|
||||||
|
|
||||||
|
// auf SSE reagieren: nur dann nachladen, wenn das Event relevant ist
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
if (!lastEvent) return
|
||||||
}, [])
|
const { type, payload } = lastEvent
|
||||||
|
|
||||||
const updateInvitationMap = (teamId: string, newId: string | null) => {
|
if (TEAM_EVENTS.has(type)) {
|
||||||
setTeamToInvitationId((prev) => {
|
// Wenn teamId fehlt: sicherheitshalber nachladen
|
||||||
|
if (!payload?.teamId) { fetchTeamsAndInvitations(true); return }
|
||||||
|
// nur nachladen, wenn dieses Team in unserer Liste vorkommt
|
||||||
|
if (teams.some(t => t.id === payload.teamId)) {
|
||||||
|
fetchTeamsAndInvitations(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (INVITE_EVENTS.has(type)) {
|
||||||
|
// Liste der Join-Requests/Invites aktualisieren (z. B. wenn ein Request erzeugt/gelöscht wurde)
|
||||||
|
fetchTeamsAndInvitations(true)
|
||||||
|
}
|
||||||
|
}, [lastEvent, teams])
|
||||||
|
|
||||||
|
const updateInvitationMap = (teamId: string, newValue: string | null | 'pending') => {
|
||||||
|
setTeamToInvitationId(prev => {
|
||||||
const updated = { ...prev }
|
const updated = { ...prev }
|
||||||
if (newId) updated[teamId] = newId
|
if (!newValue) delete updated[teamId]
|
||||||
else delete updated[teamId]
|
else if (newValue === 'pending') updated[teamId] = updated[teamId] ?? 'pending'
|
||||||
|
else updated[teamId] = newValue
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <p>Lade Teams …</p>
|
if (loading) return <p>Lade Teams …</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -55,11 +155,11 @@ export default function NoTeamView() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{teams.map((team) => (
|
{teams.map(team => (
|
||||||
<TeamCard
|
<TeamCard
|
||||||
key={team.id}
|
key={team.id}
|
||||||
team={team}
|
team={team}
|
||||||
currentUserSteamId={session?.user?.steamId || ''}
|
currentUserSteamId={currentSteamId}
|
||||||
invitationId={teamToInvitationId[team.id]}
|
invitationId={teamToInvitationId[team.id]}
|
||||||
onUpdateInvitation={updateInvitationMap}
|
onUpdateInvitation={updateInvitationMap}
|
||||||
adminMode={false}
|
adminMode={false}
|
||||||
|
|||||||
@ -4,10 +4,9 @@ import { useEffect, useState } from 'react'
|
|||||||
import NotificationDropdown from './NotificationDropdown'
|
import NotificationDropdown from './NotificationDropdown'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
|
import { NOTIFICATION_EVENTS } from '../lib/sseEvents'
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────── */
|
|
||||||
/* Typen */
|
|
||||||
/* ────────────────────────────────────────────────────────── */
|
|
||||||
type Notification = {
|
type Notification = {
|
||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
@ -17,42 +16,42 @@ type Notification = {
|
|||||||
createdAt?: string
|
createdAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────── */
|
type ActionData =
|
||||||
/* Komponente */
|
| { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
|
||||||
/* ────────────────────────────────────────────────────────── */
|
| { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
|
||||||
|
|
||||||
|
// --- API Helper ---
|
||||||
|
async function apiJSON(url: string, body?: any, method = 'POST') {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
|
||||||
|
return res.json().catch(() => ({}))
|
||||||
|
}
|
||||||
|
|
||||||
export default function NotificationCenter() {
|
export default function NotificationCenter() {
|
||||||
/* --- Hooks & States ------------------------------------ */
|
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
|
const router = useRouter()
|
||||||
|
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
|
||||||
|
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
//const { markAllAsRead, markOneAsRead, handleInviteAction } = useTeamManager({}, null)
|
|
||||||
const router = useRouter()
|
|
||||||
const [previewText, setPreviewText] = useState<string | null>(null)
|
const [previewText, setPreviewText] = useState<string | null>(null)
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
const [animateBell, setAnimateBell] = useState(false)
|
const [animateBell, setAnimateBell] = useState(false)
|
||||||
|
|
||||||
/* --- Aktionen beim Klick auf eine Notification ---------- */
|
// 1) Initial laden
|
||||||
const onNotificationClick = (notification: Notification) => {
|
|
||||||
if (!notification.actionData) return
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(notification.actionData)
|
|
||||||
if (data.redirectUrl) router.push(data.redirectUrl)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[NotificationCenter] Ungültige actionData:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Initiale Daten laden + SSE verbinden --------------- */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
if (!steamId) return
|
if (!steamId) return
|
||||||
|
;(async () => {
|
||||||
const loadNotifications = async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/notifications/user')
|
const res = await fetch('/api/notifications/user')
|
||||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const loaded = data.notifications.map((n: any) => ({
|
const loaded: Notification[] = data.notifications.map((n: any) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
text: n.message,
|
text: n.message,
|
||||||
read: n.read,
|
read: n.read,
|
||||||
@ -64,121 +63,193 @@ export default function NotificationCenter() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[NotificationCenter] Fehler beim Laden:', err)
|
console.error('[NotificationCenter] Fehler beim Laden:', err)
|
||||||
}
|
}
|
||||||
}
|
})()
|
||||||
|
|
||||||
loadNotifications()
|
|
||||||
}, [session?.user?.steamId])
|
}, [session?.user?.steamId])
|
||||||
|
|
||||||
/* --- Live-Updates über SSE empfangen -------------------- */
|
// 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return;
|
if (!lastEvent) return
|
||||||
//if (!source) return
|
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
|
||||||
|
|
||||||
/* Handler für JEDES eintreffende Paket ------------------ */
|
const data = lastEvent.payload
|
||||||
const handleEvent = (event: MessageEvent) => {
|
if (data?.type === 'heartbeat') return
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
if (data.type === 'heartbeat') return // Ping ignorieren
|
|
||||||
|
|
||||||
/* Neues Notification-Objekt erzeugen */
|
|
||||||
const newNotification: Notification = {
|
const newNotification: Notification = {
|
||||||
id : data.id ?? crypto.randomUUID(),
|
id: data?.id ?? crypto.randomUUID(),
|
||||||
text : data.message ?? 'Neue Benachrichtigung',
|
text: data?.message ?? 'Neue Benachrichtigung',
|
||||||
read: false,
|
read: false,
|
||||||
actionType: data.actionType,
|
actionType: data?.actionType,
|
||||||
actionData: data.actionData,
|
actionData: data?.actionData,
|
||||||
createdAt : data.createdAt ?? new Date().toISOString(),
|
createdAt: data?.createdAt ?? new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
/* State updaten (immer oben einsortieren) */
|
|
||||||
setNotifications(prev => [newNotification, ...prev])
|
setNotifications(prev => [newNotification, ...prev])
|
||||||
|
setPreviewText(newNotification.text) // <-- nur das hier
|
||||||
|
}, [lastEvent])
|
||||||
|
|
||||||
|
// 2) Timer separat steuern: triggert bei neuem previewText
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewText) return
|
||||||
|
|
||||||
/* Glocke & Vorschau animieren ---------------------- */
|
|
||||||
setPreviewText(newNotification.text)
|
|
||||||
setShowPreview(true)
|
setShowPreview(true)
|
||||||
setAnimateBell(true)
|
setAnimateBell(true)
|
||||||
|
|
||||||
setTimeout(() => {
|
const PREVIEW_MS = 5000
|
||||||
setShowPreview(false)
|
const CLEAR_DELAY = 300
|
||||||
setTimeout(() => setPreviewText(null), 300)
|
|
||||||
setAnimateBell(false)
|
|
||||||
}, 3000)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[SSE] Ungültige Nachricht:', event.data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Liste aller Event-Namen, die der Server schicken kann */
|
const tHide = window.setTimeout(() => setShowPreview(false), PREVIEW_MS)
|
||||||
const eventNames = [
|
const tBell = window.setTimeout(() => setAnimateBell(false), PREVIEW_MS)
|
||||||
'notification',
|
const tClear = window.setTimeout(() => setPreviewText(null), PREVIEW_MS + CLEAR_DELAY)
|
||||||
'invitation',
|
|
||||||
'team-invite',
|
|
||||||
'team-joined',
|
|
||||||
'team-member-joined',
|
|
||||||
'team-kick',
|
|
||||||
'team-kick-other',
|
|
||||||
'team-left',
|
|
||||||
'team-member-left',
|
|
||||||
'team-leader-changed',
|
|
||||||
'team-leader-self',
|
|
||||||
'team-join-request',
|
|
||||||
'expired-sharecode',
|
|
||||||
]
|
|
||||||
|
|
||||||
/* Named Events abonnieren ------------------------------ */
|
|
||||||
//eventNames.forEach(evt => source.addEventListener(evt, handleEvent))
|
|
||||||
|
|
||||||
/* Fallback: Server sendet evtl. Events ohne „event:“----- */
|
|
||||||
//source.onmessage = handleEvent
|
|
||||||
|
|
||||||
/* Aufräumen bei Unmount -------------------------------- */
|
|
||||||
return () => {
|
return () => {
|
||||||
//eventNames.forEach(evt => source.removeEventListener(evt, handleEvent))
|
clearTimeout(tHide)
|
||||||
//source.onmessage = null
|
clearTimeout(tBell)
|
||||||
|
clearTimeout(tClear)
|
||||||
}
|
}
|
||||||
}, /*[source] */)
|
}, [previewText])
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────── */
|
|
||||||
/* Render */
|
// 3) Actions
|
||||||
/* ────────────────────────────────────────────────────────── */
|
const markAllAsRead = async () => {
|
||||||
|
await apiJSON('/api/notifications/mark-all-read', undefined, 'POST')
|
||||||
|
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
const markOneAsRead = async (notificationId: string) => {
|
||||||
|
await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST')
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => {
|
||||||
|
// passende Notification finden (per actionData oder id)
|
||||||
|
const n = notifications.find(x => x.actionData === refId || x.id === refId)
|
||||||
|
if (!n) {
|
||||||
|
console.warn('[NotificationCenter] Keine Notification zu', refId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionData muss die Referenz tragen
|
||||||
|
if (!n.actionData) {
|
||||||
|
console.warn('[NotificationCenter] actionData fehlt für Notification', n.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionData parsen: erlaubt JSON {kind, inviteId/requestId, teamId} ODER nackte ID
|
||||||
|
let kind: 'invite' | 'join-request' | undefined
|
||||||
|
let invitationId: string | undefined
|
||||||
|
let requestId: string | undefined
|
||||||
|
let teamId: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(n.actionData) as
|
||||||
|
| { kind?: 'invite' | 'join-request'; inviteId?: string; requestId?: string; teamId?: string }
|
||||||
|
| string
|
||||||
|
|
||||||
|
if (typeof data === 'object' && data) {
|
||||||
|
kind = data.kind
|
||||||
|
invitationId = data.inviteId
|
||||||
|
requestId = data.requestId
|
||||||
|
teamId = data.teamId
|
||||||
|
} else if (typeof data === 'string') {
|
||||||
|
// nackte ID: sowohl als invitationId als auch requestId nutzbar
|
||||||
|
invitationId = data
|
||||||
|
requestId = data
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-JSON → nackte ID im actionData-String
|
||||||
|
invitationId = n.actionData
|
||||||
|
requestId = n.actionData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback anhand actionType
|
||||||
|
if (!kind && n.actionType === 'team-invite') kind = 'invite'
|
||||||
|
if (!kind && n.actionType === 'team-join-request') kind = 'join-request'
|
||||||
|
|
||||||
|
// Sicherheitscheck
|
||||||
|
if (kind === 'invite' && !invitationId) {
|
||||||
|
console.warn('[NotificationCenter] invitationId fehlt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (kind === 'join-request' && !requestId) {
|
||||||
|
console.warn('[NotificationCenter] requestId fehlt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic Update (Buttons ausblenden)
|
||||||
|
const snapshot = notifications
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (kind === 'invite') {
|
||||||
|
await apiJSON(`/api/user/invitations/${action}`, {
|
||||||
|
invitationId,
|
||||||
|
teamId,
|
||||||
|
})
|
||||||
|
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||||
|
if (action === 'accept') router.refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'join-request') {
|
||||||
|
if (action === 'accept') {
|
||||||
|
await apiJSON('/api/team/request-join/accept', { requestId, teamId })
|
||||||
|
} else {
|
||||||
|
await apiJSON('/api/team/request-join/reject', { requestId })
|
||||||
|
}
|
||||||
|
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||||
|
if (action === 'accept') router.refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[NotificationCenter] Unbekannter Typ:', n.actionType, kind)
|
||||||
|
setNotifications(snapshot) // rollback
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NotificationCenter] Aktion fehlgeschlagen:', err)
|
||||||
|
setNotifications(snapshot) // rollback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const onNotificationClick = (notification: Notification) => {
|
||||||
|
if (!notification.actionData) return
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(notification.actionData)
|
||||||
|
if (data.redirectUrl) router.push(data.redirectUrl)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NotificationCenter] Ungültige actionData:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Render
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-6 right-6 z-50">
|
<div className="fixed bottom-6 right-6 z-50">
|
||||||
{/* Glocke -------------------------------------------------- */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(prev => !prev)}
|
onClick={() => setOpen(prev => !prev)}
|
||||||
className={`relative flex items-center transition-all duration-300 ease-in-out
|
className={`relative flex items-center transition-all duration-300 ease-in-out
|
||||||
${showPreview ? 'w-[320px] pl-4 pr-11' : 'w-[44px] justify-center'}
|
${showPreview ? 'w-[400px] pl-4 pr-11' : 'w-[44px] justify-center'}
|
||||||
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
|
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
|
||||||
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
|
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
|
||||||
>
|
>
|
||||||
{/* Vorschau-Text --------------------------------------- */}
|
|
||||||
{previewText && (
|
{previewText && (
|
||||||
<span className="truncate text-sm text-gray-800 dark:text-white">
|
<span className="truncate text-sm text-gray-800 dark:text-white">
|
||||||
{previewText}
|
{previewText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
|
||||||
{/* Icon & Badge --------------------------------------- */}
|
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center">
|
|
||||||
<svg
|
<svg
|
||||||
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
|
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
strokeLinecap="round"
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z" />
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Badge (ungelesen) -------------------------------- */}
|
|
||||||
{notifications.some(n => !n.read) && (
|
{notifications.some(n => !n.read) && (
|
||||||
<span className="flex absolute top-0 end-0 -mt-1 -me-1">
|
<span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">
|
||||||
<span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span>
|
<span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span>
|
||||||
<span className="relative inline-flex items-center justify-center size-5 rounded-full text-xs font-bold bg-red-500 text-white">
|
<span className="relative inline-flex items-center justify-center size-5 rounded-full text-xs font-bold bg-red-500 text-white">
|
||||||
{notifications.filter(n => !n.read).length}
|
{notifications.filter(n => !n.read).length}
|
||||||
@ -188,32 +259,13 @@ export default function NotificationCenter() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown --------------------------------------------- */}
|
|
||||||
{open && (
|
{open && (
|
||||||
<NotificationDropdown
|
<NotificationDropdown
|
||||||
notifications={notifications}
|
notifications={notifications}
|
||||||
markAllAsRead={async () => {
|
markAllAsRead={markAllAsRead}
|
||||||
await markAllAsRead()
|
onSingleRead={markOneAsRead}
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
|
|
||||||
}}
|
|
||||||
onSingleRead={async (id) => {
|
|
||||||
await markOneAsRead(id)
|
|
||||||
setNotifications(prev =>
|
|
||||||
prev.map(n => (n.id === id ? { ...n, read: true } : n)),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
onAction={async (action, id) => {
|
onAction={handleInviteAction}
|
||||||
await handleInviteAction(action, id)
|
|
||||||
setNotifications(prev =>
|
|
||||||
prev.map(n =>
|
|
||||||
n.actionData === id
|
|
||||||
? { ...n, read: true, actionType: undefined, actionData: undefined }
|
|
||||||
: n,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (action === 'accept') router.refresh()
|
|
||||||
}}
|
|
||||||
onClickNotification={onNotificationClick}
|
onClickNotification={onNotificationClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -131,8 +131,9 @@ export default function NotificationDropdown({
|
|||||||
{needsAction ? (
|
{needsAction ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
onAction('accept', n.actionData!)
|
e.stopPropagation()
|
||||||
|
onAction('accept', n.actionData ?? n.id)
|
||||||
onSingleRead(n.id)
|
onSingleRead(n.id)
|
||||||
}}
|
}}
|
||||||
className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700"
|
className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700"
|
||||||
@ -142,8 +143,9 @@ export default function NotificationDropdown({
|
|||||||
✔
|
✔
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
onAction('reject', n.actionData!)
|
e.stopPropagation()
|
||||||
|
onAction('reject', n.actionData ?? n.id)
|
||||||
onSingleRead(n.id)
|
onSingleRead(n.id)
|
||||||
}}
|
}}
|
||||||
className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700"
|
className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700"
|
||||||
|
|||||||
@ -140,7 +140,22 @@ export default function SidebarFooter() {
|
|||||||
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
|
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
|
||||||
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Mein Profil
|
Profil
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/team`)}
|
||||||
|
size='sm'
|
||||||
|
variant='link'
|
||||||
|
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
|
||||||
|
${pathname === `/team`
|
||||||
|
? 'bg-gray-100 dark:bg-neutral-700 text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
|
||||||
|
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||||
|
</svg>
|
||||||
|
Team
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push('/settings')}
|
onClick={() => router.push('/settings')}
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
// components/TeamCard.tsx
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||||||
import type { Team, Player } from '../types/team'
|
import type { Team } from '../types/team'
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
team: Team
|
team: Team
|
||||||
currentUserSteamId: string
|
currentUserSteamId: string
|
||||||
invitationId?: string
|
invitationId?: string
|
||||||
onUpdateInvitation: (teamId: string, newValue: string | null) => void
|
onUpdateInvitation: (teamId: string, newValue: string | null | 'pending') => void
|
||||||
adminMode?: boolean
|
adminMode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,14 +24,12 @@ export default function TeamCard({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [joining, setJoining] = useState(false)
|
const [joining, setJoining] = useState(false)
|
||||||
|
|
||||||
/* ---------- Join / Reject ---------- */
|
|
||||||
const isRequested = Boolean(invitationId)
|
const isRequested = Boolean(invitationId)
|
||||||
const isDisabled = joining || currentUserSteamId === data.leader
|
const isDisabled = joining || currentUserSteamId === team.leader
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
if (joining) return
|
if (joining) return
|
||||||
setJoining(true)
|
setJoining(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isRequested) {
|
if (isRequested) {
|
||||||
await fetch('/api/user/invitations/reject', {
|
await fetch('/api/user/invitations/reject', {
|
||||||
@ -41,14 +37,14 @@ export default function TeamCard({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body : JSON.stringify({ invitationId }),
|
body : JSON.stringify({ invitationId }),
|
||||||
})
|
})
|
||||||
onUpdateInvitation(data.id, null)
|
onUpdateInvitation(team.id, null)
|
||||||
} else {
|
} else {
|
||||||
await fetch('/api/team/request-join', {
|
await fetch('/api/team/request-join', {
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body : JSON.stringify({ teamId: data.id }),
|
body : JSON.stringify({ teamId: team.id }),
|
||||||
})
|
})
|
||||||
onUpdateInvitation(data.id, 'pending')
|
onUpdateInvitation(team.id, 'pending')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TeamCard] Join/Reject-Fehler:', err)
|
console.error('[TeamCard] Join/Reject-Fehler:', err)
|
||||||
@ -57,44 +53,32 @@ export default function TeamCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Ziel-URL berechnen ---------- */
|
const targetHref = adminMode ? `/admin/teams/${team.id}` : `/team/${team.id}`
|
||||||
const targetHref = adminMode
|
|
||||||
? `/admin/teams/${data.id}`
|
|
||||||
: `/team/${data.id}`
|
|
||||||
|
|
||||||
/* ---------- Render ---------- */
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => router.push(targetHref)}
|
onClick={() => router.push(targetHref)}
|
||||||
onKeyDown={e => (e.key === 'Enter') && router.push(targetHref)}
|
onKeyDown={e => (e.key === 'Enter') && router.push(targetHref)}
|
||||||
className="
|
className="p-4 border rounded-lg bg-white dark:bg-neutral-800
|
||||||
p-4 border rounded-lg bg-white dark:bg-neutral-800
|
|
||||||
dark:border-neutral-700 shadow-sm hover:shadow-md
|
dark:border-neutral-700 shadow-sm hover:shadow-md
|
||||||
transition cursor-pointer focus:outline-none hover:scale-105 hover:bg-neutral-200 hover:dark:bg-neutral-700
|
transition cursor-pointer focus:outline-none
|
||||||
"
|
hover:scale-105 hover:bg-neutral-200 hover:dark:bg-neutral-700"
|
||||||
>
|
>
|
||||||
{/* Kopfzeile */}
|
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img
|
<img
|
||||||
src={
|
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
data.logo
|
alt={team.name ?? 'Teamlogo'}
|
||||||
? `/assets/img/logos/${data.logo}`
|
|
||||||
: '/assets/img/logos/cs2.webp'
|
|
||||||
}
|
|
||||||
alt={data.name ?? 'Teamlogo'}
|
|
||||||
className="w-12 h-12 rounded-full object-cover border
|
className="w-12 h-12 rounded-full object-cover border
|
||||||
border-gray-200 dark:border-neutral-600"
|
border-gray-200 dark:border-neutral-600"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
|
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
|
||||||
{data.name ?? 'Team'}
|
{team.name ?? 'Team'}
|
||||||
</span>
|
</span>
|
||||||
|
<TeamPremierRankBadge players={team.activePlayers} />
|
||||||
<TeamPremierRankBadge players={players} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -103,10 +87,10 @@ export default function TeamCard({
|
|||||||
title="Verwalten"
|
title="Verwalten"
|
||||||
size="md"
|
size="md"
|
||||||
color="blue"
|
color="blue"
|
||||||
variant='solid'
|
variant="solid"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation() // ▼ Navigation hier unterbinden
|
e.stopPropagation()
|
||||||
router.push(`/admin/teams/${data.id}`)
|
router.push(`/admin/teams/${team.id}`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Verwalten
|
Verwalten
|
||||||
@ -117,10 +101,7 @@ export default function TeamCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
color={isRequested ? 'gray' : 'blue'}
|
color={isRequested ? 'gray' : 'blue'}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onClick={e => {
|
onClick={e => { e.stopPropagation(); handleClick() }}
|
||||||
e.stopPropagation() // ▼ verhindert Klick-Weitergabe
|
|
||||||
handleClick()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{joining ? (
|
{joining ? (
|
||||||
<>
|
<>
|
||||||
@ -140,9 +121,8 @@ export default function TeamCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avatare */}
|
|
||||||
<div className="flex -space-x-3">
|
<div className="flex -space-x-3">
|
||||||
{players.slice(0, 5).map(p => (
|
{[...team.activePlayers, ...team.inactivePlayers].map(p => (
|
||||||
<img
|
<img
|
||||||
key={p.steamId}
|
key={p.steamId}
|
||||||
src={p.avatar}
|
src={p.avatar}
|
||||||
@ -152,16 +132,6 @@ export default function TeamCard({
|
|||||||
dark:border-neutral-800 object-cover"
|
dark:border-neutral-800 object-cover"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{players.length > 5 && (
|
|
||||||
<span
|
|
||||||
key="more"
|
|
||||||
className="w-8 h-8 flex items-center justify-center
|
|
||||||
rounded-full bg-gray-200 text-xs"
|
|
||||||
>
|
|
||||||
+{players.length - 5}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { forwardRef, useEffect, useState } from 'react'
|
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
import TeamInvitationView from './TeamInvitationView'
|
import TeamInvitationView from './TeamInvitationView'
|
||||||
@ -8,101 +8,202 @@ import TeamMemberView from './TeamMemberView'
|
|||||||
import NoTeamView from './NoTeamView'
|
import NoTeamView from './NoTeamView'
|
||||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||||
import CreateTeamButton from './CreateTeamButton'
|
import CreateTeamButton from './CreateTeamButton'
|
||||||
|
import type { Player, Team } from '../types/team'
|
||||||
|
import type { Invitation } from '../types/invitation'
|
||||||
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
|
|
||||||
import {
|
/** Relevante Event-Gruppen */
|
||||||
acceptInvitation,
|
const TEAM_EVENTS = new Set([
|
||||||
rejectInvitation,
|
'team-updated',
|
||||||
markOneAsRead
|
'team-leader-changed',
|
||||||
} from '@/app/lib/sse-actions'
|
'team-member-joined',
|
||||||
import { Player, Team } from '../types/team'
|
'team-member-left',
|
||||||
|
'team-renamed',
|
||||||
|
'team-logo-updated',
|
||||||
|
])
|
||||||
|
|
||||||
type Props = {
|
const SELF_CLEAR_EVENTS = new Set([
|
||||||
refetchKey?: string
|
'team-kick-self',
|
||||||
|
'team-left-self',
|
||||||
|
'user-team-cleared',
|
||||||
|
])
|
||||||
|
|
||||||
|
const INVITE_EVENTS = new Set([
|
||||||
|
'team-invite',
|
||||||
|
'team-join-request',
|
||||||
|
'team-invite-reject',
|
||||||
|
'team-join-request-reject',
|
||||||
|
])
|
||||||
|
|
||||||
|
type Props = { refetchKey?: string }
|
||||||
|
|
||||||
|
/** flache, stabile Equality-Checks, um unnötige setState zu vermeiden */
|
||||||
|
function eqPlayers(a: Player[] = [], b: Player[] = []) {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i].steamId !== b[i].steamId) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function eqTeam(a: Team | null, b: Team | null) {
|
||||||
|
if (!a && !b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
if (a.id !== b.id || a.name !== b.name || a.logo !== b.logo || a.leader !== b.leader) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Spielerlisten flach vergleichen (nach steamId sortiert vergleichen)
|
||||||
|
const sort = (arr: Player[] = []) => [...arr].sort((x, y) => x.steamId.localeCompare(y.steamId))
|
||||||
|
return eqPlayers(sort(a.activePlayers), sort(b.activePlayers)) &&
|
||||||
|
eqPlayers(sort(a.inactivePlayers), sort(b.inactivePlayers))
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint‑disable react/display‑name */
|
function TeamCardComponent(_: Props, _ref: any) {
|
||||||
function TeamCardComponent(props: Props, ref: any) {
|
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const steamId = session?.user?.steamId ?? ''
|
const steamId = session?.user?.steamId ?? ''
|
||||||
const [refetchKey, setRefetchKey] = useState<string>()
|
const { lastEvent } = useSSEStore() // nur konsumieren – Verbindung macht der globale SSEHandler
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true) // nur beim ersten Load true
|
||||||
const [team, setTeam] = useState<Team | null>(null)
|
const [team, setTeam] = useState<Team | null>(null)
|
||||||
const [activePlayers, setActivePlayers] = useState<Player[]>([])
|
const [pendingInvitation, setPendingInvitation] = useState<Invitation | null>(null)
|
||||||
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
|
|
||||||
const [invitedPlayers, setInvitedPlayers] = useState<Player[]>([])
|
|
||||||
const [pendingInvitation, setPendingInvitation] = useState<any>(null)
|
|
||||||
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
|
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
||||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||||
|
|
||||||
const loadTeam = async () => {
|
// Refs für Dedupe/Drossel/Abbruch
|
||||||
setIsLoading(true)
|
const currentTeamIdRef = useRef<string | null>(null)
|
||||||
|
const inflight = useRef<AbortController | null>(null)
|
||||||
|
const lastReloadAt = useRef<number>(0)
|
||||||
|
const lastInviteCheck = useRef<number>(0)
|
||||||
|
|
||||||
|
// Hilfs-Fetch: lädt Team und (falls kein Team) auch offene Invites
|
||||||
|
const fetchData = async (withSpinner: boolean) => {
|
||||||
|
// Drossel: mehrfache Events in kurzer Zeit zusammenfassen (400ms)
|
||||||
|
const now = Date.now()
|
||||||
|
if (!withSpinner && now - lastReloadAt.current < 400) return
|
||||||
|
lastReloadAt.current = now
|
||||||
|
|
||||||
|
// Parallel-Request abbrechen
|
||||||
|
if (inflight.current) inflight.current.abort()
|
||||||
|
const ac = new AbortController()
|
||||||
|
inflight.current = ac
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/team')
|
if (withSpinner) setInitialLoading(true)
|
||||||
|
|
||||||
|
const res = await fetch('/api/team', { cache: 'no-store', signal: ac.signal })
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (data.team) {
|
if (ac.signal.aborted) return
|
||||||
setTeam(data.team)
|
|
||||||
setActivePlayers(data.team.activePlayers || [])
|
|
||||||
setInactivePlayers(data.team.inactivePlayers || [])
|
|
||||||
setInvitedPlayers(data.team.invitedPlayers || [])
|
|
||||||
} else {
|
|
||||||
setTeam(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Einladung nur laden, wenn kein Team vorhanden
|
if (data.team) {
|
||||||
if (!data.team) {
|
// Nur setzen, wenn sich wirklich etwas geändert hat (verhindert Flackern)
|
||||||
const inviteRes = await fetch('/api/user/invitations')
|
if (!eqTeam(team, data.team)) {
|
||||||
|
setTeam(data.team)
|
||||||
|
}
|
||||||
|
setPendingInvitation(null)
|
||||||
|
currentTeamIdRef.current = data.team.id
|
||||||
|
} else {
|
||||||
|
// Kein Team → optional Invites laden (aber nicht im Millisekundentakt)
|
||||||
|
currentTeamIdRef.current = null
|
||||||
|
if (Date.now() - lastInviteCheck.current > 1500) {
|
||||||
|
lastInviteCheck.current = Date.now()
|
||||||
|
const inviteRes = await fetch('/api/user/invitations', { cache: 'no-store', signal: ac.signal })
|
||||||
if (inviteRes.ok) {
|
if (inviteRes.ok) {
|
||||||
const inviteData = await inviteRes.json()
|
const inviteData = await inviteRes.json()
|
||||||
const teamInvite = inviteData.invitations?.find(
|
const raw = (inviteData.invitations ?? []).find((i: any) => i.type === 'team-invite')
|
||||||
(i: any) => i.type === 'team-invite'
|
const inv: Invitation | null = raw && raw.team ? { id: raw.id, team: raw.team } : null
|
||||||
)
|
// nur setzen, wenn es sich ändert
|
||||||
setPendingInvitation(teamInvite ?? null)
|
if ((pendingInvitation?.id ?? null) !== (inv?.id ?? null)) {
|
||||||
|
setPendingInvitation(inv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}
|
||||||
console.error('Fehler beim Laden des Teams:', err)
|
if (team !== null) setTeam(null) // nur setzen, wenn nötig
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as any)?.name !== 'AbortError') {
|
||||||
|
console.error('fetchData error:', e)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
if (withSpinner) setInitialLoading(false)
|
||||||
|
if (inflight.current === ac) inflight.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMarkOneAsRead = async (id: string): Promise<void> => {
|
// Initialer Load mit Spinner
|
||||||
await markOneAsRead(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTeam()
|
fetchData(true)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Parent-Refresh-Trigger (falls genutzt)
|
||||||
|
const [refetchKey, setRefetchKey] = useState<string>()
|
||||||
|
useEffect(() => {
|
||||||
|
if (refetchKey) fetchData(false)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [refetchKey])
|
}, [refetchKey])
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />
|
// Auf SSE-Events reagieren → nur soft reload (kein Spinner)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastEvent) return
|
||||||
|
const { type, payload } = lastEvent
|
||||||
|
|
||||||
// 1. Pending invitation
|
// selbst entfernt/gekickt → reload
|
||||||
|
if (SELF_CLEAR_EVENTS.has(type)) {
|
||||||
|
fetchData(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team-Events: nur laden, wenn es das aktuelle Team betrifft (oder keine teamId angegeben → fail-safe)
|
||||||
|
if (TEAM_EVENTS.has(type)) {
|
||||||
|
fetchData(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite-Events: nur interessant, wenn kein Team
|
||||||
|
if (INVITE_EVENTS.has(type) && !currentTeamIdRef.current) {
|
||||||
|
fetchData(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [lastEvent])
|
||||||
|
|
||||||
|
if (initialLoading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
// 1) Pending Team-Einladung anzeigen (nur wenn kein Team vorhanden)
|
||||||
if (!team && pendingInvitation) {
|
if (!team && pendingInvitation) {
|
||||||
const notificationId = pendingInvitation.id
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<TeamInvitationView
|
<TeamInvitationView
|
||||||
invitation={pendingInvitation}
|
invitation={pendingInvitation}
|
||||||
notificationId={notificationId}
|
notificationId={pendingInvitation.id}
|
||||||
onMarkAsRead={handleMarkOneAsRead}
|
onMarkAsRead={async () => {}}
|
||||||
onAction={async (action, invitationId) => {
|
onAction={async (action) => {
|
||||||
if (action === 'accept') {
|
try {
|
||||||
await acceptInvitation(invitationId)
|
await fetch(`/api/user/invitations/${action}`, {
|
||||||
} else {
|
method: 'POST',
|
||||||
await rejectInvitation(invitationId)
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
invitationId: pendingInvitation.id,
|
||||||
|
teamId: pendingInvitation.team.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Invite respond fehlgeschlagen:', e)
|
||||||
|
} finally {
|
||||||
|
// nach Aktion erneut prüfen (soft)
|
||||||
|
await fetchData(false)
|
||||||
}
|
}
|
||||||
await markOneAsRead(notificationId)
|
|
||||||
await loadTeam()
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NoTeamView />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Kein Team
|
// 2) Kein Team & keine Einladung
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700 space-y-4">
|
<div className="p-6 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700 space-y-4">
|
||||||
@ -114,9 +215,9 @@ function TeamCardComponent(props: Props, ref: any) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Team vorhanden
|
// 3) Team vorhanden
|
||||||
return (
|
return (
|
||||||
<div className="p-5 md:p-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700">
|
<div>
|
||||||
<div className="mb-4 xl:mb-8">
|
<div className="mb-4 xl:mb-8">
|
||||||
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
|
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
|
||||||
Teameinstellungen
|
Teameinstellungen
|
||||||
@ -129,10 +230,6 @@ function TeamCardComponent(props: Props, ref: any) {
|
|||||||
<form>
|
<form>
|
||||||
<TeamMemberView
|
<TeamMemberView
|
||||||
team={team}
|
team={team}
|
||||||
activePlayers={activePlayers}
|
|
||||||
inactivePlayers={inactivePlayers}
|
|
||||||
setactivePlayers={setActivePlayers}
|
|
||||||
setInactivePlayers={setInactivePlayers}
|
|
||||||
currentUserSteamId={steamId}
|
currentUserSteamId={steamId}
|
||||||
adminMode={false}
|
adminMode={false}
|
||||||
activeDragItem={activeDragItem}
|
activeDragItem={activeDragItem}
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
|
// TeamInvitationView.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { Invitation } from '../hooks/useTeamManager'
|
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||||||
|
import type { Invitation } from '../types/invitation'
|
||||||
|
import type { Team } from '../types/team'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
invitation: Invitation
|
invitation: Invitation
|
||||||
@ -17,44 +21,161 @@ export default function TeamInvitationView({
|
|||||||
onAction,
|
onAction,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const router = useRouter()
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<'accept' | 'reject' | null>(null)
|
||||||
|
|
||||||
if (!invitation) return null
|
if (!invitation?.team) return null
|
||||||
|
const team: Team = invitation.team
|
||||||
|
const targetHref = `/team/${team.id}`
|
||||||
|
|
||||||
|
const active = team.activePlayers ?? []
|
||||||
|
const inactive = team.inactivePlayers ?? []
|
||||||
|
const badgePlayers = active.length ? active : inactive
|
||||||
|
|
||||||
const handleRespond = async (action: 'accept' | 'reject') => {
|
const handleRespond = async (action: 'accept' | 'reject') => {
|
||||||
|
if (isSubmitting) return
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(action)
|
||||||
await onAction(action, invitation.id)
|
await onAction(action, invitation.id)
|
||||||
await onMarkAsRead(notificationId)
|
await onMarkAsRead(notificationId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Fehler beim ${action === 'accept' ? 'Annehmen' : 'Ablehnen'} der Einladung:`, err)
|
console.error(`[TeamInvitationView] ${action} failed:`, err)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Klassen als Ausdruck (keine mehrzeiligen String-Literals im JSX)
|
||||||
|
const cardClasses =
|
||||||
|
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
|
||||||
|
'dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer ' +
|
||||||
|
'focus:outline-none ring-1 ring-green-500/15 hover:ring-green-500/25 mb-5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700">
|
<div
|
||||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
role="button"
|
||||||
Du wurdest eingeladen!
|
tabIndex={0}
|
||||||
</h2>
|
onClick={() => router.push(targetHref)}
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-6">{invitation.teamName}</p>
|
onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
|
||||||
<div className="flex gap-2">
|
className={cardClasses}
|
||||||
<Button
|
|
||||||
onClick={() => handleRespond('accept')}
|
|
||||||
className="px-2 py-1 text-sm font-medium rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
>
|
||||||
✔ Annehmen
|
{/* animierter, dezenter grüner Gradient */}
|
||||||
|
<div aria-hidden className="absolute inset-0 z-0 pointer-events-none invitationGradient" />
|
||||||
|
|
||||||
|
{/* Inhalt */}
|
||||||
|
<div className="relative z-[1] p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
|
alt={team.name ?? 'Teamlogo'}
|
||||||
|
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
|
||||||
|
{team.name ?? 'Team'}
|
||||||
|
</span>
|
||||||
|
{badgePlayers.length > 0 && <TeamPremierRankBadge players={badgePlayers} />}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 dark:text-neutral-400">
|
||||||
|
Du wurdest in dieses Team eingeladen.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teammitglieder */}
|
||||||
|
<div className="flex -space-x-3">
|
||||||
|
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].map((p) => (
|
||||||
|
<img
|
||||||
|
key={p.steamId}
|
||||||
|
src={p.avatar}
|
||||||
|
alt={p.name}
|
||||||
|
title={p.name}
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
title="Ablehnen"
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isSubmitting !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRespond('reject')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting === 'reject' ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
|
||||||
|
Ablehnen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Ablehnen'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRespond('reject')}
|
title="Annehmen"
|
||||||
className="px-2 py-1 text-sm font-medium rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
|
size="sm"
|
||||||
disabled={isSubmitting}
|
color="green"
|
||||||
|
variant="solid"
|
||||||
|
disabled={isSubmitting !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRespond('accept')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
✖ Ablehnen
|
{isSubmitting === 'accept' ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
|
||||||
|
Annehmen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Annehmen'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
/* kontinuierlich nach rechts schieben */
|
||||||
|
@keyframes slide-x {
|
||||||
|
from { background-position-x: 0%; }
|
||||||
|
to { background-position-x: 200%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitationGradient {
|
||||||
|
/* weicher, dezenter Verlauf */
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(16,185,129,0.10) 0%,
|
||||||
|
rgba(16,185,129,0.06) 50%,
|
||||||
|
rgba(16,185,129,0.10) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-repeat: repeat-x; /* nahtlos, kein “Sprung” am Loop */
|
||||||
|
animation: slide-x 20s linear infinite; /* langsam, konstant, endlos */
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .invitationGradient {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(16,185,129,0.18) 0%,
|
||||||
|
rgba(16,185,129,0.08) 50%,
|
||||||
|
rgba(16,185,129,0.18) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.invitationGradient { animation: none; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
// TeamMemberView.tsx
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
|
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
import { DroppableZone } from './DroppableZone'
|
import { DroppableZone } from './DroppableZone'
|
||||||
@ -11,23 +10,19 @@ import SortableMiniCard from './SortableMiniCard'
|
|||||||
import LeaveTeamModal from './LeaveTeamModal'
|
import LeaveTeamModal from './LeaveTeamModal'
|
||||||
import InvitePlayersModal from './InvitePlayersModal'
|
import InvitePlayersModal from './InvitePlayersModal'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import { Player, Team } from '../types/team'
|
import { Player } from '../types/team'
|
||||||
import { useSession } from 'next-auth/react'
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import {
|
import { leaveTeam, reloadTeam, renameTeam, revokeInvitation } from '@/app/lib/sse-actions'
|
||||||
leaveTeam,
|
|
||||||
reloadTeam,
|
|
||||||
renameTeam,
|
|
||||||
revokeInvitation,
|
|
||||||
} from '@/app/lib/sse-actions'
|
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { Team } from '../types/team'
|
||||||
import { useTeamStore } from '../lib/stores'
|
import { useTeamStore } from '../lib/stores'
|
||||||
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
team: Team
|
team?: Team
|
||||||
activeDragItem: Player | null
|
activeDragItem: Player | null
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
showLeaveModal: boolean
|
showLeaveModal: boolean
|
||||||
@ -40,14 +35,15 @@ type Props = {
|
|||||||
adminMode?: boolean
|
adminMode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type InvitedPlayer = Player & { invitationId: string }
|
type InvitedPlayer = Player & { invitationId?: string }
|
||||||
|
|
||||||
export default function TeamMemberView({
|
export default function TeamMemberView({
|
||||||
team,
|
team: teamProp,
|
||||||
activeDragItem,
|
activeDragItem,
|
||||||
isDragging,
|
isDragging,
|
||||||
showLeaveModal,
|
showLeaveModal,
|
||||||
showInviteModal,
|
showInviteModal,
|
||||||
|
currentUserSteamId,
|
||||||
setShowLeaveModal,
|
setShowLeaveModal,
|
||||||
setShowInviteModal,
|
setShowInviteModal,
|
||||||
setActiveDragItem,
|
setActiveDragItem,
|
||||||
@ -55,55 +51,146 @@ export default function TeamMemberView({
|
|||||||
adminMode = false,
|
adminMode = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const { data: session } = useSession()
|
const { team: storeTeam, setTeam } = useTeamStore()
|
||||||
const currentUserSteamId = session?.user?.steamId || ''
|
const team = teamProp ?? storeTeam
|
||||||
const isLeader = currentUserSteamId === team?.leader
|
if (!team) return null
|
||||||
|
|
||||||
|
const isLeader = currentUserSteamId === team.leader
|
||||||
const canManage = adminMode || isLeader
|
const canManage = adminMode || isLeader
|
||||||
const canInvite = isLeader && !adminMode
|
const canInvite = isLeader && !adminMode
|
||||||
const canAddDirect= adminMode
|
const canAddDirect= adminMode
|
||||||
|
|
||||||
|
const isDraggingRef = useRef(false)
|
||||||
|
const [pendingRemote, setPendingRemote] = useState<{
|
||||||
|
active: Player[]
|
||||||
|
inactive: Player[]
|
||||||
|
invited: InvitedPlayer[]
|
||||||
|
} | null>(null)
|
||||||
|
const [remountKey, setRemountKey] = useState(0)
|
||||||
|
const { connect, disconnect, lastEvent, isConnected } = useSSEStore()
|
||||||
const [activePlayers, setActivePlayers] = useState<Player[]>([])
|
const [activePlayers, setActivePlayers] = useState<Player[]>([])
|
||||||
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
|
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
|
||||||
|
const [invitedPlayers, setInvitedPlayers] = useState<InvitedPlayer[]>([])
|
||||||
const [kickCandidate, setKickCandidate] = useState<Player | null>(null)
|
const [kickCandidate, setKickCandidate] = useState<Player | null>(null)
|
||||||
const [promoteCandidate, setPromoteCandidate] = useState<Player | null>(null)
|
const [promoteCandidate, setPromoteCandidate] = useState<Player | null>(null)
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
const [isEditingName, setIsEditingName] = useState(false)
|
const [isEditingName, setIsEditingName] = useState(false)
|
||||||
const [editedName, setEditedName] = useState(team?.name || '')
|
const [editedName, setEditedName] = useState(team.name || '')
|
||||||
const [teamState, setTeamState] = useState<Team | null>(team)
|
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||||
const [invitedPlayers, setInvitedPlayers] = useState<InvitedPlayer[]>([])
|
|
||||||
|
|
||||||
console.log('[TeamMemberView] team from store:', team)
|
// 1) SSE-Verbindung sicherstellen
|
||||||
console.log('[TeamMemberView] teamState:', teamState)
|
useEffect(() => {
|
||||||
console.log('[TeamMemberView] activePlayers:', activePlayers)
|
if (!currentUserSteamId) return
|
||||||
console.log('[TeamMemberView] inactivePlayers:', inactivePlayers)
|
if (!isConnected) connect(currentUserSteamId)
|
||||||
|
return () => {
|
||||||
|
// Falls du global verbunden bleiben willst: disconnect() hier weglassen
|
||||||
|
// disconnect()
|
||||||
|
}
|
||||||
|
}, [currentUserSteamId, connect, isConnected])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!team || !team.id) return
|
if (!team) return
|
||||||
|
|
||||||
setTeamState(team)
|
const nextActive = (team.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
setEditedName(team.name || '')
|
const nextInactive = (team.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
setActivePlayers(team.activePlayers)
|
const nextInvited = Array.from(
|
||||||
setInactivePlayers(team.inactivePlayers)
|
|
||||||
|
|
||||||
const uniqueInvites = Array.from(
|
|
||||||
new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values()
|
new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values()
|
||||||
)
|
).sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
setInvitedPlayers(uniqueInvites.sort((a, b) => a.name.localeCompare(b.name)))
|
|
||||||
}, [team?.id])
|
// wenn nichts geändert: nichts tun (vermeidet Flicker)
|
||||||
|
const unchanged =
|
||||||
|
eqByIds(activePlayers, nextActive) &&
|
||||||
|
eqByIds(inactivePlayers, nextInactive) &&
|
||||||
|
eqByIds(invitedPlayers, nextInvited)
|
||||||
|
|
||||||
|
if (unchanged) return
|
||||||
|
|
||||||
|
if (!isDraggingRef.current) {
|
||||||
|
// 🔄 nicht am Ziehen → direkt übernehmen
|
||||||
|
setActivePlayers(nextActive)
|
||||||
|
setInactivePlayers(nextInactive)
|
||||||
|
setInvitedPlayers(nextInvited)
|
||||||
|
} else {
|
||||||
|
// ✋ während Drag → puffern
|
||||||
|
setPendingRemote({ active: nextActive, inactive: nextInactive, invited: nextInvited })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [team?.id, team?.name, team?.logo, team?.leader,
|
||||||
|
team?.activePlayers, team?.inactivePlayers, team?.invitedPlayers])
|
||||||
|
|
||||||
|
// 2) Auf ALLE relevanten Events reagieren
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastEvent || !team?.id) return
|
||||||
|
|
||||||
|
const RELEVANT = new Set([
|
||||||
|
'team-updated',
|
||||||
|
'team-leader-changed',
|
||||||
|
'team-member-joined',
|
||||||
|
'team-member-left',
|
||||||
|
'team-kick',
|
||||||
|
'team-kick-other',
|
||||||
|
'team-left',
|
||||||
|
'team-renamed',
|
||||||
|
'team-logo-updated',
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!RELEVANT.has(lastEvent.type)) return
|
||||||
|
|
||||||
|
const payload = lastEvent.payload ?? {}
|
||||||
|
// meistens ist teamId dabei – falls einzelne Events andere Felder nutzen,
|
||||||
|
// hier entsprechend ergänzen (z. B. payload.oldTeamId/payload.newTeamId).
|
||||||
|
if (payload.teamId !== team.id) return
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const updated = await reloadTeam(team.id)
|
||||||
|
if (!updated) return
|
||||||
|
|
||||||
|
setTeam(updated)
|
||||||
|
setEditedName(updated.name || '')
|
||||||
|
|
||||||
|
const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
|
const nextInactive = (updated.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
|
const nextInvited = Array.from(
|
||||||
|
new Map((updated.invitedPlayers ?? []).map(p => [p.steamId, p])).values()
|
||||||
|
).sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
if (isDraggingRef.current) {
|
||||||
|
// während Drag: puffern, kein Remount
|
||||||
|
setPendingRemote({ active: nextActive, inactive: nextInactive, invited: nextInvited })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// sofort in die lokalen DnD-Listen übernehmen + DnD intern neu aufbauen
|
||||||
|
setActivePlayers(nextActive)
|
||||||
|
setInactivePlayers(nextInactive)
|
||||||
|
setInvitedPlayers(nextInvited)
|
||||||
|
setRemountKey(k => k + 1)
|
||||||
|
})()
|
||||||
|
}, [lastEvent, team?.id, setTeam])
|
||||||
|
|
||||||
|
|
||||||
|
const eqByIds = (a: Player[], b: Player[]) => {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
const aa = a.map(p=>p.steamId).join(',')
|
||||||
|
const bb = b.map(p=>p.steamId).join(',')
|
||||||
|
return aa === bb
|
||||||
|
}
|
||||||
|
|
||||||
const handleDragStart = (event: any) => {
|
const handleDragStart = (event: any) => {
|
||||||
const id = event.active.id
|
const id = event.active.id
|
||||||
const item = activePlayers.find(p => p.steamId === id) || inactivePlayers.find(p => p.steamId === id)
|
const item =
|
||||||
|
activePlayers.find(p => p.steamId === id) ||
|
||||||
|
inactivePlayers.find(p => p.steamId === id)
|
||||||
if (item) {
|
if (item) {
|
||||||
setActiveDragItem(item)
|
setActiveDragItem(item)
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
|
isDraggingRef.current = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => {
|
const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/team/update-players', {
|
const res = await fetch('/api/team/update-players', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -112,82 +199,94 @@ export default function TeamMemberView({
|
|||||||
inactivePlayers: inactive.map(p => p.steamId),
|
inactivePlayers: inactive.map(p => p.steamId),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
if (!res.ok) throw new Error('Update fehlgeschlagen')
|
||||||
|
|
||||||
|
// 👇 Fail-safe: eigenes Team sofort frisch holen & in den Store schieben
|
||||||
|
const updated = await reloadTeam(teamId)
|
||||||
|
if (updated) setTeam(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Aktualisieren:', err)
|
console.error('Fehler beim Aktualisieren:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleDragEnd = async (event: any) => {
|
const handleDragEnd = async (event: any) => {
|
||||||
setActiveDragItem(null)
|
setActiveDragItem(null)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
|
isDraggingRef.current = false
|
||||||
|
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
if (!over) return
|
if (!over) return
|
||||||
|
const activeId = String(active.id)
|
||||||
const activeId = active.id
|
const overId = String(over.id)
|
||||||
const overId = over.id
|
|
||||||
if (!activeId || !overId) return
|
|
||||||
|
|
||||||
const movingItem = [...activePlayers, ...inactivePlayers].find(p => p.steamId === activeId)
|
const movingItem = [...activePlayers, ...inactivePlayers].find(p => p.steamId === activeId)
|
||||||
if (!movingItem) return
|
if (!movingItem) return
|
||||||
|
|
||||||
const isInActiveZone = activePlayers.some(p => p.steamId === activeId)
|
const dropToActive =
|
||||||
const isInInactiveZone = inactivePlayers.some(p => p.steamId === activeId)
|
overId === 'active' || activePlayers.some(p => p.steamId === overId)
|
||||||
|
|
||||||
const targetIsActiveZone = overId === 'active' || activePlayers.some(p => p.steamId === overId)
|
let nextActive = [...activePlayers]
|
||||||
const targetIsInactiveZone = overId === 'inactive' || inactivePlayers.some(p => p.steamId === overId)
|
let nextInactive = [...inactivePlayers]
|
||||||
|
|
||||||
if ((isInActiveZone && targetIsActiveZone) || (isInInactiveZone && targetIsInactiveZone)) return
|
if (dropToActive) {
|
||||||
|
if (nextActive.length >= 5) return
|
||||||
let newActive = [...activePlayers]
|
nextInactive = nextInactive.filter(p => p.steamId !== activeId)
|
||||||
let newInactive = [...inactivePlayers]
|
if (!nextActive.some(p => p.steamId === activeId)) nextActive.push(movingItem)
|
||||||
|
|
||||||
if (targetIsActiveZone) {
|
|
||||||
if (newActive.length >= 5) return
|
|
||||||
newInactive = newInactive.filter(p => p.steamId !== activeId)
|
|
||||||
if (!newActive.some(p => p.steamId === activeId)) newActive.push(movingItem)
|
|
||||||
} else {
|
} else {
|
||||||
newActive = newActive.filter(p => p.steamId !== activeId)
|
nextActive = nextActive.filter(p => p.steamId !== activeId)
|
||||||
if (!newInactive.some(p => p.steamId === activeId)) newInactive.push(movingItem)
|
if (!nextInactive.some(p => p.steamId === activeId)) nextInactive.push(movingItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
newActive.sort((a, b) => a.name.localeCompare(b.name))
|
nextActive.sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
newInactive.sort((a, b) => a.name.localeCompare(b.name))
|
nextInactive.sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
// ✅ Optimistisches UI: nur lokale DnD-States updaten
|
||||||
|
setActivePlayers(nextActive)
|
||||||
|
setInactivePlayers(nextInactive)
|
||||||
|
|
||||||
|
// 🔔 Server informieren (SSE triggert andere Clients)
|
||||||
|
updateTeamMembers(team.id, nextActive, nextInactive).catch(console.error)
|
||||||
|
|
||||||
setActivePlayers(newActive)
|
|
||||||
setInactivePlayers(newInactive)
|
|
||||||
await updateTeamMembers(team!.id, newActive, newInactive)
|
|
||||||
setSaveSuccess(true)
|
setSaveSuccess(true)
|
||||||
setTimeout(() => setSaveSuccess(false), 3000) // 3 Sekunden sichtbar
|
setTimeout(()=>setSaveSuccess(false), 3000)
|
||||||
|
|
||||||
|
// 📨 Falls während des Drags ein Remote-Update kam → jetzt anwenden
|
||||||
|
if (pendingRemote) {
|
||||||
|
// nur übernehmen, wenn abweichend (optional)
|
||||||
|
const diff =
|
||||||
|
!eqByIds(pendingRemote.active, nextActive) ||
|
||||||
|
!eqByIds(pendingRemote.inactive, nextInactive) ||
|
||||||
|
!eqByIds(pendingRemote.invited, invitedPlayers)
|
||||||
|
|
||||||
|
if (diff) {
|
||||||
|
setActivePlayers(pendingRemote.active)
|
||||||
|
setInactivePlayers(pendingRemote.inactive)
|
||||||
|
setInvitedPlayers(pendingRemote.invited)
|
||||||
|
}
|
||||||
|
setPendingRemote(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wenn du von hier manuell reloaden willst, dann Store updaten (nicht lokalen State)
|
||||||
const handleReload = async () => {
|
const handleReload = async () => {
|
||||||
if (!teamState?.id) return
|
const updated = await reloadTeam(team.id)
|
||||||
const updated = await reloadTeam(teamState.id)
|
if (updated) setTeam(updated)
|
||||||
if (!updated) return
|
|
||||||
|
|
||||||
setTeamState(updated)
|
|
||||||
setActivePlayers(updated.activePlayers)
|
|
||||||
setInactivePlayers(updated.inactivePlayers)
|
|
||||||
setInvitedPlayers(updated.invitedPlayers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmKick = async () => {
|
const confirmKick = async () => {
|
||||||
if (!kickCandidate) return
|
if (!kickCandidate) return
|
||||||
|
|
||||||
const newActive = activePlayers.filter(p => p.steamId !== kickCandidate.steamId)
|
const newActive = activePlayers.filter(p => p.steamId !== kickCandidate.steamId)
|
||||||
const newInactive = inactivePlayers.filter(p => p.steamId !== kickCandidate.steamId)
|
const newInactive = inactivePlayers.filter(p => p.steamId !== kickCandidate.steamId)
|
||||||
|
|
||||||
setActivePlayers(newActive)
|
setActivePlayers(newActive)
|
||||||
setInactivePlayers(newInactive)
|
setInactivePlayers(newInactive)
|
||||||
|
|
||||||
await fetch('/api/team/kick', {
|
await fetch('/api/team/kick', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ steamId: kickCandidate.steamId, teamId: team!.id }),
|
body: JSON.stringify({ steamId: kickCandidate.steamId, teamId: team.id }),
|
||||||
})
|
})
|
||||||
|
await updateTeamMembers(team.id, newActive, newInactive)
|
||||||
await updateTeamMembers(team!.id, newActive, newInactive)
|
|
||||||
setKickCandidate(null)
|
setKickCandidate(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,25 +295,22 @@ export default function TeamMemberView({
|
|||||||
const res = await fetch('/api/team/transfer-leader', {
|
const res = await fetch('/api/team/transfer-leader', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ teamId: team!.id, newLeaderSteamId: newLeaderId }),
|
body: JSON.stringify({ teamId: team.id, newLeaderSteamId: newLeaderId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
console.error('Fehler bei Leader-Übertragung:', data.message)
|
console.error('Fehler bei Leader-Übertragung:', data.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleReload()
|
await handleReload()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler bei Leader-Übertragung:', err)
|
console.error('Fehler bei Leader-Übertragung:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamState) return null
|
|
||||||
if (!adminMode && !currentUserSteamId) return null
|
if (!adminMode && !currentUserSteamId) return null
|
||||||
|
|
||||||
const manageSteam: string = adminMode ? teamState.leader ?? '' : currentUserSteamId
|
const manageSteam: string = adminMode ? (team.leader ?? '') : currentUserSteamId
|
||||||
|
|
||||||
const renderMemberList = (players: Player[]) => (
|
const renderMemberList = (players: Player[]) => (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@ -227,24 +323,18 @@ export default function TeamMemberView({
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className='max-w-[160px]'
|
className='max-w-[160px]'
|
||||||
>
|
>
|
||||||
{/* ✨ Link zur Profil-Seite */}
|
|
||||||
<Link
|
<Link
|
||||||
href={`/profile/${player.steamId}`} // ⇦ dein Profil-Slug
|
href={`/profile/${player.steamId}`}
|
||||||
passHref // nur nötig, falls du <a> verwendest
|
passHref
|
||||||
onClick={e => {
|
onClick={e => { if (isDragging) e.preventDefault() }}
|
||||||
/* Wenn gerade gezogen wird → Navigation verhindern */
|
|
||||||
if (isDragging) e.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Wichtig: SortableMiniCard selbst bleibt Drag-Handle,
|
|
||||||
deshalb keinen weiteren Wrapper mehr einziehen. */}
|
|
||||||
<SortableMiniCard
|
<SortableMiniCard
|
||||||
player={player}
|
player={player}
|
||||||
onKick={setKickCandidate}
|
onKick={setKickCandidate}
|
||||||
onPromote={() => setPromoteCandidate(player)}
|
onPromote={() => setPromoteCandidate(player)}
|
||||||
currentUserSteamId={manageSteam}
|
currentUserSteamId={manageSteam}
|
||||||
teamLeaderSteamId={teamState.leader}
|
teamLeaderSteamId={team.leader}
|
||||||
isAdmin={!!session?.user?.isAdmin}
|
isAdmin={adminMode}
|
||||||
isDraggingGlobal={isDragging}
|
isDraggingGlobal={isDragging}
|
||||||
hideOverlay={isDragging}
|
hideOverlay={isDragging}
|
||||||
matchParentBg
|
matchParentBg
|
||||||
@ -259,14 +349,13 @@ export default function TeamMemberView({
|
|||||||
<div className={`p-4 mt-6 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700 ${isDragging ? 'cursor-grabbing' : ''}`}>
|
<div className={`p-4 mt-6 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700 ${isDragging ? 'cursor-grabbing' : ''}`}>
|
||||||
<div className="flex justify-between items-center mb-6 flex-wrap gap-2">
|
<div className="flex justify-between items-center mb-6 flex-wrap gap-2">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Teamlogo mit Fallback */}
|
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div
|
<div
|
||||||
className="relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600 cursor-pointer"
|
className="relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600 cursor-pointer"
|
||||||
onClick={() => canManage && document.getElementById('logoUpload')?.click()}
|
onClick={() => canManage && document.getElementById('logoUpload')?.click()}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={teamState.logo ? `/assets/img/logos/${teamState.logo}` : `/assets/img/logos/cs2.webp`}
|
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
alt="Teamlogo"
|
alt="Teamlogo"
|
||||||
fill
|
fill
|
||||||
sizes="64px"
|
sizes="64px"
|
||||||
@ -274,23 +363,16 @@ export default function TeamMemberView({
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
priority={false}
|
priority={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Overlay beim Hover */}
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute inset-0 bg-black bg-opacity-50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<svg
|
{/* Icon */}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 mb-1" viewBox="0 0 576 512" fill="currentColor">
|
||||||
className="w-5 h-5 mb-1"
|
|
||||||
viewBox="0 0 576 512"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M288 109.3L288 352c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-242.7-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352l128 0c0 35.3 28.7 64 64 64s64-28.7 64-64l128 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/>
|
<path d="M288 109.3L288 352c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-242.7-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352l128 0c0 35.3 28.7 64 64 64s64-28.7 64-64l128 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden file input */}
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@ -300,27 +382,17 @@ export default function TeamMemberView({
|
|||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('logo', file)
|
formData.append('logo', file)
|
||||||
formData.append('teamId', teamState.id)
|
formData.append('teamId', team.id)
|
||||||
|
const res = await fetch('/api/team/upload-logo', { method: 'POST', body: formData })
|
||||||
const res = await fetch('/api/team/upload-logo', {
|
if (res.ok) await handleReload()
|
||||||
method: 'POST',
|
else alert('Fehler beim Hochladen des Logos.')
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
await handleReload()
|
|
||||||
} else {
|
|
||||||
alert('Fehler beim Hochladen des Logos.')
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Teamname + Bearbeiten */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isEditingName ? (
|
{isEditingName ? (
|
||||||
<>
|
<>
|
||||||
@ -330,36 +402,20 @@ export default function TeamMemberView({
|
|||||||
onChange={(e) => setEditedName(e.target.value)}
|
onChange={(e) => setEditedName(e.target.value)}
|
||||||
className="py-1.5 px-3 border rounded-lg text-sm dark:bg-neutral-800 dark:border-neutral-700 dark:text-white"
|
className="py-1.5 px-3 border rounded-lg text-sm dark:bg-neutral-800 dark:border-neutral-700 dark:text-white"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ✔ Übernehmen */}
|
|
||||||
<Button
|
<Button
|
||||||
title="Übernehmen"
|
title="Übernehmen"
|
||||||
color="green"
|
color="green"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await renameTeam(teamState.id, editedName)
|
await renameTeam(team.id, editedName)
|
||||||
setTeamState((prev) => prev ? { ...prev, teamname: editedName } : prev)
|
|
||||||
setIsEditingName(false)
|
setIsEditingName(false)
|
||||||
await handleReload()
|
await handleReload()
|
||||||
}}
|
}}
|
||||||
className="h-[34px] px-3 flex items-center justify-center"
|
className="h-[34px] px-3 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<svg
|
✔
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 00-1.414 0L8 12.586 4.707 9.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 000-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* ✖ Abbrechen */}
|
|
||||||
<Button
|
<Button
|
||||||
title="Abbrechen"
|
title="Abbrechen"
|
||||||
color="red"
|
color="red"
|
||||||
@ -367,29 +423,18 @@ export default function TeamMemberView({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditingName(false)
|
setIsEditingName(false)
|
||||||
setEditedName(teamState.name ?? '')
|
setEditedName(team.name ?? '')
|
||||||
}}
|
}}
|
||||||
className="h-[34px] px-3 flex items-center justify-center"
|
className="h-[34px] px-3 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<svg
|
✖
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||||
{teamState.name ?? 'Team'}
|
{team.name ?? 'Team'}
|
||||||
</h2>
|
</h2>
|
||||||
<TeamPremierRankBadge players={activePlayers} />
|
<TeamPremierRankBadge players={activePlayers} />
|
||||||
</div>
|
</div>
|
||||||
@ -401,18 +446,11 @@ export default function TeamMemberView({
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditingName(true)
|
setIsEditingName(true)
|
||||||
setEditedName(teamState.name || '')
|
setEditedName(team.name || '')
|
||||||
}}
|
}}
|
||||||
className="h-[34px] px-3 flex items-center justify-center"
|
className="h-[34px] px-3 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<svg
|
Bearbeiten
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="w-4 h-4"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M17.414 2.586a2 2 0 010 2.828l-9.793 9.793a1 1 0 01-.293.207l-4 2a1 1 0 01-1.32-1.32l2-4a1 1 0 01.207-.293l9.793-9.793a2 2 0 012.828 0zM15.586 4L6 13.586l-1.293 2.586L7.414 14 17 4.414 15.586 4z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -420,7 +458,6 @@ export default function TeamMemberView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aktionen */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
@ -437,11 +474,7 @@ export default function TeamMemberView({
|
|||||||
if (isLeader) {
|
if (isLeader) {
|
||||||
setShowLeaveModal(true)
|
setShowLeaveModal(true)
|
||||||
} else {
|
} else {
|
||||||
try {
|
try { await leaveTeam(currentUserSteamId) } catch (err) { console.error('Fehler beim Verlassen:', err) }
|
||||||
await leaveTeam(currentUserSteamId)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Fehler beim Verlassen:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="text-sm px-3 py-1.5 bg-gray-200 text-black rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600"
|
className="text-sm px-3 py-1.5 bg-gray-200 text-black rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600"
|
||||||
@ -451,16 +484,16 @@ export default function TeamMemberView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
<DndContext key={`dnd-${team.id}-${remountKey}`} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<DroppableZone id="active" label={`Aktive Spieler (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
|
<DroppableZone id="active" label={`Aktive Spieler (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
|
||||||
<SortableContext items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
<SortableContext key={`sc-active-${remountKey}-${activePlayers.map(p=>p.steamId).join(',')}`} items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||||||
{renderMemberList(activePlayers)}
|
{renderMemberList(activePlayers)}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DroppableZone>
|
</DroppableZone>
|
||||||
|
|
||||||
<DroppableZone id="inactive" label="Inaktive Spieler" activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
|
<DroppableZone id="inactive" label="Inaktive Spieler" activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
|
||||||
<SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
<SortableContext key={`sc-inactive-${remountKey}-${inactivePlayers.map(p=>p.steamId).join(',')}`} items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||||||
{renderMemberList(inactivePlayers)}
|
{renderMemberList(inactivePlayers)}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<motion.div key="mini-card-dummy" initial={{ opacity: 0 }} animate={{ opacity: 1}} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
<motion.div key="mini-card-dummy" initial={{ opacity: 0 }} animate={{ opacity: 1}} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
@ -472,7 +505,7 @@ export default function TeamMemberView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center w-16 h-16 bg-white rounded-full">
|
<div className="flex items-center justify-center w-16 h-16 bg-white rounded-full">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8 text-black" fill="currentColor" viewBox="0 0 640 512">
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" viewBox="0 0 640 512" fill="currentColor">
|
||||||
<path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM504 312v-64h-64c-13.3 0-24-10.7-24-24s10.7-24 24-24h64v-64c0-13.3 10.7-24 24-24s24 10.7 24 24v64h64c13.3 0 24 10.7 24 24s-10.7 24-24 24h-64v64c0 13.3-10.7 24-24 24s-24-10.7-24-24z" />
|
<path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM504 312v-64h-64c-13.3 0-24-10.7-24-24s10.7-24 24-24h64v-64c0-13.3 10.7-24 24-24s24 10.7 24 24v64h64c13.3 0 24 10.7 24 24s-10.7 24-24 24h-64v64c0 13.3-10.7 24-24 24s-24-10.7-24-24z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -491,13 +524,7 @@ export default function TeamMemberView({
|
|||||||
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{invitedPlayers.map((player: InvitedPlayer) => (
|
{invitedPlayers.map((player: InvitedPlayer) => (
|
||||||
<motion.div
|
<motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
|
||||||
key={player.steamId}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<MiniCard
|
<MiniCard
|
||||||
steamId={player.steamId}
|
steamId={player.steamId}
|
||||||
title={player.name}
|
title={player.name}
|
||||||
@ -507,12 +534,12 @@ export default function TeamMemberView({
|
|||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
currentUserSteamId={currentUserSteamId}
|
currentUserSteamId={currentUserSteamId}
|
||||||
teamLeaderSteamId={teamState.leader}
|
teamLeaderSteamId={team.leader}
|
||||||
isSelectable={false}
|
isSelectable={false}
|
||||||
isInvite={true}
|
isInvite={true}
|
||||||
rank={player.premierRank}
|
rank={player.premierRank}
|
||||||
onKick={revokeInvitation}
|
onKick={revokeInvitation}
|
||||||
invitationId={player.invitationId}
|
invitationId={(player as any).invitationId}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
@ -528,8 +555,8 @@ export default function TeamMemberView({
|
|||||||
<SortableMiniCard
|
<SortableMiniCard
|
||||||
player={activeDragItem}
|
player={activeDragItem}
|
||||||
currentUserSteamId={currentUserSteamId}
|
currentUserSteamId={currentUserSteamId}
|
||||||
teamLeaderSteamId={teamState.leader}
|
teamLeaderSteamId={team.leader}
|
||||||
isAdmin={!!session?.user?.isAdmin}
|
isAdmin={adminMode}
|
||||||
hideOverlay
|
hideOverlay
|
||||||
matchParentBg
|
matchParentBg
|
||||||
/>
|
/>
|
||||||
@ -537,37 +564,33 @@ export default function TeamMemberView({
|
|||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
{/* Modal(s) */}
|
|
||||||
{canInvite && (
|
{canInvite && (
|
||||||
<InvitePlayersModal
|
<InvitePlayersModal
|
||||||
show={showInviteModal}
|
show={showInviteModal}
|
||||||
onClose={() => setShowInviteModal(false)}
|
onClose={() => setShowInviteModal(false)}
|
||||||
onSuccess={() => {}}
|
onSuccess={() => {}}
|
||||||
team={teamState}
|
team={team}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canAddDirect && (
|
{canAddDirect && (
|
||||||
<InvitePlayersModal
|
<InvitePlayersModal
|
||||||
show={showInviteModal}
|
show={showInviteModal}
|
||||||
onClose={() => setShowInviteModal(false)}
|
onClose={() => setShowInviteModal(false)}
|
||||||
onSuccess={() => {}}
|
onSuccess={() => {}}
|
||||||
team={teamState}
|
team={team}
|
||||||
directAdd
|
directAdd
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Leader-spezifische Modale (z. B. Team verlassen) */}
|
|
||||||
{isLeader && (
|
{isLeader && (
|
||||||
<LeaveTeamModal
|
<LeaveTeamModal
|
||||||
show={showLeaveModal}
|
show={showLeaveModal}
|
||||||
onClose={() => setShowLeaveModal(false)}
|
onClose={() => setShowLeaveModal(false)}
|
||||||
onSuccess={() => setShowLeaveModal(false)}
|
onSuccess={() => setShowLeaveModal(false)}
|
||||||
team={teamState}
|
team={team}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{canManage && promoteCandidate && (
|
{canManage && promoteCandidate && (
|
||||||
<Modal
|
<Modal
|
||||||
id={`modal-promote-player-${promoteCandidate.steamId}`}
|
id={`modal-promote-player-${promoteCandidate.steamId}`}
|
||||||
@ -581,7 +604,6 @@ export default function TeamMemberView({
|
|||||||
closeButtonTitle="Übertragen"
|
closeButtonTitle="Übertragen"
|
||||||
closeButtonColor="blue"
|
closeButtonColor="blue"
|
||||||
>
|
>
|
||||||
{/* ► PlayerCard des Kandidaten */}
|
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<MiniCard
|
<MiniCard
|
||||||
steamId={promoteCandidate.steamId}
|
steamId={promoteCandidate.steamId}
|
||||||
@ -592,12 +614,11 @@ export default function TeamMemberView({
|
|||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
currentUserSteamId={currentUserSteamId}
|
currentUserSteamId={currentUserSteamId}
|
||||||
teamLeaderSteamId={teamState.leader}
|
teamLeaderSteamId={team.leader}
|
||||||
hideActions
|
hideActions
|
||||||
isSelectable={false}
|
isSelectable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||||
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
|
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
|
||||||
</p>
|
</p>
|
||||||
@ -614,7 +635,6 @@ export default function TeamMemberView({
|
|||||||
closeButtonTitle="Entfernen"
|
closeButtonTitle="Entfernen"
|
||||||
closeButtonColor="red"
|
closeButtonColor="red"
|
||||||
>
|
>
|
||||||
{/* ► PlayerCard des Kandidaten */}
|
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<MiniCard
|
<MiniCard
|
||||||
steamId={kickCandidate.steamId}
|
steamId={kickCandidate.steamId}
|
||||||
@ -625,12 +645,11 @@ export default function TeamMemberView({
|
|||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
currentUserSteamId={currentUserSteamId}
|
currentUserSteamId={currentUserSteamId}
|
||||||
teamLeaderSteamId={teamState.leader}
|
teamLeaderSteamId={team.leader}
|
||||||
hideActions
|
hideActions
|
||||||
isSelectable={false}
|
isSelectable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||||
Möchtest du <strong>{kickCandidate.name}</strong> wirklich aus dem Team entfernen?
|
Möchtest du <strong>{kickCandidate.name}</strong> wirklich aus dem Team entfernen?
|
||||||
</p>
|
</p>
|
||||||
@ -647,7 +666,7 @@ export default function TeamMemberView({
|
|||||||
await fetch('/api/team/delete', {
|
await fetch('/api/team/delete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ teamId: teamState.id }),
|
body: JSON.stringify({ teamId: team.id }),
|
||||||
})
|
})
|
||||||
setShowDeleteModal(false)
|
setShowDeleteModal(false)
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
|
|||||||
@ -75,9 +75,9 @@ export default function AdminTeamsView() {
|
|||||||
/* ─────────────────────────── Render ─────────────────────────── */
|
/* ─────────────────────────── Render ─────────────────────────── */
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<div className="text-gray-500 dark:text-gray-400">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</p>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,9 +96,9 @@ export default function AdminTeamsView() {
|
|||||||
|
|
||||||
{/* Team-Grid */}
|
{/* Team-Grid */}
|
||||||
{teams.length === 0 ? (
|
{teams.length === 0 ? (
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<div className="text-gray-500 dark:text-gray-400">
|
||||||
Es wurden noch keine Teams erstellt.
|
Es wurden noch keine Teams erstellt.
|
||||||
</p>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{teams.map(t => (
|
{teams.map(t => (
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import LatestKnownCodeSettings from "./account/ShareCodeSettings"
|
|||||||
export default function AccountSettings() {
|
export default function AccountSettings() {
|
||||||
return (
|
return (
|
||||||
<>{/* Account Card */}
|
<>{/* Account Card */}
|
||||||
<div className="p-5 md:p-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700">
|
<div className="">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-4 xl:mb-8">
|
<div className="mb-4 xl:mb-8">
|
||||||
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
|
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
|
||||||
|
|||||||
@ -1,56 +1,97 @@
|
|||||||
|
// SSEHandler.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useRef, startTransition } from 'react'
|
||||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { reloadTeam } from '@/app/lib/sse-actions'
|
import { reloadTeam } from '@/app/lib/sse-actions'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTeamStore } from '@/app/lib/stores'
|
import { useTeamStore } from '@/app/lib/stores'
|
||||||
|
import { TEAM_EVENTS, SELF_EVENTS, SSEEventType } from '@/app/lib/sseEvents'
|
||||||
|
|
||||||
export default function SSEHandler() {
|
export default function SSEHandler() {
|
||||||
const { data: session } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId ?? null
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { setTeam } = useTeamStore()
|
const { setTeam, team } = useTeamStore()
|
||||||
|
const { connect, disconnect, lastEvent, source } = useSSEStore()
|
||||||
const { connect, lastEvent } = useSSEStore()
|
|
||||||
|
|
||||||
|
// nur verbinden, wenn eingeloggt & steamId vorhanden
|
||||||
|
const prevSteamId = useRef<string | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (steamId) connect(steamId)
|
if (status !== 'authenticated' || !steamId) {
|
||||||
}, [steamId])
|
// getrennt halten, wenn ausgeloggt / keine ID
|
||||||
|
if (source) disconnect()
|
||||||
|
prevSteamId.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (prevSteamId.current !== steamId) {
|
||||||
|
connect(steamId) // useSSEStore verhindert Doppel-Connect
|
||||||
|
prevSteamId.current = steamId
|
||||||
|
}
|
||||||
|
// bewusst KEIN disconnect() im Cleanup, damit global verbunden bleibt
|
||||||
|
}, [status, steamId, connect, disconnect, source])
|
||||||
|
|
||||||
|
// parallele Reloads pro Team vermeiden
|
||||||
|
const reloadInFlight = useRef<Set<string>>(new Set())
|
||||||
|
// alte/duplizierte Events filtern
|
||||||
|
const lastHandledTs = useRef<number>(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
|
const { type, payload, ts } = lastEvent
|
||||||
|
if (ts && ts <= lastHandledTs.current) return
|
||||||
|
lastHandledTs.current = ts ?? Date.now()
|
||||||
|
|
||||||
const { type, payload } = lastEvent
|
const teamId: string | undefined = payload?.teamId
|
||||||
|
|
||||||
const handleEvent = async () => {
|
const reloadIfNeeded = async (tid?: string) => {
|
||||||
switch (type) {
|
if (!tid) return
|
||||||
case 'team-updated':
|
// nur reloaden, wenn es auch das aktuell angezeigte Team ist
|
||||||
if (payload.teamId) {
|
if (team?.id && tid !== team.id) return
|
||||||
const updated = await reloadTeam(payload.teamId)
|
if (reloadInFlight.current.has(tid)) return
|
||||||
|
reloadInFlight.current.add(tid)
|
||||||
|
try {
|
||||||
|
const updated = await reloadTeam(tid)
|
||||||
if (updated) {
|
if (updated) {
|
||||||
setTeam(updated) // ✅ zentral setzen
|
// nicht render-blockierend updaten
|
||||||
|
startTransition(() => setTeam(updated))
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
break
|
console.error('[SSE] reloadTeam failed:', e)
|
||||||
|
} finally {
|
||||||
case 'team-kick-other':
|
reloadInFlight.current.delete(tid)
|
||||||
console.log('[SSE] Du wurdest gekickt')
|
|
||||||
router.push('/')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'team-left':
|
|
||||||
console.log('[SSE] Jemand hat das Team verlassen')
|
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.log('[SSE] Unbehandeltes Event:', type, payload)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEvent()
|
const handleSelfExit = () => {
|
||||||
}, [lastEvent])
|
// nur handeln, wenn wir noch ein Team im Store haben
|
||||||
|
if (team) {
|
||||||
return null // kein UI
|
startTransition(() => setTeam(null as any))
|
||||||
|
// replace statt push, um History nicht zu fluten
|
||||||
|
router.replace('/team')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// 1) Team-relevante Events → evtl. Reload
|
||||||
|
if (TEAM_EVENTS.has(type as SSEEventType)) {
|
||||||
|
await reloadIfNeeded(teamId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Self-Events → Store leeren + redirect
|
||||||
|
if (SELF_EVENTS.has(type as SSEEventType)) {
|
||||||
|
handleSelfExit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) alles andere (Notifications etc.) hier ignorieren
|
||||||
|
// -> werden in anderen Komponenten ausgewertet
|
||||||
|
})()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [lastEvent, team?.id, setTeam, router])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,20 @@ import { Player, Team, InvitedPlayer } from '../types/team'
|
|||||||
export async function reloadTeam(teamId: string): Promise<Team | null> {
|
export async function reloadTeam(teamId: string): Promise<Team | null> {
|
||||||
try {
|
try {
|
||||||
console.log("reloadTeam");
|
console.log("reloadTeam");
|
||||||
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}`)
|
const res = await fetch(
|
||||||
|
`/api/team/${encodeURIComponent(teamId)}?t=${Date.now()}`,
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
)
|
||||||
if (!res.ok) throw new Error('Fehler beim Abrufen des Teams')
|
if (!res.ok) throw new Error('Fehler beim Abrufen des Teams')
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const team = data.team ?? data
|
const team = data.team ?? data
|
||||||
if (!team) return null
|
if (!team) return null
|
||||||
|
|
||||||
const sortByName = <T extends Player>(players: T[]): T[] =>
|
const sortByName = <T extends Player>(arr: T[]) =>
|
||||||
players.sort((a, b) => a.name.localeCompare(b.name))
|
[...arr].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
console.log("reloadTeam:", data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
|
|||||||
105
src/app/lib/sseEvents.ts
Normal file
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
|
// useSSEStore.ts
|
||||||
import { create } from 'zustand'
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import {
|
||||||
|
SSE_EVENT_TYPES,
|
||||||
|
SSEEventType,
|
||||||
|
isSseEventType,
|
||||||
|
normalizeEventType,
|
||||||
|
} from '@/app/lib/sseEvents';
|
||||||
|
|
||||||
type SSEEvent = {
|
type SSEEvent = {
|
||||||
type: string
|
type: SSEEventType;
|
||||||
payload: any
|
payload: any;
|
||||||
}
|
ts: number;
|
||||||
|
};
|
||||||
|
|
||||||
type SSEState = {
|
type SSEState = {
|
||||||
source: EventSource | null
|
source: EventSource | null;
|
||||||
isConnected: boolean
|
isConnected: boolean;
|
||||||
lastEvent: SSEEvent | null
|
steamId: string | null;
|
||||||
connect: (steamId: string) => void
|
lastEvent: SSEEvent | null;
|
||||||
disconnect: () => void
|
connect: (steamId: string) => void;
|
||||||
}
|
disconnect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const useSSEStore = create<SSEState>((set, get) => {
|
export const useSSEStore = create<SSEState>((set, get) => {
|
||||||
let reconnectTimeout: NodeJS.Timeout | null = null
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const connect = (steamId: string) => {
|
const clearReconnect = () => {
|
||||||
if (get().source) return
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
const source = new EventSource(`http://localhost:3001/events?steamId=${steamId}`)
|
reconnectTimeout = null;
|
||||||
|
|
||||||
const eventTypes = [
|
|
||||||
'team-updated',
|
|
||||||
'team-leader-changed',
|
|
||||||
'team-member-joined',
|
|
||||||
'team-member-left',
|
|
||||||
'team-kick',
|
|
||||||
'team-kick-other',
|
|
||||||
'team-left',
|
|
||||||
'team-renamed',
|
|
||||||
'team-logo-updated',
|
|
||||||
// ... beliebig erweiterbar
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const type of eventTypes) {
|
|
||||||
source.addEventListener(type, (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse((event as MessageEvent).data)
|
|
||||||
console.log(`[SSE] ${type}:`, data)
|
|
||||||
|
|
||||||
set({ lastEvent: { type, payload: data } })
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[SSE] Fehler beim Parsen von ${type}:`, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
source.onerror = (err) => {
|
|
||||||
console.warn('[SSE] Fehler, versuche Reconnect...')
|
|
||||||
source.close()
|
|
||||||
set({ source: null, isConnected: false })
|
|
||||||
|
|
||||||
if (!reconnectTimeout) {
|
|
||||||
reconnectTimeout = setTimeout(() => {
|
|
||||||
reconnectTimeout = null
|
|
||||||
connect(steamId)
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
source.onopen = () => {
|
|
||||||
console.log('[SSE] Verbunden!')
|
|
||||||
set({ source, isConnected: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ source })
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
get().source?.close()
|
try { get().source?.close(); } catch {}
|
||||||
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
clearReconnect();
|
||||||
set({ source: null, isConnected: false })
|
set({ source: null, isConnected: false, steamId: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = (steamId: string) => {
|
||||||
|
const prev = get();
|
||||||
|
if (prev.source && prev.steamId === steamId) return;
|
||||||
|
|
||||||
|
if (prev.source && prev.steamId && prev.steamId !== steamId) {
|
||||||
|
try { prev.source.close(); } catch {}
|
||||||
|
set({ source: null, isConnected: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001';
|
||||||
|
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`;
|
||||||
|
const source = new EventSource(url);
|
||||||
|
|
||||||
|
const pushEvent = (rawType: string, data: any) => {
|
||||||
|
if (data?.type === 'heartbeat') return;
|
||||||
|
|
||||||
|
// Kanonisieren
|
||||||
|
const canonical = normalizeEventType(rawType, data, get().steamId);
|
||||||
|
if (!canonical) return;
|
||||||
|
|
||||||
|
set({ lastEvent: { type: canonical, payload: data, ts: Date.now() } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Named Events (nur kanonische registrieren; Legacy kommt über onmessage oder wird
|
||||||
|
// vom Server ggf. trotzdem als named geschickt → wir normalisieren in pushEvent)
|
||||||
|
for (const type of SSE_EVENT_TYPES) {
|
||||||
|
source.addEventListener(type, (ev) => {
|
||||||
|
try {
|
||||||
|
pushEvent(type, JSON.parse((ev as MessageEvent).data));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[SSE] parse ${type}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Events ohne "event:"-Header → onmessage
|
||||||
|
source.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(ev.data);
|
||||||
|
const rawType = data?.type;
|
||||||
|
if (typeof rawType !== 'string') return;
|
||||||
|
pushEvent(rawType, data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SSE] onmessage parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
console.warn('[SSE] Fehler, reconnect…');
|
||||||
|
try { source.close(); } catch {}
|
||||||
|
set({ source: null, isConnected: false });
|
||||||
|
clearReconnect();
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
reconnectTimeout = null;
|
||||||
|
if (!get().steamId || get().steamId === steamId) connect(steamId);
|
||||||
|
}, 1500 + Math.floor(Math.random() * 500));
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onopen = () => set({ source, isConnected: true, steamId });
|
||||||
|
|
||||||
|
// sofort setzen, onopen kommt ggf. später
|
||||||
|
set({ source, steamId });
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: null,
|
source: null,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
|
steamId: null,
|
||||||
lastEvent: null,
|
lastEvent: null,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import CreateTeamButton from '@/app/components/CreateTeamButton'
|
|||||||
import TeamCardComponent from '@/app/components/TeamCardComponent'
|
import TeamCardComponent from '@/app/components/TeamCardComponent'
|
||||||
import AccountSettings from '@/app/components/settings/AccountSettings'
|
import AccountSettings from '@/app/components/settings/AccountSettings'
|
||||||
|
|
||||||
export default function Page({ params }: { params: Promise<{ tab: string }> }) {
|
export default function SettingsTabPage({ params }: { params: Promise<{ tab: string }> }) {
|
||||||
const { tab } = use(params)
|
const { tab } = use(params)
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
@ -22,12 +22,6 @@ export default function Page({ params }: { params: Promise<{ tab: string }> }) {
|
|||||||
return (
|
return (
|
||||||
<Card maxWidth='auto' />
|
<Card maxWidth='auto' />
|
||||||
)
|
)
|
||||||
case 'team':
|
|
||||||
return (
|
|
||||||
<Card maxWidth='auto'>
|
|
||||||
<TeamCardComponent />
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
default:
|
default:
|
||||||
return notFound()
|
return notFound()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab name="Account" href="/settings/account" />
|
<Tab name="Account" href="/settings/account" />
|
||||||
<Tab name="Datenschutz" href="/settings/privacy" />
|
<Tab name="Datenschutz" href="/settings/privacy" />
|
||||||
<Tab name="Team" href="/settings/team" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
export default function Page() {
|
export default function SettingPage() {
|
||||||
redirect('/settings/account')
|
redirect('/settings/account')
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/app/team/layout.tsx
Normal file
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'
|
import { DefaultSession, DefaultUser } from 'next-auth'
|
||||||
|
|
||||||
declare module 'next-auth' {
|
declare module 'next-auth' {
|
||||||
interface Session {
|
interface Session {
|
||||||
user: {
|
user: DefaultSession['user'] & {
|
||||||
steamId: string
|
steamId?: string
|
||||||
isAdmin: boolean
|
isAdmin?: boolean
|
||||||
team: string | null
|
team?: string | null
|
||||||
id?: string
|
|
||||||
name?: string
|
|
||||||
image?: string
|
|
||||||
}
|
}
|
||||||
id?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User extends DefaultUser {
|
interface User extends DefaultUser {
|
||||||
@ -29,6 +24,5 @@ declare module 'next-auth/jwt' {
|
|||||||
team?: string | null
|
team?: string | null
|
||||||
name?: string
|
name?: string
|
||||||
image?: string
|
image?: string
|
||||||
id?: string
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// sse-server.js
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
const url = require('url')
|
const url = require('url')
|
||||||
|
|
||||||
@ -35,16 +36,26 @@ const server = http.createServer((req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
res.write('\n') // Verbindung offen halten
|
res.write('\n') // Verbindung offen halten
|
||||||
|
|
||||||
|
// 🔄 Client speichern
|
||||||
clients.set(steamId, res)
|
clients.set(steamId, res)
|
||||||
//console.log(`[SSE] Verbunden: steamId=${steamId}`)
|
|
||||||
|
// 🫀 Heartbeat alle 25s
|
||||||
|
res.write('retry: 2000\n\n') // optional: Client-Reconnect-Vorschlag
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
res.write(`event: ping\n`)
|
||||||
|
res.write(`data: {}\n\n`)
|
||||||
|
}, 25000)
|
||||||
|
|
||||||
req.on('close', () => {
|
req.on('close', () => {
|
||||||
|
clearInterval(interval) // 💡 sonst läuft dein setInterval ewig
|
||||||
clients.delete(steamId)
|
clients.delete(steamId)
|
||||||
//console.log(`[SSE] Verbindung geschlossen: steamId=${steamId}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Nachricht senden (POST)
|
// Nachricht senden (POST)
|
||||||
if (req.method === 'POST' && req.url === '/send') {
|
if (req.method === 'POST' && req.url === '/send') {
|
||||||
let body = ''
|
let body = ''
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user