This commit is contained in:
Linrador 2025-08-03 21:45:27 +02:00
parent c8945e55d8
commit 90a3bdeb35
63 changed files with 2958 additions and 1252 deletions

1
.env
View File

@ -15,3 +15,4 @@ STEAMCMD_PATH=C:\Users\Rother\Desktop\dev\ironie\steamcmd\steamcmd.exe
NEXTAUTH_SECRET=ironieopen NEXTAUTH_SECRET=ironieopen
NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_URL=http://localhost:3000
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3

741
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"fast": "next dev --turbo",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -18,8 +19,9 @@
"@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-fontawesome": "^0.2.2",
"@preline/dropdown": "^3.0.1", "@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0", "@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.10.1", "@prisma/client": "^6.13.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2", "csgo-sharecode": "^3.1.2",
"datatables.net": "^2.2.2", "datatables.net": "^2.2.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -28,10 +30,12 @@
"flag-icons": "^7.3.2", "flag-icons": "^7.3.2",
"framer-motion": "^12.18.1", "framer-motion": "^12.18.1",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"ky": "^1.8.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lzma-native": "^8.0.6", "lzma-native": "^8.0.6",
"next": "15.3.0", "next": "15.3.0",
"next-auth-steam": "^0.4.0", "next-auth-steam": "^0.4.0",
"next-intl": "^4.3.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
@ -56,7 +60,7 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.0", "eslint-config-next": "15.3.0",
"prisma": "^6.10.1", "prisma": "^6.13.0",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.19.4", "tsx": "^4.19.4",

View File

@ -21,7 +21,7 @@ model User {
location String? location String?
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
teamId String? @unique teamId String?
team Team? @relation("UserTeam", fields: [teamId], references: [id]) team Team? @relation("UserTeam", fields: [teamId], references: [id])
ledTeam Team? @relation("TeamLeader") ledTeam Team? @relation("TeamLeader")

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -1,41 +1,43 @@
'use client' 'use client'
import { use, useState } from 'react' import { notFound, usePathname } from 'next/navigation'
import { notFound } from 'next/navigation' import Card from '@/app/components/Card'
import Card from '@/app/components/Card' import MatchesAdminManager from '@/app/components/admin/MatchesAdminManager'
import TeamCardComponent from '@/app/components/TeamCardComponent' import AdminTeamsView from '@/app/components/admin/teams/AdminTeamsView'
import MatchesAdminManager from '@/app/components/MatchesAdminManager'
import MatchList from '@/app/components/MatchList'
export default function Page({ params }: { params: Promise<{ tab: string }> }) { export default function AdminPage() {
const { tab } = use(params) const pathname = usePathname()
const [refetchKey, setRefetchKey] = useState<string>('') // 🔥 Gemeinsamer Reload-Key const activeTab: 'teams' | 'matches' | 'privacy' | '' =
pathname.startsWith('/admin/teams') ? 'teams' :
pathname.startsWith('/admin/matches') ? 'matches' :
pathname.startsWith('/admin/privacy') ? 'privacy' :
''
const renderTabContent = () => { switch (activeTab) {
switch (tab) { case 'matches':
case 'matches': return (
return ( <Card title="Matches">
<Card title="Matches" description=""> <MatchesAdminManager />
<MatchesAdminManager /> </Card>
</Card> )
)
case 'privacy': case 'privacy':
return ( return (
<Card title="Datenschutz" description="Einstellungen zum Schutz deiner Daten." /> <Card title="Datenschutz"
) description="Einstellungen zum Schutz deiner Daten." />
case 'team': )
return (
<Card title="Team" description="Verwalte dein Team und lade Mitglieder ein."> case 'teams':
<div className="mb-4"> return (
<TeamCardComponent refetchKey={refetchKey} /> <Card title="Teams"
</div> description="Verwalte dein Team und lade Mitglieder ein."
</Card> maxWidth="auto">
) <AdminTeamsView />
default: </Card>
return notFound() )
}
default:
return notFound()
} }
return <>{renderTabContent()}</>
} }

View File

@ -7,7 +7,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<Tabs> <Tabs>
<Tab name="Spielpläne" href="/admin/matches" /> <Tab name="Spielpläne" href="/admin/matches" />
<Tab name="Privacy" href="/admin/privacy" /> <Tab name="Privacy" href="/admin/privacy" />
<Tab name="Team" href="/admin/team" /> <Tab name="Teams" href="/admin/teams" />
</Tabs> </Tabs>
<div className="mt-6"> <div className="mt-6">
{children} {children}

View File

@ -0,0 +1,31 @@
// ───────────────────────────────────────────────────────────
// src/app/(admin)/admin/teams/[teamId]/TeamAdminClient.tsx
// ───────────────────────────────────────────────────────────
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import TeamMemberView from '@/app/components/TeamMemberView'
import { useTeamManager } from '@/app/hooks/useTeamManager'
export default function TeamAdminClient({ teamId }: { teamId: string }) {
const [refetchKey, setRefetchKey] = useState<string>()
const { data: session } = useSession()
// jetzt wird die ID korrekt übergeben ➜ /api/team/[id]
const teamManager = useTeamManager({ teamId, refetchKey }, null)
if (teamManager.isLoading) return <LoadingSpinner />
return (
<div className="max-w-5xl mx-auto">
<TeamMemberView
{...teamManager}
currentUserSteamId={session?.user?.steamId ?? ''}
adminMode={true}
/>
</div>
)
}

View File

@ -0,0 +1,8 @@
// ───────────────────────────────────────────────────────────
// src/app/(admin)/admin/teams/[teamId]/page.tsx (SERVER)
// ───────────────────────────────────────────────────────────
import TeamAdminClient from './TeamAdminClient'
export default function Page({ params }: { params: { teamId: string } }) {
return <TeamAdminClient teamId={params.teamId} />
}

View File

@ -0,0 +1,12 @@
'use client'
import Card from '@/app/components/Card'
import AdminTeamsView from '@/app/components/admin/teams/AdminTeamsView'
export default function AdminTeamsPage() {
return (
<Card title="Teams" description="Alle Teams im Überblick" maxWidth="auto">
<AdminTeamsView />
</Card>
)
}

View File

@ -1,227 +1,234 @@
// /app/api/matches/[id]/route.ts // /app/api/matches/[id]/route.ts
/* eslint-disable @typescript-eslint/return-await */
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
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 { isAfter } from 'date-fns'
export async function GET(_: Request, context: { params: { id: string } }) { /*
const { id } = context.params Hilfs-Typen
if (!id) { */
return NextResponse.json({ error: 'Missing ID' }, { status: 400 }) type PlayerOut = {
} user : { steamId: string; name: string | null; avatar: string | null }
stats: any | null
try { team : string
const match = await prisma.match.findUnique({
where: { id },
include: {
players: {
include: {
user: true,
stats: true,
team: true,
},
},
teamAUsers: {
include: {
team: true,
},
},
teamBUsers: {
include: {
team: true,
},
},
},
})
if (!match) {
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
}
const teamAIds = new Set(match.teamAUsers.map(u => u.steamId));
const teamBIds = new Set(match.teamBUsers.map(u => u.steamId));
const playersA = match.players
.filter(p => teamAIds.has(p.steamId))
.map(p => ({
user: p.user,
stats: p.stats,
team: p.team?.name ?? 'Team A',
}));
const playersB = match.players
.filter(p => teamBIds.has(p.steamId))
.map(p => ({
user: p.user,
stats: p.stats,
team: p.team?.name ?? 'Team B',
}));
const teamA = {
name: match.teamAUsers[0]?.team?.name ?? 'Team A',
logo: null,
score: match.scoreA,
players: playersA,
};
const teamB = {
name: match.teamBUsers[0]?.team?.name ?? 'Team B',
logo: null,
score: match.scoreB,
players: playersB,
};
return NextResponse.json({
id: match.id,
title: match.title,
description: match.description,
demoDate: match.demoDate,
matchType: match.matchType,
roundCount: match.roundCount,
map: match.map,
teamA,
teamB,
});
} catch (err) {
console.error(`GET /matches/${id} failed:`, err)
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
}
} }
export async function PUT(req: NextRequest, context: { params: { id: string } }) { /* ───────────────────────────── GET ───────────────────────────── */
const { id } = context.params export async function GET (
const session = await getServerSession(authOptions(req)) _req: Request,
const userId = session?.user?.steamId { params: { id } }: { params: { id: string } },
const isAdmin = session?.user?.isAdmin ) {
if (!id)
if (!userId) { return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const body = await req.json()
const { title, description, matchDate, players } = body
const user = await prisma.user.findUnique({
where: { steamId: userId },
include: { ledTeam: true },
});
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id }, where : { id },
}); include: {
teamA : true,
teamB : true,
teamAUsers : { include: { team: true } },
teamBUsers : { include: { team: true } },
players : { include: { user: true, stats: true, team: true } },
},
})
if (!match) { if (!match)
return NextResponse.json({ error: 'Match not found' }, { status: 404 }) return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
}
const isTeamLeaderA = match.teamAId && user?.ledTeam?.id === match.teamAId; /* ---------- Editierbarkeit bestimmen ---------- */
const isTeamLeaderB = match.teamBId && user?.ledTeam?.id === match.teamBId; const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
const editable = match.matchType === 'community' && isFuture
if (!isAdmin && !isTeamLeaderA && !isTeamLeaderB) { /* ---------- Spielerlisten zusammenstellen --------------------------------- */
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) let playersA: PlayerOut[] = []
} let playersB: PlayerOut[] = []
// 🛡️ Validierung: Nur eigene Spieler if (editable) {
if (!isAdmin) { /* ───── Spieler kommen direkt aus der Match-Relation ───── */
const ownTeamId = isTeamLeaderA ? match.teamAId : match.teamBId /* ▸ teamAUsers / teamBUsers enthalten bereits User-Objekte */
const mapUser = (u: any, fallbackTeam: string) => ({
if (!ownTeamId) { user : { // nur die Felder, die das Frontend braucht
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 }) steamId: u.steamId,
} name : u.name ?? 'Unbekannt',
avatar : u.avatar ?? null,
const ownTeam = await prisma.team.findUnique({ where: { id: ownTeamId } })
const allowed = new Set(ownTeam?.activePlayers || [])
const invalid = players.some((p: any) =>
p.teamId === ownTeamId && !allowed.has(p.userId)
)
if (invalid) {
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
}
}
try {
// ❌ Alte Spieler löschen
await prisma.matchPlayer.deleteMany({ where: { matchId: id } }) // ✅ Richtig, nur wenn das Feld korrekt heißt
// ✅ Neue Spieler speichern
await prisma.matchPlayer.createMany({
data: players.map((p: any) => ({
matchId: id,
userId: p.userId,
teamId: p.teamId,
})),
})
// ✏️ Match aktualisieren
const updated = await prisma.match.findUnique({
where: { id },
include: {
players: {
include: {
user: true,
stats: true,
team: true,
},
},
}, },
stats: null, // noch keine Stats vorhanden
team : fallbackTeam,
}) })
if (!updated) { playersA = match.teamAUsers.map(u => mapUser(u, match.teamA?.name ?? 'CT'))
return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 }) playersB = match.teamBUsers.map(u => mapUser(u, match.teamB?.name ?? 'T'))
/* Falls beim Anlegen noch keine Spieler zugewiesen wurden,
(z. B. nach Migration) greifen wir auf activePlayers zurück */
if (playersA.length === 0 || playersB.length === 0) {
const [aIds, bIds] = [
match.teamA?.activePlayers ?? [],
match.teamB?.activePlayers ?? [],
]
const users = await prisma.user.findMany({
where : { steamId: { in: [...aIds, ...bIds] } },
select: { steamId: true, name: true, avatar: true },
})
const byId = Object.fromEntries(users.map(u => [u.steamId, u]))
playersA = aIds.map(id => mapUser(byId[id] ?? { steamId: id }, match.teamA?.name ?? 'CT'))
playersB = bIds.map(id => mapUser(byId[id] ?? { steamId: id }, match.teamB?.name ?? 'T'))
} }
} else {
/* ───── Vergangene Matches: Stats-basierte Darstellung ───── */
const setA = new Set(match.teamAUsers.map(u => u.steamId))
const setB = new Set(match.teamBUsers.map(u => u.steamId))
// 🔄 Spieler wieder trennen playersA = match.players
const playersA = updated.players .filter(p => setA.has(p.steamId))
.filter(p => p.teamId === updated.teamAId) .map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'CT' }))
.map(p => ({
user: p.user,
stats: p.stats,
team: p.team?.name ?? 'CT',
}))
const playersB = updated.players playersB = match.players
.filter(p => p.teamId === updated.teamBId) .filter(p => setB.has(p.steamId))
.map(p => ({ .map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'T' }))
user: p.user,
stats: p.stats,
team: p.team?.name ?? 'T',
}))
return NextResponse.json({
id: updated.id,
title: updated.title,
description: updated.description,
demoDate: updated.demoDate,
matchType: updated.matchType,
map: updated.map,
scoreA: updated.scoreA,
scoreB: updated.scoreB,
teamA: playersA,
teamB: playersB,
})
} catch (err) {
console.error(`PUT /matches/${id} failed:`, err)
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
} }
/* ---------- Antwort ---------- */
return NextResponse.json({
id : match.id,
title : match.title,
description: match.description,
demoDate : match.demoDate,
matchType : match.matchType,
roundCount : match.roundCount,
map : match.map,
scoreA : match.scoreA,
scoreB : match.scoreB,
editable, // <-- Frontend-Flag
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? 'CT',
logo : match.teamA?.logo ?? null,
leader : match.teamA?.leaderId ?? null,
score : match.scoreA,
players: playersA,
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? 'T',
logo : match.teamB?.logo ?? null,
leader : match.teamB?.leaderId ?? null,
score : match.scoreB,
players: playersB,
},
})
} }
export async function DELETE(req: NextRequest, context: { params: { id: string } }) { /* ───────────────────────────── PUT ───────────────────────────── */
const { id } = context.params export async function PUT (
req: NextRequest,
{ params: { id } }: { params: { id: string } },
) {
const session = await getServerSession(authOptions(req)) const session = await getServerSession(authOptions(req))
const me = session?.user
if (!session?.user?.isAdmin) { if (!me?.steamId)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const match = await prisma.match.findUnique({ where: { id } })
if (!match)
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
/* ---------- erneute Editierbarkeits-Prüfung ---------- */
const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
const editable = match.matchType === 'community' && isFuture
if (!editable)
return NextResponse.json({ error: 'Match kann nicht bearbeitet werden' }, { status: 403 })
/* ---------- Rollen-Check (Admin oder Team-Leader) ----- */
const userData = await prisma.user.findUnique({
where : { steamId: me.steamId },
include: { ledTeam: true },
})
const leaderOf = userData?.ledTeam?.id
const isLeader = leaderOf && (leaderOf === match.teamAId || leaderOf === match.teamBId)
if (!me.isAdmin && !isLeader)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
/* ---------- Payload einlesen & validieren ------------- */
const { players } = await req.json() // title / description etc. bei Bedarf ergänzen
// wenn kein Admin: sicherstellen, dass nur Spieler des eigenen Teams gesetzt werden
if (!me.isAdmin && leaderOf) {
const ownTeam = await prisma.team.findUnique({ where: { id: leaderOf } })
const allowed = new Set([
...(ownTeam?.activePlayers ?? []),
...(ownTeam?.inactivePlayers ?? []),
])
const invalid = players.some((p: any) =>
p.teamId === leaderOf && !allowed.has(p.steamId),
)
if (invalid)
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
} }
/* ---------- Spieler-Mapping speichern ----------------- */
try { try {
// Lösche Match inklusive aller zugehörigen MatchPlayer-Einträge (wenn onDelete: Cascade nicht aktiv) /* ► Listen pro Team aus dem Payload aufteilen */
await prisma.matchPlayer.deleteMany({ where: { matchId: id } }) const teamAIds = players
.filter((p: any) => p.teamId === match.teamAId)
.map((p: any) => p.steamId)
// Lösche das Match const teamBIds = players
await prisma.match.delete({ where: { id } }) .filter((p: any) => p.teamId === match.teamBId)
.map((p: any) => p.steamId)
await prisma.$transaction([
/* 1) alle alten Zuordnungen löschen … */
prisma.matchPlayer.deleteMany({ where: { matchId: id } }),
/* 2) … neue anlegen */
prisma.matchPlayer.createMany({
data: players.map((p: any) => ({
matchId: id,
steamId: p.steamId,
teamId : p.teamId,
})),
skipDuplicates: true,
}),
/* 3) M-N-Relationen an Match-Eintrag aktualisieren */
prisma.match.update({
where: { id },
data : {
teamAUsers: { set: teamAIds.map((steamId: string) => ({ steamId })) },
teamBUsers: { set: teamBIds.map((steamId: string) => ({ steamId })) },
},
}),
])
} catch (e) {
console.error(`PUT /matches/${id} Spielerupdate fehlgeschlagen:`, e)
return NextResponse.json({ error: 'Failed to update players' }, { status: 500 })
}
/* ---------- neue Daten abrufen & zurückgeben ---------- */
return GET(req, { params: { id } }) // gleiche Antwort-Struktur wie oben
}
/* ─────────────────────────── DELETE ─────────────────────────── */
export async function DELETE (
_req: NextRequest,
{ params: { id } }: { params: { id: string } },
) {
const session = await getServerSession(authOptions(_req))
if (!session?.user?.isAdmin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
try {
await prisma.$transaction([
prisma.matchPlayer.deleteMany({ where: { matchId: id } }),
prisma.match.delete({ where: { id } }),
])
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (err) { } catch (err) {
console.error(`DELETE /matches/${id} failed:`, err) console.error(`DELETE /matches/${id} failed:`, err)

View File

@ -1,37 +1,32 @@
// /app/api/matches/create/route.ts // /app/api/matches/create/route.ts
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
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'
export async function POST (req: NextRequest) { export async function POST (req: NextRequest) {
/* ▸ Berechtigung ---------------------------------------------------------------- */ /* ── Auth ▸ nur Admins ───────────────────────────── */
const session = await getServerSession(authOptions(req)) const session = await getServerSession(authOptions(req))
if (!session?.user?.isAdmin) { if (!session?.user?.isAdmin)
return NextResponse.json({ error: 'Unauthorized' }, { status : 403 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
/* ▸ Eingaben aus dem Body -------------------------------------------------------- */ /* ── Body auslesen ──────────────────────────────── */
const { teamAId, teamBId, title, description, matchDate, map } = await req.json() const { teamAId, teamBId, title, description, matchDate, map } = await req.json()
if (!teamAId || !teamBId || !matchDate)
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
if (!teamAId || !teamBId || !matchDate) { /* ── Teams inkl. aktiver Spieler laden ───────────── */
return NextResponse.json({ error: 'Missing fields' }, { status : 400 }) const [teamA, teamB] = await Promise.all([
} prisma.team.findUnique({ where: { id: teamAId }, select: { activePlayers: true } }),
prisma.team.findUnique({ where: { id: teamBId }, select: { activePlayers: true } }),
])
if (!teamA || !teamB)
return NextResponse.json({ error: 'Team not found' }, { status: 404 })
/* ── Match + Spieler in EINER Transaktion ────────── */
try { try {
/* ▸ Aktive Spieler der Teams laden ------------------------------------------- */
const [teamA, teamB] = await Promise.all([
prisma.team.findUnique({ where: { id: teamAId }, select: { activePlayers: true } }),
prisma.team.findUnique({ where: { id: teamBId }, select: { activePlayers: true } })
])
if (!teamA || !teamB) {
return NextResponse.json({ error: 'Team not found' }, { status : 404 })
}
/* ▸ Match & MatchPlayers in einer Transaktion anlegen ----------------------- */
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
/* 1. Match */ /* 1) Match mit verbundenen Team-User-Arrays anlegen */
const newMatch = await tx.match.create({ const newMatch = await tx.match.create({
data: { data: {
teamAId, teamAId,
@ -39,28 +34,21 @@ export async function POST (req: NextRequest) {
title : title?.trim() || `${teamAId}-${teamBId}`, title : title?.trim() || `${teamAId}-${teamBId}`,
description : description?.trim() || null, description : description?.trim() || null,
map : map?.trim() || null, map : map?.trim() || null,
demoDate : new Date(matchDate) demoDate : new Date(matchDate),
}
/* aktive Spieler direkt verbinden */
teamAUsers: { connect: teamA.activePlayers.map(id => ({ steamId: id })) },
teamBUsers: { connect: teamB.activePlayers.map(id => ({ steamId: id })) },
},
}) })
/* 2. Spieler-Datensätze vorbereiten */ /* 2) separate MatchPlayer-Zeilen */
const playersData = [ const playersData = [
...teamA.activePlayers.map((steamId: string) => ({ ...teamA.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamAId })),
matchId: newMatch.id, ...teamB.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamBId })),
steamId,
teamId : teamAId
})),
...teamB.activePlayers.map((steamId: string) => ({
matchId: newMatch.id,
steamId,
teamId : teamBId
}))
] ]
if (playersData.length)
/* 3. Anlegen (nur wenn Spieler vorhanden) */
if (playersData.length) {
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true }) await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
}
return newMatch return newMatch
}) })
@ -68,6 +56,6 @@ export async function POST (req: NextRequest) {
return NextResponse.json(result, { status: 201 }) return NextResponse.json(result, { status: 201 })
} catch (err) { } catch (err) {
console.error('POST /matches/create failed:', err) console.error('POST /matches/create failed:', err)
return NextResponse.json({ error: 'Failed to create match' }, { status : 500 }) return NextResponse.json({ error: 'Failed to create match' }, { status: 500 })
} }
} }

View File

@ -1,46 +1,80 @@
// /app/api/schedule/route.ts
'use server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
export async function GET() { export async function GET() {
try { try {
const schedules = await prisma.schedule.findMany({ /* 1) nur Community-Matches holen ------------------------------ */
orderBy: { const matches = await prisma.match.findMany({
date: 'asc', where : { matchType: 'community' },
}, orderBy : { demoDate: 'desc' }, // falls demoDate null ⇒ älter oben
include: {
teamA: true, include : {
teamB: true, teamA : true,
createdBy: { teamB : true,
select: { players: {
steamId: true, include: {
name: true, user : true,
avatar: true, stats: true,
}, team : true,
},
confirmedBy: {
select: {
steamId: true,
name: true,
avatar: true,
},
},
linkedMatch: {
select: {
id: true,
map: true,
scoreA: true,
scoreB: true,
demoDate: true,
}, },
}, },
}, },
}) })
return NextResponse.json({ schedules }) /* 2) API-Response vereinheitlichen ---------------------------- */
} catch (error) { const formatted = matches.map(m => {
console.error('❌ Fehler beim Abrufen der geplanten Matches:', error) /** ➜ einheitliches Datumsfeld für Frontend */
return new NextResponse('Serverfehler beim Laden der geplanten Matches', { const matchDate =
status: 500, m.demoDate ??
// @ts-ignore falls du optional noch ein „date“-Feld hast
(m as any).date ??
m.createdAt
return {
id : m.id,
title : m.title,
map : m.map ?? null,
matchType : 'community',
matchDate : matchDate.toISOString(),
scoreA : m.scoreA,
scoreB : m.scoreB,
winnerTeam: m.winnerTeam ?? null,
teamA: {
id : m.teamA?.id ?? null,
name : m.teamA?.name ?? 'CT',
logo : m.teamA?.logo ?? null,
score: m.scoreA,
},
teamB: {
id : m.teamB?.id ?? null,
name : m.teamB?.name ?? 'T',
logo : m.teamB?.logo ?? null,
score: m.scoreB,
},
players: m.players.map(p => ({
steamId : p.steamId,
name : p.user?.name,
avatar : p.user?.avatar,
stats : p.stats,
teamId : p.teamId,
teamName: p.team?.name ?? null,
})),
}
}) })
/* 3) zurückgeben --------------------------------------------- */
return NextResponse.json({ matches: formatted })
} catch (err) {
console.error('❌ Fehler beim Abrufen der Community-Matches:', err)
return NextResponse.json(
{ error: 'Serverfehler beim Laden der Community-Matches' },
{ status: 500 },
)
} }
} }

View File

@ -1,34 +1,80 @@
// /api/team/[teamId]/route.ts // src/app/api/team/[teamId]/route.ts
import { NextResponse, type NextRequest } from 'next/server' 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'
export async function GET( export async function GET(
req: NextRequest, _req: NextRequest,
{ params }: { params: { teamId: string } } { params }: { params: { teamId: string } },
) { ) {
try { try {
const param = await params /* ─── 1) Team holen ─────────────────────────────── */
const team = await prisma.team.findUnique({ const team = await prisma.team.findUnique({
where: { id: param.teamId }, where: { id: params.teamId },
}) })
if (!team) { if (!team) {
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 }) return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
} }
const activePlayers = await prisma.user.findMany({ /* ─── 2) Alle Steam-IDs sammeln und Users laden ─── */
where: { steamId: { in: team.activePlayers } }, const allIds = Array.from(
select: { steamId: true, name: true, avatar: true, location: true }, new Set([...team.activePlayers, ...team.inactivePlayers]),
)
const users = await prisma.user.findMany({
where : { steamId: { in: allIds } },
select: {
steamId : true,
name : true,
avatar : true,
location : true,
premierRank: true,
},
}) })
const inactivePlayers = await prisma.user.findMany({ /* Map steamId → Player */
where: { steamId: { in: team.inactivePlayers } }, const byId: Record<string, Player> = Object.fromEntries(
select: { steamId: true, name: true, avatar: true, location: true }, users.map(u => [
}) u.steamId,
{
steamId: u.steamId,
name : u.name ?? 'Unbekannt',
avatar : u.avatar ?? '/assets/img/avatars/default.png',
location: u.location ?? '',
premierRank: u.premierRank ?? 0,
},
]),
)
return NextResponse.json(team) /* ─── 3) Arrays umwandeln + sortieren ───────────── */
const activePlayers = team.activePlayers
.map(id => byId[id])
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name))
const inactivePlayers = team.inactivePlayers
.map(id => byId[id])
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name))
/* ─── 4) Antwort zusammenbauen ───────────────────── */
const result = {
id : team.id,
name : team.name,
logo : team.logo,
leader : team.leaderId,
createdAt : team.createdAt,
activePlayers,
inactivePlayers,
}
return NextResponse.json(result)
} catch (error) { } catch (error) {
console.error('Fehler beim Laden des Teams:', error) console.error('GET /api/team/[teamId] failed:', error)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 }) return NextResponse.json(
{ error: 'Interner Serverfehler' },
{ status: 500 },
)
} }
} }

View File

@ -0,0 +1,47 @@
// /app/api/team/add-players/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export async function POST(req: NextRequest) {
const { teamId, steamIds }: { teamId: string; steamIds: string[] } = await req.json()
if (!teamId || !Array.isArray(steamIds) || steamIds.length === 0) {
return NextResponse.json({ message: 'Ungültige Parameter' }, { status: 400 })
}
/* ▸ Spieler anhängen -------------------------------------------------------- */
await prisma.team.update({
where: { id: teamId },
data : { inactivePlayers: { push: steamIds } },
})
/* ▸ Frisches Team laden, um alle Player-IDs zu haben ----------------------- */
const team = await prisma.team.findUnique({
where : { id: teamId },
select: { // nur das Nötigste
id : true,
activePlayers : true,
inactivePlayers: true,
},
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
/* ▸ Alle Teammitglieder (alt + neu) als Zielgruppe ------------------------- */
const allPlayers = [
...team.activePlayers,
...team.inactivePlayers,
]
/* ▸ SSE-Push --------------------------------------------------------------- */
await sendServerSSEMessage({
type : 'team-updated',
teamId : team.id,
targetUserIds : allPlayers,
})
return NextResponse.json({ ok: true })
}

View File

@ -1,60 +1,68 @@
// /api/team/create/route.ts // /api/team/create/route.ts
import { NextResponse, type NextRequest } from 'next/server' 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 async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamname, leader } = await req.json(); /* ───── Request-Body ───── */
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
if (!teamname || !leader) { /* ► Teamname pflicht */
return NextResponse.json({ message: 'Fehlende Eingaben.' }, { status: 400 }); if (!teamname?.trim()) {
return NextResponse.json({ message: 'Teamname fehlt.' }, { status: 400 })
} }
const existingTeam = await prisma.team.findFirst({ where: { name: teamname } }); /* ► Name schon vergeben? */
if (existingTeam) { const dup = await prisma.team.findFirst({ where: { name: teamname } })
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 }); if (dup) {
} return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
const existingUser = await prisma.user.findUnique({ where: { steamId: leader } });
if (!existingUser) {
return NextResponse.json({ message: 'Benutzer nicht gefunden.' }, { status: 404 });
} }
/* ───── Team anlegen ───── */
const newTeam = await prisma.team.create({ const newTeam = await prisma.team.create({
data: { data: {
name: teamname, name : teamname,
leaderId: leader, leaderId : leader ?? null, // ← nur setzen, wenn übergeben
activePlayers: [leader], activePlayers : leader ? [leader] : [],
inactivePlayers: [], inactivePlayers : [],
}, },
}); })
await prisma.user.update({ /* ───── Optional: Leader verknüpfen ───── */
where: { steamId: leader }, if (leader) {
data: { teamId: newTeam.id }, const user = await prisma.user.findUnique({ where: { steamId: leader } })
}); if (!user) {
// Rollback Team und Fehler ausgeben
await prisma.team.delete({ where: { id: newTeam.id } })
return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 })
}
await prisma.notification.create({ await prisma.user.update({
data: { where: { steamId: leader },
steamId: leader, data : { teamId: newTeam.id },
title: 'Team erstellt', })
message: `Du hast erfolgreich das Team "${teamname}" erstellt.`,
},
});
// 📢 SSE Nachricht senden await prisma.notification.create({
data: {
steamId: leader,
title : 'Team erstellt',
message: `Du hast erfolgreich das Team „${teamname}“ erstellt.`,
},
})
}
/* ───── SSE an alle raus ───── */
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-created', type : 'team-created',
title: 'Team erstellt', title : 'Team erstellt',
message: `Das Team "${teamname}" wurde erstellt.`, message: `Das Team ${teamname} wurde erstellt.`,
teamId: newTeam.id, teamId : newTeam.id,
}); })
return NextResponse.json({ message: 'Team erstellt', team: newTeam }); return NextResponse.json({ message: 'Team erstellt', team: newTeam })
} catch (error) {
} catch (error: any) { console.error('❌ Fehler beim Team erstellen:', error)
console.error('Fehler beim Team erstellen:', error.message, error.stack); return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 })
return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 });
} }
} }

View File

@ -1,67 +1,80 @@
import { NextResponse, type NextRequest } 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'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json() const { teamId, userIds: rawUserIds, invitedBy } = await req.json()
const { teamId, userIds: rawUserIds, invitedBy } = body
if (!teamId || !rawUserIds || !invitedBy) { /* ------------------------------------------------------------ */
/* Eingaben prüfen */
/* ------------------------------------------------------------ */
if (!teamId || !Array.isArray(rawUserIds) || rawUserIds.length === 0) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 }) return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
} }
const userIds = rawUserIds.filter((id: string) => id !== invitedBy) /* Eingeladener darf nicht sich selbst einladen */
const steamIds = rawUserIds.filter((id: string) => id !== invitedBy)
/* Team holen */
const team = await prisma.team.findUnique({ const team = await prisma.team.findUnique({
where: { id: teamId }, where : { id: teamId },
select: { name: true }, select: { name: true },
}) })
if (!team) { if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 }) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
} }
const teamName = team.name ?? 'Unbekanntes Team'
const teamName = team.name || 'Unbekanntes Team' /* ------------------------------------------------------------ */
/* Einladungen + Benachrichtigungen erzeugen */
const results = await Promise.all( /* ------------------------------------------------------------ */
userIds.map(async (userId: string) => { const invitationIds = await Promise.all(
const invitation = await prisma.teamInvite.create({ steamIds.map(async (steamId: string) => {
/* TeamInvite anlegen FELD-NAMEN ans Schema anpassen! */
const invite = await prisma.teamInvite.create({
data: { data: {
userId,
teamId, teamId,
steamId,
type: 'team-invite', type: 'team-invite',
}, },
}) })
/* Notification anlegen */
const notification = await prisma.notification.create({ const notification = await prisma.notification.create({
data: { data: {
userId, steamId,
title: 'Teameinladung', title : 'Teameinladung',
message: `Du wurdest in das Team "${teamName}" eingeladen.`, message : `Du wurdest in das Team "${teamName}" eingeladen.`,
actionType: 'team-invite', actionType: 'team-invite',
actionData: invitation.id, actionData: invite.id,
}, },
}) })
/* SSE pushen */
await sendServerSSEMessage({ await sendServerSSEMessage({
type: notification.actionType ?? 'notification', type : notification.actionType ?? 'notification',
targetUserIds: [userId], targetUserIds: [steamId],
message: notification.message, message : notification.message,
id: notification.id, id : notification.id,
actionType: notification.actionType ?? undefined, actionType : notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined, actionData : notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(), createdAt : notification.createdAt.toISOString(),
}) })
return invitation.id return invite.id
}) }),
) )
return NextResponse.json({ message: 'Einladungen versendet', invitationIds: results }) return NextResponse.json(
} catch (error) { { message: 'Einladungen versendet', invitationIds },
console.error('Fehler beim Versenden der Einladungen:', error) { status: 200 },
return NextResponse.json({ message: 'Fehler beim Einladen' }, { status: 500 }) )
} catch (err) {
console.error('[TEAM-INVITE] Fehler:', err)
return NextResponse.json(
{ message: 'Fehler beim Einladen' },
{ status: 500 },
)
} }
} }

View File

@ -1,3 +1,4 @@
// src/app/api/team/kick/route.ts
import { NextResponse, type NextRequest } from 'next/server' 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'
@ -6,12 +7,18 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
/* ------------------------------------------------------------------ *
* 1) Payload-Validierung *
* ------------------------------------------------------------------ */
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 *
* ------------------------------------------------------------------ */
const team = await prisma.team.findUnique({ where: { id: teamId } }) const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) { if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 }) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
@ -19,75 +26,87 @@ export async function POST(req: NextRequest) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { steamId }, where: { steamId },
select: { name: true } select: { name: 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'
const active = team.activePlayers.filter((id) => id !== steamId) /* ------------------------------------------------------------------ *
const inactive = team.inactivePlayers.filter((id) => id !== steamId) * 3) Spielerlisten aktualisieren *
* ------------------------------------------------------------------ */
const active = team.activePlayers.filter(id => id !== steamId)
const inactive = team.inactivePlayers.filter(id => id !== steamId)
await prisma.team.update({ await prisma.team.update({
where: { id: teamId }, where: { id: teamId },
data: { data : {
activePlayers: { set: active }, activePlayers : { set: active },
inactivePlayers: { set: inactive }, inactivePlayers : { set: inactive },
}, },
}) })
/* der gekickte User gehört zu keinem Team mehr */
await prisma.user.update({ await prisma.user.update({
where: { steamId }, where: { steamId },
data: { teamId: null }, data : { teamId: null },
}) })
// 🟥 Gekickter User> /* ------------------------------------------------------------------ *
const notification = await prisma.notification.create({ * 4) Notifikation für den gekickten User *
* ------------------------------------------------------------------ */
const kickedNotification = await prisma.notification.create({
data: { data: {
userId: steamId, title : 'Team verlassen',
title: 'Teamverlassen', message : `Du wurdest aus dem Team „${teamName}“ geworfen.`,
message: `Du wurdest aus dem Team "${teamName}" geworfen.`, actionType : 'team-kick',
actionType: 'team-kick', actionData : null,
actionData: null, user : { connect: { steamId } }, // <-- Relation herstellen
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type: notification.actionType ?? 'notification', type : kickedNotification.actionType ?? 'notification',
targetUserIds: [steamId], targetUserIds: [steamId],
message: notification.message, message : kickedNotification.message,
id: notification.id, id : kickedNotification.id,
actionType: notification.actionType ?? undefined, actionType : kickedNotification.actionType ?? undefined,
actionData: notification.actionData ?? undefined, actionData : kickedNotification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(), createdAt : kickedNotification.createdAt.toISOString(),
}) })
// 🟩 Verbleibende Mitglieder /* ------------------------------------------------------------------ *
* 5) Notifikation für verbleibende Mitglieder *
* ------------------------------------------------------------------ */
const remainingUserIds = [...active, ...inactive] const remainingUserIds = [...active, ...inactive]
await Promise.all( await Promise.all(
remainingUserIds.map(async (userId) => { remainingUserIds.map(async memberSteamId => {
const notification = await prisma.notification.create({ const n = await prisma.notification.create({
data: { data: {
userId, title : 'Team-Update',
title: 'Teamupdate', message : `${userName} wurde aus dem Team „${teamName}“ geworfen.`,
message: `${userName} wurde aus dem Team "${teamName}" geworfen.`, actionType : 'team-kick-other',
actionType: 'team-kick-other', actionData : null,
actionData: null, user : { connect: { steamId: memberSteamId } }, // <-- Relation
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type: notification.actionType ?? 'notification', type : n.actionType ?? 'notification',
targetUserIds: [userId], targetUserIds: [memberSteamId],
message: notification.message, message : n.message,
id: notification.id, id : n.id,
actionType: notification.actionType ?? undefined, actionType : n.actionType ?? undefined,
actionData: notification.actionData ?? undefined, actionData : n.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(), createdAt : n.createdAt.toISOString(),
}) })
}) })
) )
/* ------------------------------------------------------------------ *
* 6) Erfolg *
* ------------------------------------------------------------------ */
return NextResponse.json({ message: 'Mitglied entfernt' }) return NextResponse.json({ message: 'Mitglied entfernt' })
} catch (error) { } catch (error) {
console.error('[KICK] Fehler:', error) console.error('[KICK] Fehler:', error)

View File

@ -58,7 +58,9 @@ export async function POST(req: NextRequest) {
const notification = await prisma.notification.create({ const notification = await prisma.notification.create({
data: { data: {
userId: steamId, user: {
connect: { steamId },
},
title: 'Teamupdate', title: 'Teamupdate',
message: `Du hast das Team "${team.name}" verlassen.`, message: `Du hast das Team "${team.name}" verlassen.`,
actionType: 'team-left', actionType: 'team-left',
@ -85,7 +87,9 @@ export async function POST(req: NextRequest) {
allRemainingPlayers.map(async (userId) => { allRemainingPlayers.map(async (userId) => {
const notification = await prisma.notification.create({ const notification = await prisma.notification.create({
data: { data: {
userId, user: {
connect: { steamId: userId },
},
title: 'Teamupdate', title: 'Teamupdate',
message: `${user?.name ?? 'Ein Spieler'} hat das Team verlassen.`, message: `${user?.name ?? 'Ein Spieler'} hat das Team verlassen.`,
actionType: 'team-member-left', actionType: 'team-member-left',
@ -102,6 +106,12 @@ export async function POST(req: NextRequest) {
actionData: notification.actionData ?? undefined, actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(), createdAt: notification.createdAt.toISOString(),
}) })
await sendServerSSEMessage({
type: 'team-updated',
teamId: team.id,
targetUserIds: allRemainingPlayers,
})
}) })
) )

View File

@ -1,53 +1,80 @@
// src/app/api/team/list/route.ts // src/app/api/team/list/route.ts
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import type { Player } from '@/app/types/team'
/**
* GET /api/team/list
* Liefert alle Teams inklusive aller Mitglieder (UserObjekte)
* Struktur:
* [
* {
* id, teamname, logo, leader, createdAt,
* players: [ { steamId, name, avatar, location } ]
* },
*
* ]
*/
export async function GET() { export async function GET() {
try { try {
/* 1. Alle Teams holen */ /* 1) Alle Teams mit reinen Steam-ID-Arrays holen ---------------- */
const teams = await prisma.team.findMany() const teams = await prisma.team.findMany({
select: {
id : true,
name : true,
logo : true,
leaderId : true,
createdAt : true,
activePlayers : true, // string[]
inactivePlayers: true, // string[]
},
})
/* 2. Für jedes Team die zugehörigen UserDatensätze besorgen */ /* 2) Einmalig ALLE vorkommenden Steam-IDs sammeln --------------- */
const teamsWithPlayers = await Promise.all( const uniqueIds = new Set<string>()
teams.map(async (t) => { teams.forEach(t => {
const steamIds = [...t.activePlayers, ...t.inactivePlayers] t.activePlayers.forEach(id => uniqueIds.add(id))
t.inactivePlayers.forEach(id => uniqueIds.add(id))
})
// User abrufen, die in active+inactive vorkommen /* 3) Die zugehörigen User-Objekte laden (ein Query) ------------- */
const players = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { steamId: { in: steamIds } }, where : { steamId: { in: [...uniqueIds] } },
select: { select: {
steamId: true, steamId : true,
name: true, name : true,
avatar: true, avatar : true,
location: true, location : true,
}, premierRank: true,
}) },
})
return { /* 4) steamId → Player: Null-Werte abfangen ------------------------ */
...t, const byId: Record<string, Player> = {}
players,
}
})
)
return NextResponse.json({ teams: teamsWithPlayers }, { status: 200 }) /* Fallbacks definieren */
const DEFAULT_AVATAR = '/assets/img/avatars/default.png' // oder was du nutzt
const UNKNOWN_NAME = 'Unbekannt'
users.forEach(u => {
byId[u.steamId] = {
steamId : u.steamId,
name : u.name ?? UNKNOWN_NAME,
avatar : u.avatar ?? DEFAULT_AVATAR,
location : u.location ?? '',
premierRank: u.premierRank ?? 0,
}
})
/* 5) Teams zurückgeben jetzt mit aufgelösten Spielern --------- */
const result = teams.map(t => ({
id : t.id,
name : t.name,
logo : t.logo,
leader: t.leaderId, // Steam-ID des Leaders
createdAt: t.createdAt,
activePlayers : t.activePlayers
.map(id => byId[id])
.filter(Boolean) as Player[],
inactivePlayers: t.inactivePlayers
.map(id => byId[id])
.filter(Boolean) as Player[],
}))
return NextResponse.json({ teams: result })
} catch (err) { } catch (err) {
console.error('GET /api/team/list failed:', err) console.error('GET /api/team/list failed:', err)
return NextResponse.json( return NextResponse.json(
{ message: 'Interner Serverfehler' }, { message: 'Interner Serverfehler' },
{ status: 500 } { status: 500 },
) )
} }
} }

View File

@ -7,26 +7,26 @@ import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
/* ---- Session prüfen ------------------------------------------ */ /* ───────────────── Session prüfen ────────────────────── */
const session = await getServerSession(authOptions(req)) const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) { if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
} }
const requesterSteamId = session.user.steamId const requesterSteamId = session.user.steamId
/* ---- Body validieren ----------------------------------------- */ /* ───────────────── Body validieren ────────────────────── */
const { teamId } = await req.json() const { teamId } = await req.json()
if (!teamId) { if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 }) return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
} }
/* ---- Team holen ---------------------------------------------- */ /* ───────────────── Team holen ─────────────────────────── */
const team = await prisma.team.findUnique({ where: { id: teamId } }) const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) { if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 }) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
} }
/* ---- Bereits Mitglied? --------------------------------------- */ /* ───────────────── Bereits Mitglied? ──────────────────── */
if ( if (
requesterSteamId === team.leaderId || requesterSteamId === team.leaderId ||
team.activePlayers.includes(requesterSteamId) || team.activePlayers.includes(requesterSteamId) ||
@ -35,43 +35,47 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Du bist bereits Mitglied' }, { status: 400 }) return NextResponse.json({ message: 'Du bist bereits Mitglied' }, { status: 400 })
} }
/* ---- Doppelte Anfrage vermeiden ------------------------------ */ /* ───────────────── Doppelte Anfrage vermeiden ─────────── */
const existingInvite = await prisma.teamInvite.findFirst({ const existingInvite = await prisma.teamInvite.findFirst({
where: { steamId: requesterSteamId, teamId }, where: {
steamId: requesterSteamId,
teamId,
type : 'team-join-request',
},
}) })
if (existingInvite) { if (existingInvite) {
return NextResponse.json({ message: 'Anfrage läuft bereits' }, { status: 200 }) return NextResponse.json({ message: 'Anfrage läuft bereits' }, { status: 200 })
} }
/* ---- Invitation anlegen -------------------------------------- */ /* ───────────────── Invitation anlegen ─────────────────── */
await prisma.teamInvite.create({ const invitation = await prisma.teamInvite.create({
data: { data: {
steamId: requesterSteamId, // User.steamId steamId: requesterSteamId,
teamId, teamId ,
type: 'team-join-request', type : 'team-join-request',
}, },
}) })
/* ---- Leader benachrichtigen ---------------------------------- */ /* ───────────────── Leader benachrichtigen ─────────────── */
const notification = await prisma.notification.create({ const notification = await prisma.notification.create({
data: { data: {
steamId: team.leaderId!, steamId : team.leaderId!, // garantiert vorhanden
title: 'Beitrittsanfrage', title : 'Beitrittsanfrage',
message: `${session.user.name ?? 'Ein Spieler'} möchte deinem Team beitreten.`, message : `${session.user.name ?? 'Ein Spieler'} möchte deinem Team beitreten.`,
actionType: 'team-join-request', actionType: 'team-join-request',
actionData: teamId, actionData: invitation.id, // ← WICHTIG: invitationId
}, },
}) })
/* ---- SSE Event (optional) ------------------------------ */ /* ───────────────── SSE Event auslösen ─────────────────── */
await sendServerSSEMessage({ await sendServerSSEMessage({
type: notification.actionType ?? 'notification', type : notification.actionType ?? 'notification',
targetUserIds: [team.leaderId], targetUserIds: [team.leaderId],
message: notification.message, message : notification.message,
id: notification.id, id : notification.id,
actionType: notification.actionType ?? undefined, actionType : notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined, actionData : notification.actionData ?? undefined, // invitation.id
createdAt: notification.createdAt.toISOString(), createdAt : notification.createdAt.toISOString(),
}) })
return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 }) return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 })

View File

@ -61,7 +61,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
team: { team: {
id: team.id, id: team.id,
teamname: team.name, name: team.name,
logo: team.logo, logo: team.logo,
leader: team.leaderId, leader: team.leaderId,
activePlayers, activePlayers,

View File

@ -1,5 +1,4 @@
// /app/api/team/transfer-leader/route.ts // src/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'
@ -8,48 +7,112 @@ 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 })
} }
const team = await prisma.team.findUnique({ /* ────────────── Team holen ───────────────────── */
where: { id: teamId }, const team = await prisma.team.findUnique({ where: { id: teamId } })
})
if (!team) { if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 }) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
} }
const allPlayerIds = Array.from(new Set([ /* ────────────── Mitgliedschaft prüfen ────────── */
...(team.activePlayers || []), const allPlayerIds = Array.from(
...(team.inactivePlayers || []), new Set([
])) ...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
]),
)
if (!allPlayerIds.includes(newLeaderSteamId)) { if (!allPlayerIds.includes(newLeaderSteamId)) {
return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 }) return NextResponse.json({
message: 'Neuer Leader ist kein Teammitglied.',
}, { status: 400 })
} }
/* ────────────── Leader setzen ────────────────── */
await prisma.team.update({ await prisma.team.update({
where: { id: teamId }, where: { id: teamId },
data: { leader: newLeaderSteamId }, data : { leaderId: newLeaderSteamId },
}) })
/* ────────────── Namen des neuen Leaders ───────── */
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 leaderNote = await prisma.notification.create({
data: {
steamId : newLeaderSteamId,
title : 'Beförderung',
message : `Du bist jetzt Teamleader von "${team.name}".`,
actionType: 'team-leader-self',
actionData: teamId,
},
})
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-leader-changed', type : leaderNote.actionType ?? 'notification',
title: 'Neuer Teamleader', targetUserIds: [newLeaderSteamId],
message: `${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader.`, message : leaderNote.message,
teamId, id : leaderNote.id,
actionType : leaderNote.actionType ?? undefined,
actionData : leaderNote.actionData ?? undefined,
createdAt : leaderNote.createdAt.toISOString(),
})
/* ────────── 2) Info an alle anderen ───────────── */
const others: string[] = [
...allPlayerIds,
team.leaderId ?? undefined, // alter Leader (kann null sein)
]
/* Type-Guard: nur echte Strings behalten */
.filter((id): id is string => typeof id === 'string' && id !== newLeaderSteamId)
if (others.length) {
const text =
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
const notes = await Promise.all(
others.map(steamId =>
prisma.notification.create({
data: {
steamId,
title : 'Neuer Teamleader',
message: text,
actionType: 'team-leader-changed',
actionData: newLeaderSteamId,
},
}),
),
)
await sendServerSSEMessage({
type : 'team-leader-changed',
targetUserIds: others,
message : text,
id : notes[0].id, // eine Referenz-ID reicht
actionType : 'team-leader-changed',
actionData : newLeaderSteamId,
createdAt : notes[0].createdAt.toISOString(),
})
}
/* ── 3) Globales “team-updated” an ALLE ──────────────── */
await sendServerSSEMessage({
type : 'team-updated',
targetUserIds: allPlayerIds, targetUserIds: allPlayerIds,
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({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 }) return NextResponse.json({
message: 'Serverfehler beim Leaderwechsel.',
}, { status: 500 })
} }
} }

View File

@ -95,6 +95,12 @@ export async function POST(
actionData: notification.actionData ?? undefined, actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(), createdAt: notification.createdAt.toISOString(),
}) })
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: allSteamIds,
})
}) })
) )

View File

@ -0,0 +1,76 @@
// ───────────────────────────────────────────────────────────
// src/app/api/user/list/route.ts
// ───────────────────────────────────────────────────────────
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
// -----------------------------------------------------------
// POST { steamIds: string[] }
// -----------------------------------------------------------
export async function POST(req: NextRequest) {
try {
const { steamIds } = await req.json()
if (!Array.isArray(steamIds) || steamIds.length === 0) {
return NextResponse.json(
{ message: 'steamIds muss ein Array mit mindestens einer ID sein.' },
{ status: 400 }
)
}
const users = await prisma.user.findMany({
where: { steamId: { in: steamIds } },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
},
})
return NextResponse.json({ users }, { status: 200 })
} catch (err) {
console.error('POST /api/user/list failed:', err)
return NextResponse.json(
{ message: 'Interner Serverfehler' },
{ status: 500 }
)
}
}
// -----------------------------------------------------------
// GET /api/user/list?ids=ID1,ID2,ID3
// -----------------------------------------------------------
export async function GET(req: NextRequest) {
try {
const idsParam = req.nextUrl.searchParams.get('ids') ?? ''
const steamIds = idsParam.split(',').filter(Boolean)
if (steamIds.length === 0) {
return NextResponse.json(
{ message: 'Bitte mindestens eine ID in ids=… angeben.' },
{ status: 400 }
)
}
const users = await prisma.user.findMany({
where: { steamId: { in: steamIds } },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
},
})
return NextResponse.json({ users }, { status: 200 })
} catch (err) {
console.error('GET /api/user/list failed:', err)
return NextResponse.json(
{ message: 'Interner Serverfehler' },
{ status: 500 }
)
}
}

View File

@ -52,7 +52,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
} }
setShowModal(false) setShowModal(false)
setRefetchKey(Date.now().toString()) // 🔥 Neuer Key zum Reload setRefetchKey(Date.now().toString())
}, 1500) }, 1500)
} catch (err: any) { } catch (err: any) {
@ -94,6 +94,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
setStatus('idle') setStatus('idle')
setMessage('') setMessage('')
}} }}
placeholder="Gebe einen Teamnamen ein..."
className={`py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600 className={`py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600
${ ${
status === 'error' status === 'error'

View File

@ -2,6 +2,7 @@
import { useDroppable } from '@dnd-kit/core' import { useDroppable } from '@dnd-kit/core'
import { Player } from '../types/team' import { Player } from '../types/team'
import clsx from 'clsx'
type DroppableZoneProps = { type DroppableZoneProps = {
id: string id: string
@ -10,19 +11,40 @@ type DroppableZoneProps = {
activeDragItem: Player | null activeDragItem: Player | null
} }
export function DroppableZone({ id, label, children, activeDragItem }: DroppableZoneProps) { export function DroppableZone({
id,
label,
children,
}: DroppableZoneProps) {
const { isOver, setNodeRef } = useDroppable({ id }) const { isOver, setNodeRef } = useDroppable({ id })
const baseClasses = ` /* ───────────── sichtbare Zone ───────────── */
p-4 rounded-lg border-2 min-h-[200px] transition-all const zoneClasses = clsx(
${isOver ? 'border-blue-400 border-dashed bg-gray-200 dark:bg-neutral-800' : 'border-gray-300 dark:border-neutral-700'} // immer volle Zeilenbreite
` 'w-full rounded-lg p-4 transition-colors',
// Mindesthöhe einer MiniCard (damit sie bei leeren Teams nicht einklappt)
'min-h-[200px]',
isOver
? 'border-2 border-dashed border-blue-400 bg-blue-400/10'
: 'border border-gray-300 dark:border-neutral-700'
)
return ( return (
<div ref={setNodeRef} className={baseClasses}> <div className="space-y-2">
<h3 className="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">{label}</h3> <h3 className="text-md font-semibold text-gray-700 dark:text-gray-300">
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-4"> {label}
{children} </h3>
{/* Hier sitzt der Droppable-Ref */}
<div ref={setNodeRef} className={zoneClasses}>
<div
className="
grid gap-4 justify-start
[grid-template-columns:repeat(5,minmax(0,160px))]
"
>
{children}
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,177 +1,245 @@
/* ------------------------------------------------------------------
/app/components/EditMatchPlayersModal.tsx
zeigt ALLE Spieler des gewählten Teams & nutzt DroppableZone-IDs
"active" / "inactive" analog zur TeamMemberView.
------------------------------------------------------------------- */
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Modal from '@/app/components/Modal' import { useSession } from 'next-auth/react'
import MiniCard from '@/app/components/MiniCard' import {
import LoadingSpinner from '@/app/components/LoadingSpinner' DndContext, closestCenter, DragOverlay,
import { useSession } from 'next-auth/react' } from '@dnd-kit/core'
import { Player } from '@/app/types/team' import {
import { Team } from '@/app/types/team' SortableContext, verticalListSortingStrategy,
} from '@dnd-kit/sortable'
type Props = { import Modal from '@/app/components/Modal'
show: boolean import SortableMiniCard from '@/app/components/SortableMiniCard'
onClose: () => void import LoadingSpinner from '@/app/components/LoadingSpinner'
matchId: string import { DroppableZone } from '@/app/components/DroppableZone'
teamA: Team
teamB: Team import type { Player, Team } from '@/app/types/team'
initialPlayersA: string[]
initialPlayersB: string[] /* ───────────────────────── Typen ────────────────────────── */
export type EditSide = 'A' | 'B'
interface Props {
show : boolean
onClose : () => void
matchId : string
teamA : Team
teamB : Team
side : EditSide // welches Team wird editiert?
initialA: string[] // bereits eingesetzte Spieler-IDs
initialB: string[]
onSaved?: () => void onSaved?: () => void
} }
export default function EditMatchPlayersModal({ /* ───────────────────── Komponente ──────────────────────── */
show, export default function EditMatchPlayersModal (props: Props) {
onClose, const {
matchId, show, onClose, matchId,
teamA, teamA, teamB, side,
teamB, initialA, initialB,
initialPlayersA, onSaved,
initialPlayersB, } = props
onSaved,
}: Props) { /* ---- Rollen-Check --------------------------------------- */
const { data: session } = useSession() const { data: session } = useSession()
const [playersA, setPlayersA] = useState<Player[]>([]) const meSteam = session?.user?.steamId
const [playersB, setPlayersB] = useState<Player[]>([]) const isAdmin = session?.user?.isAdmin
const [selectedA, setSelectedA] = useState<string[]>([]) const isLeader = side === 'A'
const [selectedB, setSelectedB] = useState<string[]>([]) ? meSteam === teamA.leader
const [loading, setLoading] = useState(false) : meSteam === teamB.leader
const [saved, setSaved] = useState(false) const canEdit = isAdmin || isLeader
const steamId = session?.user?.steamId /* ---- States --------------------------------------------- */
const isLeaderA = steamId && teamA?.leader && steamId === teamA.leader const [players, setPlayers] = useState<Player[]>([])
const isLeaderB = steamId && teamB?.leader && steamId === teamB.leader const [selected, setSelected] = useState<string[]>([])
const isAdmin = session?.user?.isAdmin const [dragItem, setDragItem] = useState<Player | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const canEdit = isAdmin || isLeaderA || isLeaderB /* ---- Team-Info ------------------------------------------ */
const team = side === 'A' ? teamA : teamB
if (!teamA || !teamB) return <LoadingSpinner /> const other = side === 'A' ? teamB : teamA
const otherInit = side === 'A' ? initialB : initialA
const myInit = side === 'A' ? initialA : initialB
/* ---- Komplett-Spielerliste laden ------------------------ */
useEffect(() => { useEffect(() => {
if (show) { if (!show) return
fetchTeamPlayers() (async () => {
setSelectedA(initialPlayersA) try {
setSelectedB(initialPlayersB) const res = await fetch(`/api/team/${team.id}`)
setSaved(false) const data = await res.json()
}
}, [show])
const fetchTeamPlayers = async () => { /* ❶ aktive + inaktive Spieler zusammenführen */
try { const all = [
const [resA, resB] = await Promise.all([ ...(data.activePlayers as Player[] ?? []),
fetch(`/api/team/${teamA.id}`).then(res => res.json()), ...(data.inactivePlayers as Player[] ?? []),
fetch(`/api/team/${teamB.id}`).then(res => res.json()), ].filter((p, i, arr) => arr.findIndex(x => x.steamId === p.steamId) === i) // dedupe
])
setPlayersA(resA.activePlayers || []) setPlayers(all.sort((a, b) => a.name.localeCompare(b.name)))
setPlayersB(resB.activePlayers || []) setSelected(myInit) // übernommene Line-up
} catch (err) { setSaved(false)
console.error('Fehler beim Laden der Spieler:', err) } catch (e) {
} console.error('[EditMatchPlayersModal] load error:', e)
}
})()
}, [show, team.id, myInit])
/* ---- DragnDrop-Handler -------------------------------- */
const onDragStart = ({ active }: any) => {
setDragItem(players.find(p => p.steamId === active.id) ?? null)
} }
const toggleSelect = (team: 'A' | 'B', steamId: string) => { const onDragEnd = ({ active, over }: any) => {
if (team === 'A') { setDragItem(null)
setSelectedA(prev => if (!over) return
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
) const id = active.id as string
} else { const dropZone = over.id as string // "active" | "inactive"
setSelectedB(prev => const already = selected.includes(id)
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId] const toActive = dropZone === 'active'
)
} if ( (toActive && already) || (!toActive && !already) ) return
setSelected(sel =>
toActive
? [...sel, id].slice(0, 5) // max 5 einsatzfähig
: sel.filter(x => x !== id),
)
} }
/* ---- Speichern ------------------------------------------ */
const handleSave = async () => { const handleSave = async () => {
setLoading(true) setSaving(true)
try { try {
const players = [ const body = {
...selectedA.map(userId => ({ userId, teamId: teamA.id })), players: [
...selectedB.map(userId => ({ userId, teamId: teamB.id })), /* akt. Auswahl für die bearbeitete Seite */
] ...selected.map(steamId => ({ steamId, teamId: team.id })),
/* unveränderte Gegenseite unbedingt mitschicken! */
...otherInit.map(steamId => ({ steamId, teamId: other.id })),
],
}
const res = await fetch(`/api/matches/${matchId}`, { const res = await fetch(`/api/matches/${matchId}`, {
method: 'PUT', method : 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ players }), body : JSON.stringify(body),
}) })
if (!res.ok) throw new Error('Fehler beim Speichern') if (!res.ok) throw new Error()
setSaved(true) setSaved(true)
onSaved?.() onSaved?.()
} catch (err) { } catch (e) {
console.error('Speichern fehlgeschlagen:', err) console.error('[EditMatchPlayersModal] save error:', e)
} finally { } finally {
setLoading(false) setSaving(false)
} }
} }
/* ---- Listen trennen ------------------------------------- */
const active = players.filter(p => selected.includes(p.steamId))
const inactive = players.filter(p => !selected.includes(p.steamId))
/* ---- UI -------------------------------------------------- */
if (!show) return null
return ( return (
<Modal <Modal
id="edit-match-players-modal" id="edit-match-players"
title="Spieler bearbeiten" title={`Spieler bearbeiten ${team.name ?? 'Team'}`}
show={show} show
onClose={onClose} onClose={onClose}
onSave={handleSave} onSave={handleSave}
closeButtonTitle={saved ? '✓ gespeichert' : 'Speichern'} closeButtonTitle={
saved ? '✓ gespeichert' : saving ? 'Speichern …' : 'Speichern'
}
closeButtonColor={saved ? 'green' : 'blue'} closeButtonColor={saved ? 'green' : 'blue'}
disableSave={!canEdit || saving}
maxWidth='sm:max-w-2xl'
> >
{!canEdit ? ( {!canEdit && (
<p className="text-sm text-gray-700 dark:text-neutral-300"> <p className="text-sm text-gray-700 dark:text-neutral-300">
Du bist kein Teamleiter dieses Matches. Du darfst dieses Team nicht bearbeiten.
</p> </p>
) : ( )}
<>
{saved && (
<div className="mb-4 text-green-700 bg-green-100 border border-green-200 rounded px-4 py-2 text-sm">
Änderungen gespeichert
</div>
)}
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="font-semibold mb-2">{teamA.teamname}</h3>
{playersA.length === 0 ? (
<LoadingSpinner />
) : (
<div className="space-y-2">
{playersA.map((p) => (
<MiniCard
key={p.steamId}
title={p.name}
avatar={p.avatar}
steamId={p.steamId}
location={p.location}
selected={selectedA.includes(p.steamId)}
onSelect={() => toggleSelect('A', p.steamId)}
currentUserSteamId={steamId!}
teamLeaderSteamId={teamA.leader}
hideActions
/>
))}
</div>
)}
</div>
<div> {canEdit && (
<h3 className="font-semibold mb-2">{teamB.teamname}</h3> <>
{playersB.length === 0 ? ( {players.length === 0 && <LoadingSpinner />}
<LoadingSpinner />
) : ( {players.length > 0 && (
<div className="space-y-2"> <DndContext
{playersB.map((p) => ( collisionDetection={closestCenter}
<MiniCard onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{/* --- Zone: Aktuell eingestellte Spieler ------------- */}
<DroppableZone
id="active"
label={`Eingesetzte Spieler (${active.length} / 5)`}
activeDragItem={dragItem}
>
<SortableContext
items={active.map(p => p.steamId)}
strategy={verticalListSortingStrategy}
>
{active.map(p => (
<SortableMiniCard
key={p.steamId} key={p.steamId}
title={p.name} player={p}
avatar={p.avatar} currentUserSteamId={meSteam ?? ''}
steamId={p.steamId} teamLeaderSteamId={team.leader}
location={p.location} isAdmin={!!session?.user?.isAdmin}
selected={selectedB.includes(p.steamId)} hideOverlay
onSelect={() => toggleSelect('B', p.steamId)}
currentUserSteamId={steamId!}
teamLeaderSteamId={teamB.leader}
hideActions
/> />
))} ))}
</div> </SortableContext>
)} </DroppableZone>
</div>
</div> {/* --- Zone: Verfügbar (restliche) ------------------- */}
<DroppableZone
id="inactive"
label="Verfügbare Spieler"
activeDragItem={dragItem}
>
<SortableContext
items={inactive.map(p => p.steamId)}
strategy={verticalListSortingStrategy}
>
{inactive.map(p => (
<SortableMiniCard
key={p.steamId}
player={p}
currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader}
isAdmin={!!session?.user?.isAdmin}
hideOverlay
/>
))}
</SortableContext>
</DroppableZone>
{/* Drag-Overlay */}
<DragOverlay>
{dragItem && (
<SortableMiniCard
player={dragItem}
currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader}
isAdmin={!!session?.user?.isAdmin}
hideOverlay
/>
)}
</DragOverlay>
</DndContext>
)}
</> </>
)} )}
</Modal> </Modal>

View File

@ -14,9 +14,10 @@ type Props = {
onClose: () => void onClose: () => void
onSuccess: () => void onSuccess: () => void
team: Team team: Team
directAdd?: boolean
} }
export default function InvitePlayersModal({ show, onClose, onSuccess, team }: Props) { export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const steamId = session?.user?.steamId const steamId = session?.user?.steamId
@ -63,14 +64,16 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
if (selectedIds.length === 0 || !steamId) return if (selectedIds.length === 0 || !steamId) return
try { try {
const res = await fetch('/api/team/invite', { const url = directAdd ? '/api/team/add-players'
: '/api/team/invite'
const body = directAdd
? { teamId: team.id, steamIds: selectedIds }
: { teamId: team.id, userIds: selectedIds, invitedBy: steamId }
const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(body),
teamId: team.id,
userIds: selectedIds,
invitedBy: steamId,
}),
}) })
@ -124,15 +127,21 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
return ( return (
<Modal <Modal
id="invite-members-modal" id="invite-members-modal"
title="Mitglieder einladen" title={directAdd ? 'Mitglieder hinzufügen' : 'Mitglieder einladen'}
show={show} show={show}
onClose={onClose} onClose={onClose}
onSave={handleInvite} onSave={handleInvite}
closeButtonColor={isSuccess ? "teal" : "blue"} closeButtonColor={isSuccess ? "teal" : "blue"}
closeButtonTitle={isSuccess ? "Einladungen versendet" : "Einladungen senden"} closeButtonTitle={
isSuccess
? directAdd ? 'Mitglieder hinzugefügt' : 'Einladungen versendet'
: directAdd ? 'Hinzufügen' : 'Einladungen senden'
}
> >
<p className="text-sm text-gray-700 dark:text-neutral-300 mb-2"> <p className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
Wähle Benutzer aus, die du in dein Team einladen möchtest: {directAdd
? 'Wähle Benutzer aus, die du direkt zum Team hinzufügen möchtest:'
: 'Wähle Benutzer aus, die du in dein Team einladen möchtest:'}
</p> </p>
{/* Ausgewählte Benutzer anzeigen */} {/* Ausgewählte Benutzer anzeigen */}
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
@ -184,7 +193,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
/> />
{isSuccess && ( {isSuccess && (
<div className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg"> <div className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg">
{sentCount} Einladung{sentCount !== 1 ? 'en' : ''} erfolgreich versendet! {directAdd
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`}
</div> </div>
)} )}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
@ -192,9 +203,11 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
<LoadingSpinner /> <LoadingSpinner />
) : filteredUsers.length === 0 ? ( ) : filteredUsers.length === 0 ? (
<div className="col-span-full text-center text-gray-500 dark:text-neutral-400"> <div className="col-span-full text-center text-gray-500 dark:text-neutral-400">
{allUsers.length === 0 {allUsers.length === 0
? 'Niemand zum Einladen verfügbar :(' ? directAdd
: 'Keine Benutzer gefunden.'} ? 'Keine Benutzer verfügbar :('
: 'Niemand zum Einladen verfügbar :('
: 'Keine Benutzer gefunden.'}
</div> </div>
) : ( ) : (
<> <>

View File

@ -41,12 +41,12 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
const success = await leaveTeam(steamId, team.leader === steamId ? newLeaderId : undefined) const success = await leaveTeam(steamId, team.leader === steamId ? newLeaderId : undefined)
if (success) { if (success) {
onSuccess() onSuccess()
onClose()
} }
} catch (err) { } catch (err) {
console.error('Fehler beim Verlassen:', err) console.error('Fehler beim Verlassen:', err)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
onClose()
} }
} }
@ -64,7 +64,10 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
Du bist der Teamleader. Bitte wähle ein anderes Mitglied aus, das die Rolle des Leaders übernehmen soll: Du bist der Teamleader. Bitte wähle ein anderes Mitglied aus, das die Rolle des Leaders übernehmen soll:
</p> </p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{(team.players ?? []) {[
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
]
.filter((player) => player.steamId !== steamId) .filter((player) => player.steamId !== steamId)
.map((player: Player) => ( .map((player: Player) => (
<MiniCard <MiniCard

View File

@ -1,120 +1,128 @@
// /app/components/MatchDetails.tsx /*
/app/components/MatchDetails.tsx
Zeigt pro Team einen eigenen Spieler bearbeiten-Button und öffnet
das Modal nur für das angeklickte Team.
*/
'use client' 'use client'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { useParams } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Match, MatchPlayer } from '../types/match' import { useSession } from 'next-auth/react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { de } from 'date-fns/locale' import { de } from 'date-fns/locale'
import Table from './Table'
import { useRouter } from 'next/navigation';
import PremierRankBadge from './PremierRankBadge'
import CompRankBadge from './CompRankBadge'
interface MatchDetailsProps { import Table from './Table'
match: Match import PremierRankBadge from './PremierRankBadge'
} import CompRankBadge from './CompRankBadge'
import EditMatchPlayersModal from './EditMatchPlayersModal'
import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
function calcKDR(kills?: number, deaths?: number): string { import type { Match, MatchPlayer } from '../types/match'
if (typeof kills !== 'number' || typeof deaths !== 'number') return '-';
if (deaths === 0) return '∞';
return (kills / deaths).toFixed(2);
}
function calcADR(totalDamage?: number, roundCount?: number): string { /* ─────────────────── Hilfsfunktionen ────────────────────────── */
if (typeof totalDamage !== 'number' || typeof roundCount !== 'number' || roundCount === 0) { const kdr = (k?: number, d?: number) =>
return '-'; typeof k === 'number' && typeof d === 'number'
} ? d === 0 ? '∞' : (k / d).toFixed(2)
return (totalDamage / roundCount).toFixed(1); : '-'
}
export function MatchDetails({ match }: MatchDetailsProps) { const adr = (dmg?: number, rounds?: number) =>
const router = useRouter(); typeof dmg === 'number' && typeof rounds === 'number' && rounds > 0
? (dmg / rounds).toFixed(1)
: '-'
const matchDate = match.demoDate /* ─────────────────── Komponente ─────────────────────────────── */
? format(new Date(match.demoDate), 'PPpp', { locale: de }) export function MatchDetails ({ match }: { match: Match }) {
: 'Unbekannt' const { data: session } = useSession()
const router = useRouter()
const renderPlayerTable = (players: MatchPlayer[]) => { /* ─── Rollen & Rechte ─────────────────────────────────────── */
const me = session?.user
const userId = me?.steamId
const isAdmin = me?.isAdmin
const isLeaderA = !!userId && userId === match.teamA?.leader
const isLeaderB = !!userId && userId === match.teamB?.leader
const canEditA = isAdmin || isLeaderA
const canEditB = isAdmin || isLeaderB
const sortedPlayers = [...players].sort((a, b) => { /* ─── Match-Zeitpunkt ─────────────────────────────────────── */
const dmgA = a.stats?.totalDamage ?? 0; const dateString = match.matchDate ?? match.demoDate
const dmgB = b.stats?.totalDamage ?? 0; const isFutureMatch = !!dateString && new Date(dateString).getTime() > Date.now()
return dmgB - dmgA;
});
console.log(match); /* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
const [editSide, setEditSide] = useState<EditSide | null>(null)
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
const ColGroup = () => (
<colgroup>
<col style={{ width: '24%' }} />
<col style={{ width: '8%' }} />
{Array.from({ length: 12 }).map((_, i) => (
<col key={i} style={{ width: '5.666%' }} />
))}
</colgroup>
)
/* ─── Spieler-Tabelle ─────────────────────────────────────── */
const renderTable = (players: MatchPlayer[]) => {
const sorted = [...players].sort(
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
)
return ( return (
<Table> <Table>
<ColGroup />
<Table.Head> <Table.Head>
<Table.Row> <Table.Row>
<Table.Cell as='th'>Spieler</Table.Cell> {['Spieler','Rank','K','A','D','1K','2K','3K','4K','5K',
<Table.Cell as='th'>Rank</Table.Cell> 'K/D','ADR','HS%','Damage'].map(h => (
<Table.Cell as='th'>K</Table.Cell> <Table.Cell key={h} as="th">{h}</Table.Cell>
<Table.Cell as='th'>A</Table.Cell> ))}
<Table.Cell as='th'>D</Table.Cell>
<Table.Cell as='th'>1K</Table.Cell>
<Table.Cell as='th'>2K</Table.Cell>
<Table.Cell as='th'>3K</Table.Cell>
<Table.Cell as='th'>4K</Table.Cell>
<Table.Cell as='th'>5K</Table.Cell>
<Table.Cell as='th'>K/D</Table.Cell>
<Table.Cell as='th'>ADR</Table.Cell>
<Table.Cell as='th'>HS%</Table.Cell>
<Table.Cell as='th'>Damage</Table.Cell>
</Table.Row> </Table.Row>
</Table.Head> </Table.Head>
<Table.Body> <Table.Body>
{sortedPlayers.map((p: MatchPlayer, i) => ( {sorted.map(p => (
<Table.Row <Table.Row
key={i} key={p.user.steamId}
hoverable hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)} onClick={() => router.push(`/profile/${p.user.steamId}`)}
> >
<Table.Cell className="py-1 flex items-center gap-2"> <Table.Cell className="py-1 flex items-center gap-2">
{( <img
<img src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'} alt={p.user.name}
alt={p.user.name} className="w-8 h-8 rounded-full"
className="w-8 h-8 rounded-full" />
/>
)}
{p.user.name ?? 'Unbekannt'} {p.user.name ?? 'Unbekannt'}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<div className="flex items-center gap-[6px]"> <div className="flex items-center gap-[6px]">
{match.matchType === 'premier' ? ( {match.matchType === 'premier'
<PremierRankBadge rank={p.stats?.rankNew ?? 0} /> ? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
) : ( : <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
<CompRankBadge rank={p.stats?.rankNew ?? 0} /> {match.matchType === 'premier' &&
)} typeof p.stats?.rankChange === 'number' && (
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && ( <span className={`text-sm ${
<span p.stats.rankChange > 0 ? 'text-green-500'
className={`text-sm ${ : p.stats.rankChange < 0 ? 'text-red-500' : ''}`}>
p.stats?.rankChange > 0 {p.stats.rankChange > 0 ? '+' : ''}
? 'text-green-500' {p.stats.rankChange}
: p.stats?.rankChange < 0 </span>
? 'text-red-500' )}
: ''
}`}
>
{p.stats?.rankChange > 0 ? '+' : ''}
{p.stats?.rankChange}
</span>
)}
</div> </div>
</Table.Cell> </Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k5 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell>
<Table.Cell>{calcKDR(p.stats?.kills, p.stats?.deaths)}</Table.Cell> <Table.Cell>{p.stats?.k5 ?? '-'}</Table.Cell>
<Table.Cell>{calcADR(p.stats?.totalDamage, match.roundCount)}</Table.Cell> <Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell> <Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
</Table.Row> </Table.Row>
@ -124,13 +132,19 @@ export function MatchDetails({ match }: MatchDetailsProps) {
) )
} }
/* ─── Ausgabe-Datum ───────────────────────────────────────── */
const readableDate = dateString
? format(new Date(dateString), 'PPpp', { locale: de })
: 'Unbekannt'
/* ─── Render ─────────────────────────────────────────────── */
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold">
Match auf {match.map} ({match.matchType}) Match auf {match.map} ({match.matchType})
</h1> </h1>
<p className="text-sm text-gray-500">Datum: {matchDate}</p> <p className="text-sm text-gray-500">Datum: {readableDate}</p>
<div className="text-md"> <div className="text-md">
<strong>Teams:</strong>{' '} <strong>Teams:</strong>{' '}
@ -141,21 +155,65 @@ export function MatchDetails({ match }: MatchDetailsProps) {
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0} <strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div> </div>
<div className="border-t pt-4 mt-4 space-y-6"> {/* ───────── Team-Blöcke ───────── */}
<div className="border-t pt-4 mt-4 space-y-10">
{/* Team A */}
<div> <div>
<h2 className="text-xl font-semibold mb-2"> <div className="flex items-center justify-between mb-2">
{match.teamA?.name ?? 'Team A'} <h2 className="text-xl font-semibold">
</h2> {match.teamA?.name ?? 'Team A'}
{renderPlayerTable(match.teamA.players)} </h2>
{canEditA && isFutureMatch && (
<button
onClick={() => setEditSide('A')}
className="px-3 py-1.5 text-sm rounded-lg
bg-blue-600 hover:bg-blue-700 text-white"
>
Spieler bearbeiten
</button>
)}
</div>
{renderTable(match.teamA.players)}
</div> </div>
{/* Team B */}
<div> <div>
<h2 className="text-xl font-semibold mb-2"> <div className="flex items-center justify-between mb-2">
{match.teamB?.name ?? 'Team B'} <h2 className="text-xl font-semibold">
</h2> {match.teamB?.name ?? 'Team B'}
{renderPlayerTable(match.teamB.players)} </h2>
{canEditB && isFutureMatch && (
<button
onClick={() => setEditSide('B')}
className="px-3 py-1.5 text-sm rounded-lg
bg-blue-600 hover:bg-blue-700 text-white"
>
Spieler bearbeiten
</button>
)}
</div>
{renderTable(match.teamB.players)}
</div> </div>
</div> </div>
{/* ───────── Modal ───────── */}
{editSide && (
<EditMatchPlayersModal
show
onClose={() => setEditSide(null)}
matchId={match.id}
teamA={match.teamA}
teamB={match.teamB}
side={editSide}
initialA={match.teamA.players.map(p => p.user.steamId)}
initialB={match.teamB.players.map(p => p.user.steamId)}
onSaved={() => window.location.reload()}
/>
)}
</div> </div>
) )
} }

View File

@ -14,7 +14,7 @@ type MiniCardProps = {
isLeader?: boolean isLeader?: boolean
draggable?: boolean draggable?: boolean
currentUserSteamId: string currentUserSteamId: string
teamLeaderSteamId: string teamLeaderSteamId?: string | null
location?: string location?: string
rank?: number rank?: number
dragListeners?: any dragListeners?: any
@ -22,6 +22,8 @@ type MiniCardProps = {
onPromote?: (steamId: string) => void onPromote?: (steamId: string) => void
hideActions?: boolean hideActions?: boolean
hideOverlay?: boolean hideOverlay?: boolean
isSelectable?: boolean
isAdmin?: boolean
} }
export default function MiniCard({ export default function MiniCard({
@ -42,13 +44,15 @@ export default function MiniCard({
onPromote, onPromote,
hideActions = false, hideActions = false,
hideOverlay = false, hideOverlay = false,
isSelectable = true,
isAdmin = false,
}: MiniCardProps) { }: MiniCardProps) {
const isSelectable = typeof onSelect === 'function' //const isSelectable = typeof onSelect === 'function'
const canKick = currentUserSteamId === teamLeaderSteamId && steamId !== teamLeaderSteamId const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
const cardClasses = ` const cardClasses = `
relative flex flex-col items-center p-4 border rounded-lg transition relative flex flex-col items-center p-4 border rounded-lg transition
max-h-[200px] w-full overflow-hidden max-h-[200px] max-w-[160px] overflow-hidden
bg-white dark:bg-neutral-800 border shadow-2xs rounded-xl bg-white dark:bg-neutral-800 border shadow-2xs rounded-xl
${selected ? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400' : 'border-gray-200 dark:border-neutral-700'} ${selected ? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400' : 'border-gray-200 dark:border-neutral-700'}
${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''} ${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''}
@ -62,10 +66,14 @@ export default function MiniCard({
} }
const handleKickClick = (e: React.MouseEvent) => { const handleKickClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onKick?.(steamId) onKick?.(steamId)
} }
const handlePromoteClick = (e: React.MouseEvent) => { const handlePromoteClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onPromote?.(steamId) onPromote?.(steamId)
} }
@ -75,7 +83,7 @@ export default function MiniCard({
return ( return (
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}> <div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
{canKick && !hideActions && !hideOverlay && ( {canEdit && !hideActions && !hideOverlay && (
<div className={`absolute inset-0 bg-white dark:bg-black bg-opacity-50 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${ <div className={`absolute inset-0 bg-white dark:bg-black bg-opacity-50 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${
hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100' hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'
}`}> }`}>

View File

@ -9,7 +9,7 @@ export default function MiniCardDummy({ title, onClick, children }: MiniCardDumm
<div <div
onClick={onClick} onClick={onClick}
className={` className={`
relative flex flex-col items-center p-4 border border-dashed rounded-lg transition relative flex flex-col h-full max-h-[200px] items-center p-4 border border-dashed rounded-lg transition
hover:border-blue-400 dark:hover:border-blue-400 hover:cursor-pointer hover:border-blue-400 dark:hover:border-blue-400 hover:cursor-pointer
border-gray-300 dark:border-neutral-700 border-gray-300 dark:border-neutral-700
`} `}

View File

@ -2,6 +2,14 @@
import { useEffect } from 'react' import { useEffect } from 'react'
type Width =
| 'sm:max-w-sm'
| 'sm:max-w-md'
| 'sm:max-w-lg'
| 'sm:max-w-xl'
| 'sm:max-w-2xl'
| string
type ModalProps = { type ModalProps = {
id: string id: string
title: string title: string
@ -10,8 +18,10 @@ type ModalProps = {
onClose?: () => void onClose?: () => void
onSave?: () => void onSave?: () => void
hideCloseButton?: boolean hideCloseButton?: boolean
closeButtonColor?: string closeButtonColor?: 'blue' | 'red' | 'green' | 'teal'
closeButtonTitle?: string closeButtonTitle?: string
disableSave?: boolean
maxWidth?: Width
} }
export default function Modal({ export default function Modal({
@ -22,132 +32,136 @@ export default function Modal({
onClose, onClose,
onSave, onSave,
hideCloseButton = false, hideCloseButton = false,
closeButtonColor = "blue", closeButtonColor = 'blue',
closeButtonTitle = "Speichern" closeButtonTitle = 'Speichern',
disableSave,
maxWidth = 'sm:max-w-lg',
}: ModalProps) { }: ModalProps) {
/* ───────── Overlay-Lifecycle ───────── */
useEffect(() => { useEffect(() => {
const modalEl = document.getElementById(id); const modalEl = document.getElementById(id)
const hs = (window as any).HSOverlay; const hs = (window as any).HSOverlay
if (!modalEl || !hs) return
const handleClose = () => { /* Collection kann undefined oder ein Objekt sein.
if (typeof onClose === 'function') { Wir sichern uns ab und behandeln nur echte Arrays. */
onClose(); const getCollection = (): any[] =>
Array.isArray(hs.collection) ? hs.collection : []
const destroyIfExists = () => {
const inst = getCollection().find((i) => i.element === modalEl)
inst?.destroy?.()
if (inst) {
hs.collection = getCollection().filter((i) => i !== inst)
} }
}; }
modalEl?.addEventListener('hsOverlay:close', handleClose); const handleClose = () => onClose?.()
modalEl.addEventListener('hsOverlay:close', handleClose)
const tryOpen = () => { try {
try { if (show) {
if (typeof hs?.autoInit === 'function') { destroyIfExists()
hs.autoInit(); hs.autoInit?.()
} hs.open?.(modalEl)
} else {
if (modalEl && typeof hs?.open === 'function') { hs.close?.(modalEl)
hs.open(modalEl); destroyIfExists()
}
} catch (err) {
console.error('[Modal] Fehler beim Öffnen des Modals:', err);
} }
}; } catch (err) {
// eslint-disable-next-line no-console
const tryClose = () => { console.error('[Modal] HSOverlay Fehler:', err)
try {
if (modalEl && typeof hs?.close === 'function') {
const isInCollection = hs?.collection?.find?.((item: any) => item.element === modalEl);
if (isInCollection) {
hs.close(modalEl);
}
}
} catch (err) {
console.error('[Modal] Fehler beim Schließen des Modals:', err);
}
};
if (show) {
tryOpen();
} else {
tryClose();
} }
return () => { return () => {
modalEl?.removeEventListener('hsOverlay:close', handleClose); modalEl.removeEventListener('hsOverlay:close', handleClose)
}; destroyIfExists()
}, [show, id]); }
}, [show, id, onClose])
/* ───────── Render ───────── */
return ( return (
<div <div
id={id} id={id}
data-hs-overlay="true" data-hs-overlay="true"
className="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none"
role="dialog" role="dialog"
tabIndex={-1} tabIndex={-1}
aria-labelledby={`${id}-label`} aria-labelledby={`${id}-label`}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose?.()
}}
className="hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden"
> >
<div className="fixed inset-0 z-[-1] bg-black bg-opacity-50 dark:bg-neutral-900/70 hs-overlay-backdrop"> {/* Backdrop */}
<div className="hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500 mt-0 opacity-0 ease-out transition-all sm:max-w-lg sm:w-full m-3 sm:mx-auto min-h-[calc(100%-56px)] flex items-center"> <div className="fixed inset-0 -z-10 bg-black/50 dark:bg-neutral-900/70" />
<div className="w-full flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl pointer-events-auto dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70">
<div className="flex justify-between items-center py-3 px-4 border-b border-gray-200 dark:border-neutral-700">
<h3 id={`${id}-label`} className="font-bold text-gray-800 dark:text-white">
{title}
</h3>
{!hideCloseButton && (
<button
type="button"
onClick={onClose}
className="size-8 inline-flex justify-center items-center gap-x-2 rounded-full border border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 focus:outline-hidden focus:bg-gray-200 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-400 dark:focus:bg-neutral-600"
aria-label="Close"
data-hs-overlay={`#${id}`}
>
<span className="sr-only">Schließen</span>
<svg
className="shrink-0 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="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
<div className="p-4 overflow-visible"> {/* Dialog */}
{children} <div className={`hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500
</div> mt-0 opacity-0 transition-all ease-out
${maxWidth} sm:w-full m-3 sm:mx-auto
min-h-[calc(100%-56px)] flex items-center`}
>
<div className="w-full flex flex-col bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 shadow-2xs dark:shadow-neutral-700/70 rounded-xl">
{/* Header */}
<div className="flex justify-between items-center py-3 px-4 border-b border-gray-200 dark:border-neutral-700">
<h3 id={`${id}-label`} className="font-bold text-gray-800 dark:text-white">
{title}
</h3>
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700"> {!hideCloseButton && (
{!hideCloseButton && ( <button
<button type="button"
type="button" aria-label="Close"
onClick={onClose} data-hs-overlay={`#${id}`}
data-hs-overlay={`#${id}`} onClick={onClose}
className="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700" className="size-8 inline-flex justify-center items-center rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-gray-800 dark:text-neutral-400"
>
<svg
className="size-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
> >
Schließen <path d="M18 6 6 18" />
</button> <path d="m6 6 12 12" />
)} </svg>
{onSave && ( </button>
<button )}
type="button" </div>
onClick={onSave}
className={`py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 text-white hover:bg-${closeButtonColor}-700 focus:outline-hidden focus:bg-${closeButtonColor}-700 disabled:opacity-50 disabled:pointer-events-none`} {/* Body */}
> <div className="p-4 overflow-visible">{children}</div>
{closeButtonTitle}
</button> {/* Footer */}
)} <div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
</div> {!hideCloseButton && (
<button
type="button"
data-hs-overlay={`#${id}`}
onClick={onClose}
className="py-2 px-3 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-700"
>
Schließen
</button>
)}
{onSave && (
<button
type="button"
onClick={onSave}
disabled={disableSave}
className={`py-2 px-3 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 hover:bg-${closeButtonColor}-700 focus:bg-${closeButtonColor}-700 text-white`}
>
{closeButtonTitle}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@ -20,7 +20,7 @@ export default function Navbar({ children }: { children?: React.ReactNode }) {
</div> </div>
</div> </div>
<div id="hs-navbar-to-overlay" className="hs-overlay hs-overlay-open:translate-x-0 [--auto-close:sm] -translate-x-full fixed top-0 start-0 transition-all duration-300 transform h-full w-full sm:w-96 z-60 bg-white border-e sm:static sm:block sm:h-auto sm:w-full sm:border-e-transparent sm:transition-none sm:transform-none sm:translate-x-0 sm:z-40 dark:bg-neutral-800 sm:dark:bg-neutral-900 dark:border-e-neutral-700 sm:dark:border-e-transparent hidden" role="dialog" tabindex="-1" aria-label="Sidebar" data-hs-overlay-close-on-resize> <div id="hs-navbar-to-overlay" className="hs-overlay hs-overlay-open:translate-x-0 [--auto-close:sm] -translate-x-full fixed top-0 start-0 transition-all duration-300 transform h-full w-full sm:w-96 z-60 bg-white border-e sm:static sm:block sm:h-auto sm:w-full sm:border-e-transparent sm:transition-none sm:transform-none sm:translate-x-0 sm:z-40 dark:bg-neutral-800 sm:dark:bg-neutral-900 dark:border-e-neutral-700 sm:dark:border-e-transparent hidden" role="dialog" tabIndex={-1} aria-label="Sidebar" data-hs-overlay-close-on-resize>
<div className="overflow-hidden overflow-y-auto h-full [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500"> <div className="overflow-hidden overflow-y-auto h-full [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500">
<div className="flex flex-col gap-y-3 sm:gap-y-0 sm:flex-row sm:items-center sm:justify-end p-2 sm:p-0"> <div className="flex flex-col gap-y-3 sm:gap-y-0 sm:flex-row sm:items-center sm:justify-end p-2 sm:p-0">
<div className="py-3 sm:hidden flex justify-between items-center border-b border-gray-200 dark:border-neutral-700"> <div className="py-3 sm:hidden flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">

View File

@ -62,6 +62,7 @@ export default function NoTeamView() {
currentUserSteamId={session?.user?.steamId || ''} currentUserSteamId={session?.user?.steamId || ''}
invitationId={teamToInvitationId[team.id]} invitationId={teamToInvitationId[team.id]}
onUpdateInvitation={updateInvitationMap} onUpdateInvitation={updateInvitationMap}
adminMode={false}
/> />
))} ))}
</div> </div>

View File

@ -7,6 +7,9 @@ import { useSession } from 'next-auth/react'
import { useTeamManager } from '../hooks/useTeamManager' import { useTeamManager } from '../hooks/useTeamManager'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
/* ────────────────────────────────────────────────────────── */
/* Typen */
/* ────────────────────────────────────────────────────────── */
type Notification = { type Notification = {
id: string id: string
text: string text: string
@ -16,8 +19,11 @@ type Notification = {
createdAt?: string createdAt?: string
} }
/* ────────────────────────────────────────────────────────── */
/* Komponente */
/* ────────────────────────────────────────────────────────── */
export default function NotificationCenter() { export default function NotificationCenter() {
/* --- Hooks & States ------------------------------------ */
const { data: session } = useSession() const { data: session } = useSession()
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -28,20 +34,18 @@ export default function NotificationCenter() {
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 ---------- */
const onNotificationClick = (notification: Notification) => { const onNotificationClick = (notification: Notification) => {
if (!notification.actionData) return; if (!notification.actionData) return
try { try {
const data = JSON.parse(notification.actionData); const data = JSON.parse(notification.actionData)
console.error('Weiterleitung: ', notification.actionData); if (data.redirectUrl) router.push(data.redirectUrl)
if (data.redirectUrl) {
router.push(data.redirectUrl);
}
} catch (err) { } catch (err) {
console.error('Ungültige actionData:', 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
@ -52,12 +56,12 @@ export default function NotificationCenter() {
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 = data.notifications.map((n: any) => ({
id: n.id, id : n.id,
text: n.message, text : n.message,
read: n.read, read : n.read,
actionType: n.actionType, actionType: n.actionType,
actionData: n.actionData, actionData: n.actionData,
createdAt: n.createdAt, createdAt : n.createdAt,
})) }))
setNotifications(loaded) setNotifications(loaded)
} catch (err) { } catch (err) {
@ -66,44 +70,33 @@ export default function NotificationCenter() {
} }
loadNotifications() loadNotifications()
connect(steamId) connect(steamId) // SSE starten
}, [session?.user?.steamId, connect]) }, [session?.user?.steamId, connect])
/* --- Live-Updates über SSE empfangen -------------------- */
useEffect(() => { useEffect(() => {
if (!source) return if (!source) return
/* Handler für JEDES eintreffende Paket ------------------ */
const handleEvent = (event: MessageEvent) => { const handleEvent = (event: MessageEvent) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.type === 'heartbeat') return if (data.type === 'heartbeat') return // Ping ignorieren
const isNotificationType = [
'notification',
'invitation',
'team-invite',
'team-joined',
'team-member-joined',
'team-kick',
'team-kick-other',
'team-left',
'team-member-left',
'team-leader-changed',
'team-join-request',
'expired-sharecode'
].includes(data.type)
if (!isNotificationType) return
/* Neues Notification-Objekt erzeugen */
const newNotification: Notification = { const newNotification: Notification = {
id: data.id, 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, createdAt : data.createdAt ?? new Date().toISOString(),
} }
/* State updaten (immer oben einsortieren) */
setNotifications(prev => [newNotification, ...prev]) setNotifications(prev => [newNotification, ...prev])
/* Glocke & Vorschau animieren ---------------------- */
setPreviewText(newNotification.text) setPreviewText(newNotification.text)
setShowPreview(true) setShowPreview(true)
setAnimateBell(true) setAnimateBell(true)
@ -114,16 +107,46 @@ export default function NotificationCenter() {
setAnimateBell(false) setAnimateBell(false)
}, 3000) }, 3000)
} catch (err) { } catch (err) {
console.error('[SSE] Ungültige Nachricht:', event) console.error('[SSE] Ungültige Nachricht:', event.data, err)
} }
} }
source.addEventListener('notification', handleEvent) /* Liste aller Event-Namen, die der Server schicken kann */
return () => source.removeEventListener('notification', handleEvent) const eventNames = [
'notification',
'invitation',
'team-invite',
'team-joined',
'team-member-joined',
'team-kick',
'team-kick-other',
'team-left',
'team-member-left',
'team-leader-changed',
'team-leader-self',
'team-join-request',
'expired-sharecode',
]
/* Named Events abonnieren ------------------------------ */
eventNames.forEach(evt => source.addEventListener(evt, handleEvent))
/* Fallback: Server sendet evtl. Events ohne „event:“----- */
source.onmessage = handleEvent
/* Aufräumen bei Unmount -------------------------------- */
return () => {
eventNames.forEach(evt => source.removeEventListener(evt, handleEvent))
source.onmessage = null
}
}, [source]) }, [source])
/* ────────────────────────────────────────────────────────── */
/* 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)}
@ -132,14 +155,14 @@ export default function NotificationCenter() {
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`}
> >
{/* Vorschautext */} {/* 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>
)} )}
{/* Notification Bell (absolut rechts innerhalb des Buttons) */} {/* Icon & Badge --------------------------------------- */}
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center"> <div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center">
<svg <svg
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`} className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
@ -156,6 +179,7 @@ export default function NotificationCenter() {
/> />
</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">
<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>
@ -167,7 +191,7 @@ export default function NotificationCenter() {
</div> </div>
</button> </button>
{/* Dropdown */} {/* Dropdown --------------------------------------------- */}
{open && ( {open && (
<NotificationDropdown <NotificationDropdown
notifications={notifications} notifications={notifications}
@ -177,7 +201,9 @@ export default function NotificationCenter() {
}} }}
onSingleRead={async (id) => { onSingleRead={async (id) => {
await markOneAsRead(id) await markOneAsRead(id)
setNotifications(prev => prev.map(n => (n.id === id ? { ...n, read: true } : n))) setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, read: true } : n)),
)
}} }}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onAction={async (action, id) => { onAction={async (action, id) => {
@ -186,8 +212,8 @@ export default function NotificationCenter() {
prev.map(n => prev.map(n =>
n.actionData === id n.actionData === id
? { ...n, read: true, actionType: undefined, actionData: undefined } ? { ...n, read: true, actionType: undefined, actionData: undefined }
: n : n,
) ),
) )
if (action === 'accept') router.refresh() if (action === 'accept') router.refresh()
}} }}

View File

@ -69,6 +69,7 @@ export default function Pagination({
) : ( ) : (
<button <button
key={page} key={page}
type="button"
onClick={() => onPageChange(page)} onClick={() => onPageChange(page)}
aria-current={page === currentPage ? 'page' : undefined} aria-current={page === currentPage ? 'page' : undefined}
className={`min-h-9.5 min-w-9.5 flex justify-center items-center py-2 px-3 text-sm rounded-lg border className={`min-h-9.5 min-w-9.5 flex justify-center items-center py-2 px-3 text-sm rounded-lg border

View File

@ -8,7 +8,8 @@ import { Player } from '../types/team'
type Props = { type Props = {
player: Player player: Player
currentUserSteamId: string currentUserSteamId: string
teamLeaderSteamId: string teamLeaderSteamId: string | null | undefined
isAdmin?: boolean
onKick?: (player: Player) => void onKick?: (player: Player) => void
onPromote?: (steamId: string) => void onPromote?: (steamId: string) => void
hideOverlay?: boolean hideOverlay?: boolean
@ -16,14 +17,14 @@ type Props = {
matchParentBg?: boolean matchParentBg?: boolean
} }
export default function SortableMiniCard({ export default function SortableMiniCard({
player, player,
onKick,
onPromote,
currentUserSteamId, currentUserSteamId,
teamLeaderSteamId, teamLeaderSteamId,
hideOverlay = false isAdmin = false,
onKick,
onPromote,
hideOverlay = false,
}: Props) { }: Props) {
const { const {
attributes, attributes,
@ -41,7 +42,8 @@ export default function SortableMiniCard({
transition, transition,
} }
const isDraggable = currentUserSteamId === teamLeaderSteamId /* Drag-Berechtigung: Leader **oder** Admin */
const isDraggable = isAdmin || currentUserSteamId === teamLeaderSteamId
return ( return (
<div <div
@ -61,7 +63,8 @@ export default function SortableMiniCard({
onKick={() => onKick?.(player)} onKick={() => onKick?.(player)}
onPromote={onPromote} onPromote={onPromote}
currentUserSteamId={currentUserSteamId} currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamLeaderSteamId} teamLeaderSteamId={teamLeaderSteamId ?? ''}
isAdmin={isAdmin}
dragListeners={isDraggable ? listeners : undefined} dragListeners={isDraggable ? listeners : undefined}
hoverEffect={isDraggable} hoverEffect={isDraggable}
hideOverlay={hideOverlay} hideOverlay={hideOverlay}
@ -69,4 +72,3 @@ export default function SortableMiniCard({
</div> </div>
) )
} }

View File

@ -24,8 +24,10 @@ export function Tabs({ children }: { children: ReactNode }) {
typeof tab.props.href === 'string' typeof tab.props.href === 'string'
) )
.map((tab, index) => { .map((tab, index) => {
const slug = tab.props.href.split('/').pop() const base = tab.props.href.replace(/\/$/, '')
const isActive = pathname.endsWith(slug ?? '') const current = pathname.replace(/\/$/, '')
const isActive = current === base || current.startsWith(base + '/');
return ( return (
<Link <Link

View File

@ -1,99 +1,174 @@
// components/TeamCard.tsx // components/TeamCard.tsx
'use client' 'use client'
import { useEffect, useState } from 'react' import { useState } from 'react'
import Button from './Button' import { useRouter } from 'next/navigation'
import { Team, Player } from '../types/team' import Button from './Button'
import { useLiveTeam } from '../hooks/useLiveTeam' import TeamPremierRankBadge from './TeamPremierRankBadge'
import { useLiveTeam } from '../hooks/useLiveTeam'
import type { Team, Player } 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) => void
adminMode?: boolean
} }
export default function TeamCard({ team, currentUserSteamId, invitationId, onUpdateInvitation }: Props) { export default function TeamCard({
team,
currentUserSteamId,
invitationId,
onUpdateInvitation,
adminMode = false,
}: Props) {
const router = useRouter()
const [joining, setJoining] = useState(false) const [joining, setJoining] = useState(false)
/* ---------- Live-Daten ---------- */
const data = useLiveTeam(team) const data = useLiveTeam(team)
if (!data) return <LoadingSpinner />
if (!data || !data.players) { const players: Player[] = [
return <p className="text-sm text-gray-400">Lade Team </p> ...(data.activePlayers ?? []),
} ...(data.inactivePlayers ?? []),
]
/* ---------- Join / Reject ---------- */
const isRequested = Boolean(invitationId)
const isDisabled = joining || currentUserSteamId === data.leader
const handleClick = async () => { const handleClick = async () => {
if (joining) return if (joining) return
setJoining(true) setJoining(true)
try { try {
if (invitationId) { if (isRequested) {
await fetch('/api/user/invitations/reject', { await fetch('/api/user/invitations/reject', {
method: 'POST', method : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId }), body : JSON.stringify({ invitationId }),
}) })
onUpdateInvitation(data.id, null) onUpdateInvitation(data.id, null)
} else { } else {
const res = 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: data.id }),
}) })
if (!res.ok) throw new Error() onUpdateInvitation(data.id, 'pending')
onUpdateInvitation(data.id, 'dummy-id') // ← bei Bedarf mit realer ID aktualisieren
} }
} catch (err) { } catch (err) {
console.error('Fehler bei Join/Reject:', err) console.error('[TeamCard] Join/Reject-Fehler:', err)
} finally { } finally {
setJoining(false) setJoining(false)
} }
} }
const isRequested = !!invitationId /* ---------- Ziel-URL berechnen ---------- */
const isDisabled = joining || currentUserSteamId === data.leader const targetHref = adminMode
? `/admin/teams/${data.id}`
: `/team/${data.id}`
/* ---------- Render ---------- */
return ( return (
<div className="p-4 border rounded-lg bg-white dark:bg-neutral-800 dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer"> <div
role="button"
tabIndex={0}
onClick={() => router.push(targetHref)}
onKeyDown={e => (e.key === 'Enter') && router.push(targetHref)}
className="
p-4 border rounded-lg bg-white dark:bg-neutral-800
dark:border-neutral-700 shadow-sm hover:shadow-md
transition cursor-pointer focus:outline-none
"
>
{/* 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={data.logo ? `/assets/img/logos/${data.logo}` : '/assets/img/logos/placeholder.png'} src={
alt={data.teamname ?? 'Teamlogo'} data.logo
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600" ? `/assets/img/logos/${data.logo}`
: '/assets/img/logos/cs2.webp'
}
alt={data.name ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border
border-gray-200 dark:border-neutral-600"
/> />
<span className="font-medium truncate text-gray-500 dark:text-neutral-400">
{data.teamname ?? 'Team'} <div className="flex items-center gap-2">
</span> <span className="font-medium truncate text-gray-800 dark:text-neutral-200">
{data.name ?? 'Team'}
</span>
<TeamPremierRankBadge players={players} />
</div>
</div> </div>
<Button {adminMode ? (
title={isRequested ? 'Angefragt (zurückziehen)' : 'Beitreten'} <Button
size="sm" title="Verwalten"
color={isRequested ? 'gray' : 'blue'} size="sm"
disabled={isDisabled} color="blue"
onClick={(e: any) => { onClick={e => {
e.stopPropagation() e.stopPropagation() // ▼ Navigation hier unterbinden
handleClick() router.push(`/admin/teams/${data.id}`)
}} }}
> >
{joining ? '...' : isRequested ? 'Angefragt' : 'Beitreten'} Verwalten
</Button> </Button>
) : (
<Button
title={isRequested ? 'Angefragt (zurückziehen)' : 'Beitritt anfragen'}
size="sm"
color={isRequested ? 'gray' : 'blue'}
disabled={isDisabled}
onClick={e => {
e.stopPropagation() // ▼ verhindert Klick-Weitergabe
handleClick()
}}
>
{joining ? (
<>
<span
className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1"
role="status"
aria-label="loading"
/>
Lädt
</>
) : isRequested ? (
'Angefragt'
) : (
'Beitritt anfragen'
)}
</Button>
)}
</div> </div>
{/* Avatare */}
<div className="flex -space-x-3"> <div className="flex -space-x-3">
{data.players.slice(0, 5).map((p) => ( {players.slice(0, 5).map(p => (
<img <img
key={p.steamId} key={p.steamId}
src={p.avatar} src={p.avatar}
alt={p.name} alt={p.name}
title={p.name} title={p.name}
className="w-8 h-8 rounded-full border-2 border-white dark:border-neutral-800 object-cover" className="w-8 h-8 rounded-full border-2 border-white
dark:border-neutral-800 object-cover"
/> />
))} ))}
{data.players.length > 5 && (
<span className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 text-xs"> {players.length > 5 && (
+{data.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> </span>
)} )}
</div> </div>

View File

@ -79,11 +79,8 @@ function TeamCardComponent(props: Props, ref: any) {
<TeamMemberView <TeamMemberView
{...teamManager} {...teamManager}
currentUserSteamId={steamId} currentUserSteamId={steamId}
adminMode={false}
/> />
<div className="flex gap-x-3">
<CreateTeamButton setRefetchKey={setRefetchKey} />
</div>
</form> </form>
</div> </div>
) )

View File

@ -4,6 +4,7 @@ import { useEffect, 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'
import MiniCard from './MiniCard'
import MiniCardDummy from './MiniCardDummy' import MiniCardDummy from './MiniCardDummy'
import SortableMiniCard from './SortableMiniCard' import SortableMiniCard from './SortableMiniCard'
import LeaveTeamModal from './LeaveTeamModal' import LeaveTeamModal from './LeaveTeamModal'
@ -17,6 +18,7 @@ import { useTeamManager } from '../hooks/useTeamManager'
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'
type Props = { type Props = {
team: Team | null team: Team | null
@ -33,6 +35,7 @@ type Props = {
setIsDragging: (v: boolean) => void setIsDragging: (v: boolean) => void
setactivePlayers: (players: Player[]) => void setactivePlayers: (players: Player[]) => void
setInactivePlayers: (players: Player[]) => void setInactivePlayers: (players: Player[]) => void
adminMode?: boolean
} }
export default function TeamMemberView({ export default function TeamMemberView({
@ -49,6 +52,7 @@ export default function TeamMemberView({
setIsDragging, setIsDragging,
setactivePlayers, setactivePlayers,
setInactivePlayers, setInactivePlayers,
adminMode = false,
}: Props) { }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const { source, connect } = useSSE() const { source, connect } = useSSE()
@ -57,11 +61,14 @@ export default function TeamMemberView({
const currentUserSteamId = session?.user?.steamId || '' const currentUserSteamId = session?.user?.steamId || ''
const isLeader = currentUserSteamId === team?.leader const isLeader = currentUserSteamId === team?.leader
const canManage = adminMode || isLeader
const canInvite = isLeader && !adminMode
const canAddDirect = adminMode
const { leaveTeam, reloadTeam, renameTeam, deleteTeam } = useTeamManager({}, null) const { leaveTeam, reloadTeam, renameTeam, deleteTeam } = useTeamManager({}, null)
const [showRenameModal, setShowRenameModal] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false)
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?.teamname || '') const [editedName, setEditedName] = useState(team?.name || '')
const [isEditingLogo, setIsEditingLogo] = useState(false) const [isEditingLogo, setIsEditingLogo] = useState(false)
const [logoPreview, setLogoPreview] = useState<string | null>(null) const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [logoFile, setLogoFile] = useState<File | null>(null) const [logoFile, setLogoFile] = useState<File | null>(null)
@ -79,44 +86,58 @@ export default function TeamMemberView({
useEffect(() => { useEffect(() => {
if (!source || !team?.id) return if (!source || !teamState?.id) return
const handleMessage = (event: MessageEvent) => { const handleMessage = (e: MessageEvent) => {
const data = JSON.parse(event.data) try {
const data = JSON.parse(e.data)
const relevant = [
'team-updated',
'team-leader-changed',
'team-leader-self',
'team-member-joined',
'team-member-left',
'team-kick',
'team-kick-other',
'team-renamed',
'team-logo-updated',
]
if (data.teamId !== teamState.id || !relevant.includes(data.type)) return
const relevantTypes = [ /* EIN Aufruf genügt holt Team + Spieler + setzt States */
'team-updated',
'team-kick',
'team-kick-other',
'team-member-joined',
'team-member-left',
'team-leader-changed',
'team-renamed',
'team-logo-updated',
]
if (relevantTypes.includes(data.type) && typeof data.teamId === 'string') {
fetch(`/api/team/${encodeURIComponent(data.teamId)}`) fetch(`/api/team/${encodeURIComponent(data.teamId)}`)
.then((res) => res.json()) .then(r => r.json())
.then((data) => { .then(fresh => {
setactivePlayers( setTeamState(fresh)
(data.activePlayers ?? []) setactivePlayers((fresh.activePlayers ?? [])
.filter((p: Player) => p?.name) .sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
.sort((a: Player, b: Player) => a.name.localeCompare(b.name)) setInactivePlayers((fresh.inactivePlayers ?? [])
); .sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
})
setInactivePlayers( } catch (err) {
(data.inactivePlayers ?? []) console.error('SSE parse error:', err)
.filter((p: Player) => p?.name)
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
);
})
} }
} }
source.addEventListener('message', handleMessage) const eventNames = [
return () => source.removeEventListener('message', handleMessage) 'team-updated',
}, [source, team?.id]) 'team-leader-changed',
'team-leader-self',
'team-member-joined',
'team-member-left',
'team-kick',
'team-kick-other',
'team-renamed',
'team-logo-updated',
]
eventNames.forEach(evt => source.addEventListener(evt, handleMessage))
source.onmessage = handleMessage
return () => {
eventNames.forEach(evt => source.removeEventListener(evt, handleMessage))
source.onmessage = null
}
}, [source, teamState?.id, reloadTeam])
const handleDragStart = (event: any) => { const handleDragStart = (event: any) => {
@ -225,40 +246,62 @@ export default function TeamMemberView({
} }
} }
if (!teamState || !currentUserSteamId) return null if (!teamState) return null
if (!adminMode && !currentUserSteamId) return null
const manageSteam = adminMode ? teamState.leader : currentUserSteamId
const renderMemberList = (players: Player[]) => ( const renderMemberList = (players: Player[]) => (
<AnimatePresence> <AnimatePresence>
{players.map(player => ( {players.map(player => (
<motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}> <motion.div
<SortableMiniCard key={player.steamId}
player={player} initial={{ opacity: 0, y: 10 }}
onKick={setKickCandidate} animate={{ opacity: 1, y: 0 }}
onPromote={() => setPromoteCandidate(player)} exit={{ opacity: 0, y: -10 }}
currentUserSteamId={currentUserSteamId} transition={{ duration: 0.2 }}
teamLeaderSteamId={teamState.leader} className='max-w-[160px]'
isDraggingGlobal={isDragging} >
hideOverlay={isDragging} {/* ✨ Link zur Profil-Seite */}
matchParentBg={true} <Link
/> href={`/profile/${player.steamId}`} // ⇦ dein Profil-Slug
passHref // nur nötig, falls du <a> verwendest
onClick={e => {
/* Wenn gerade gezogen wird → Navigation verhindern */
if (isDragging) e.preventDefault()
}}
>
{/* Wichtig: SortableMiniCard selbst bleibt Drag-Handle,
deshalb keinen weiteren Wrapper mehr einziehen. */}
<SortableMiniCard
player={player}
onKick={setKickCandidate}
onPromote={() => setPromoteCandidate(player)}
currentUserSteamId={manageSteam}
teamLeaderSteamId={teamState.leader}
isAdmin={!!session?.user?.isAdmin}
isDraggingGlobal={isDragging}
hideOverlay={isDragging}
matchParentBg
/>
</Link>
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
) )
return ( return (
<div className={`p-4 my-6 sm:my-8 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 */} {/* 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={() => isLeader && document.getElementById('logoUpload')?.click()} onClick={() => canManage && document.getElementById('logoUpload')?.click()}
> >
<Image <Image
src={teamState.logo ? `/assets/img/logos/${teamState.logo}` : `/assets/img/logos/placeholder.png`} src={teamState.logo ? `/assets/img/logos/${teamState.logo}` : `/assets/img/logos/cs2.webp`}
alt="Teamlogo" alt="Teamlogo"
fill fill
sizes="64px" sizes="64px"
@ -268,7 +311,7 @@ export default function TeamMemberView({
/> />
{/* Overlay beim Hover */} {/* Overlay beim Hover */}
{isLeader && ( {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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -283,7 +326,7 @@ export default function TeamMemberView({
</div> </div>
{/* Hidden file input */} {/* Hidden file input */}
{isLeader && ( {canManage && (
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
@ -359,7 +402,7 @@ export default function TeamMemberView({
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setIsEditingName(false) setIsEditingName(false)
setEditedName(teamState.teamname ?? '') setEditedName(teamState.name ?? '')
}} }}
className="h-[34px] px-3 flex items-center justify-center" className="h-[34px] px-3 flex items-center justify-center"
> >
@ -381,11 +424,11 @@ export default function TeamMemberView({
<> <>
<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.teamname ?? 'Team'} {teamState.name ?? 'Team'}
</h2> </h2>
<TeamPremierRankBadge players={activePlayers} /> <TeamPremierRankBadge players={activePlayers} />
</div> </div>
{isLeader && ( {canManage && (
<Button <Button
title="Bearbeiten" title="Bearbeiten"
color="blue" color="blue"
@ -393,7 +436,7 @@ export default function TeamMemberView({
variant="soft" variant="soft"
onClick={() => { onClick={() => {
setIsEditingName(true) setIsEditingName(true)
setEditedName(teamState.teamname || '') setEditedName(teamState.name || '')
}} }}
className="h-[34px] px-3 flex items-center justify-center" className="h-[34px] px-3 flex items-center justify-center"
> >
@ -414,8 +457,9 @@ export default function TeamMemberView({
{/* Aktionen */} {/* Aktionen */}
<div className="flex gap-2"> <div className="flex gap-2">
{isLeader && ( {canManage && (
<button <button
type="button"
onClick={() => setShowDeleteModal(true)} onClick={() => setShowDeleteModal(true)}
className="text-sm px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700" className="text-sm px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700"
> >
@ -423,6 +467,7 @@ export default function TeamMemberView({
</button> </button>
)} )}
<button <button
type="button"
onClick={async () => { onClick={async () => {
if (isLeader) { if (isLeader) {
setShowLeaveModal(true) setShowLeaveModal(true)
@ -452,10 +497,10 @@ export default function TeamMemberView({
<DroppableZone id="inactive" label="Inaktives Team" activeDragItem={activeDragItem}> <DroppableZone id="inactive" label="Inaktives Team" activeDragItem={activeDragItem}>
<SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}> <SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(inactivePlayers)} {renderMemberList(inactivePlayers)}
{isLeader && ( {canManage && (
<motion.div key="mini-card-dummy" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.2 }}> <motion.div key="mini-card-dummy" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.2 }}>
<MiniCardDummy <MiniCardDummy
title="Einladen" title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
onClick={() => { onClick={() => {
setShowInviteModal(false) setShowInviteModal(false)
setTimeout(() => setShowInviteModal(true), 0) setTimeout(() => setShowInviteModal(true), 0)
@ -479,6 +524,7 @@ export default function TeamMemberView({
player={activeDragItem} player={activeDragItem}
currentUserSteamId={currentUserSteamId} currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader} teamLeaderSteamId={teamState.leader}
isAdmin={!!session?.user?.isAdmin}
hideOverlay hideOverlay
matchParentBg matchParentBg
/> />
@ -486,14 +532,38 @@ export default function TeamMemberView({
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
{isLeader && ( {/* Modal(s) */}
<> {canInvite && (
<LeaveTeamModal show={showLeaveModal} onClose={() => setShowLeaveModal(false)} onSuccess={() => setShowLeaveModal(false)} team={teamState} /> <InvitePlayersModal
<InvitePlayersModal show={showInviteModal} onClose={() => setShowInviteModal(false)} onSuccess={() => {}} team={teamState} /> show={showInviteModal}
</> onClose={() => setShowInviteModal(false)}
onSuccess={() => {}}
team={teamState}
/>
)} )}
{isLeader && promoteCandidate && ( {canAddDirect && (
<InvitePlayersModal
show={showInviteModal}
onClose={() => setShowInviteModal(false)}
onSuccess={() => {}}
team={teamState}
directAdd
/>
)}
{/* Leader-spezifische Modale (z. B. Team verlassen) */}
{isLeader && (
<LeaveTeamModal
show={showLeaveModal}
onClose={() => setShowLeaveModal(false)}
onSuccess={() => setShowLeaveModal(false)}
team={teamState}
/>
)}
{canManage && promoteCandidate && (
<Modal <Modal
id={`modal-promote-player-${promoteCandidate.steamId}`} id={`modal-promote-player-${promoteCandidate.steamId}`}
title="Leader übertragen" title="Leader übertragen"
@ -506,13 +576,30 @@ export default function TeamMemberView({
closeButtonTitle="Übertragen" closeButtonTitle="Übertragen"
closeButtonColor="blue" closeButtonColor="blue"
> >
{/* ► PlayerCard des Kandidaten */}
<div className="flex justify-center mb-4">
<MiniCard
steamId={promoteCandidate.steamId}
title={promoteCandidate.name}
avatar={promoteCandidate.avatar}
location={promoteCandidate.location}
selected={false}
onSelect={() => {}}
draggable={false}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader}
hideActions
isSelectable={false}
/>
</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 Teamleader machen? Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
</p> </p>
</Modal> </Modal>
)} )}
{isLeader && kickCandidate && ( {canManage && kickCandidate && (
<Modal <Modal
id={`modal-kick-player-${kickCandidate.steamId}`} id={`modal-kick-player-${kickCandidate.steamId}`}
title="Mitglied entfernen" title="Mitglied entfernen"
@ -522,13 +609,30 @@ export default function TeamMemberView({
closeButtonTitle="Entfernen" closeButtonTitle="Entfernen"
closeButtonColor="red" closeButtonColor="red"
> >
{/* ► PlayerCard des Kandidaten */}
<div className="flex justify-center mb-4">
<MiniCard
steamId={kickCandidate.steamId}
title={kickCandidate.name}
avatar={kickCandidate.avatar}
location={kickCandidate.location}
selected={false}
onSelect={() => {}}
draggable={false}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader}
hideActions
isSelectable={false}
/>
</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>
</Modal> </Modal>
)} )}
{isLeader && ( {canManage && (
<Modal <Modal
id="modal-delete-team" id="modal-delete-team"
title="Team löschen" title="Team löschen"

View File

@ -0,0 +1,15 @@
// components/UserClips.tsx
import { useEffect, useState } from 'react'
import { getClips, type Clip } from '../lib/allstar'
export default function UserClips({ steamId }: { steamId: string }) {
const [clips, setClips] = useState<Clip[]>([])
useEffect(() => { getClips(steamId).then(r => setClips(r.clips)) }, [steamId])
return (
<div className="grid gap-4 md:grid-cols-3">
{clips.map(c => (
<video key={c.id} src={c.url} controls className="rounded-lg w-full" />
))}
</div>
)
}

View File

@ -1,14 +1,19 @@
'use client' 'use client'
import { useSession } from 'next-auth/react'
import Chart from '@/app/components/Chart' import Chart from '@/app/components/Chart'
import { MatchStats } from '@/app/types/match' import { MatchStats } from '@/app/types/match'
import Card from './Card' import Card from './Card'
import UserClips from './UserClips'
type MatchStatsProps = { type MatchStatsProps = {
stats: { matches: MatchStats[] } stats: { matches: MatchStats[] }
} }
export default function UserProfile({ stats }: MatchStatsProps) { export default function UserProfile({ stats }: MatchStatsProps) {
const { data: session } = useSession()
const steamId = session?.user?.steamId ?? '' // ← für UserClips
const { matches } = stats const { matches } = stats
const totalKills = matches.reduce((sum, m) => sum + m.kills, 0) const totalKills = matches.reduce((sum, m) => sum + m.kills, 0)
@ -77,6 +82,14 @@ export default function UserProfile({ stats }: MatchStatsProps) {
]} ]}
/> />
</Card> </Card>
{/* ► Allstar-Clips des aktuellen Users -------------------------- */}
{steamId && (
<Card>
<h3 className="text-lg font-semibold mb-2">Highlights</h3>
<UserClips steamId={steamId} />
</Card>
)}
</div> </div>
{/* Breite Diagramme */} {/* Breite Diagramme */}

View File

@ -4,12 +4,12 @@ import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Modal from '@/app/components/Modal' import Modal from '@/app/components/Modal'
import Select from '@/app/components/Select' import Select from '@/app/components/Select'
import Input from './Input' import Input from '../Input'
import Button from './Button' import Button from '../Button'
import DatePickerWithTime from './DatePickerWithTime' import DatePickerWithTime from '../DatePickerWithTime'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import Switch from './Switch' import Switch from '../Switch'
function getRoundedDate() { function getRoundedDate() {
const now = new Date() const now = new Date()
@ -37,7 +37,6 @@ export default function MatchesAdminManager() {
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [matchDate, setMatchDate] = useState(getRoundedDate()) const [matchDate, setMatchDate] = useState(getRoundedDate())
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
useEffect(() => { useEffect(() => {
fetch('/api/admin/teams').then(res => res.json()).then(setTeams) fetch('/api/admin/teams').then(res => res.json()).then(setTeams)
@ -49,23 +48,19 @@ export default function MatchesAdminManager() {
const teamA = teams.find(t => t.id === teamAId) const teamA = teams.find(t => t.id === teamAId)
const teamB = teams.find(t => t.id === teamBId) const teamB = teams.find(t => t.id === teamBId)
if (teamA && teamB) { if (teamA && teamB) {
setTitle(`${teamA.teamname} vs ${teamB.teamname}`) setTitle(`${teamA.name} vs ${teamB.name}`)
} }
} }
}, [teamAId, teamBId, teams, titleManuallySet]) }, [teamAId, teamBId, teams, titleManuallySet])
const fetchMatches = async () => { const fetchMatches = async () => {
const res = await fetch('/api/matches') const res = await fetch('/api/matches')
if (res.ok) { if (res.ok) setMatches(await res.json())
const data = await res.json()
setMatches(data)
}
} }
const filteredMatches = onlyOwnTeam && session?.user?.team const filteredMatches = matches.filter(
? matches.filter((m: any) => (m: any) => m.matchType === 'community'
m.teamA.id === session.user.team || m.teamB.id === session.user.team) )
: matches
const resetFields = () => { const resetFields = () => {
setTitle('') setTitle('')
@ -117,13 +112,13 @@ export default function MatchesAdminManager() {
<div className="flex flex-col items-center w-1/4"> <div className="flex flex-col items-center w-1/4">
<Image <Image
src={getTeamLogo(match.teamA?.logo)} src={getTeamLogo(match.teamA?.logo)}
alt={match.teamA?.teamname || 'Team A'} alt={match.teamA?.name || 'Team A'}
width={64} width={64}
height={64} height={64}
className="rounded-full border object-cover bg-white" className="rounded-full border object-cover bg-white"
/> />
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200"> <span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{match.teamA?.teamname || 'Team A'} {match.teamA?.name || 'Team A'}
</span> </span>
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-300 w-1/2"> <div className="text-sm text-gray-600 dark:text-gray-300 w-1/2">
@ -136,13 +131,13 @@ export default function MatchesAdminManager() {
<div className="flex flex-col items-center w-1/4"> <div className="flex flex-col items-center w-1/4">
<Image <Image
src={getTeamLogo(match.teamB?.logo)} src={getTeamLogo(match.teamB?.logo)}
alt={match.teamB?.teamname || 'Team B'} alt={match.teamB?.name || 'Team B'}
width={64} width={64}
height={64} height={64}
className="rounded-full border object-cover bg-white" className="rounded-full border object-cover bg-white"
/> />
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200"> <span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{match.teamB?.teamname || 'Team B'} {match.teamB?.name || 'Team B'}
</span> </span>
</div> </div>
</div> </div>
@ -171,20 +166,20 @@ export default function MatchesAdminManager() {
> >
<div className="grid grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-2 gap-4 mb-4">
<div> <div>
<label className="block mb-1">Team A</label> <label className="block text-sm font-medium mb-2 dark:text-white">Team A</label>
<Select <Select
value={teamAId} value={teamAId}
onChange={setTeamAId} onChange={setTeamAId}
options={teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.teamname }))} options={teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.name }))}
placeholder="Wählen" placeholder="Wählen"
/> />
</div> </div>
<div> <div>
<label className="block mb-1">Team B</label> <label className="block text-sm font-medium mb-2 dark:text-white">Team B</label>
<Select <Select
value={teamBId} value={teamBId}
onChange={setTeamBId} onChange={setTeamBId}
options={teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.teamname }))} options={teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.name }))}
placeholder="Wählen" placeholder="Wählen"
/> />
</div> </div>

View File

@ -0,0 +1,135 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import Button from '@/app/components/Button'
import Modal from '@/app/components/Modal'
import Input from '@/app/components/Input'
import TeamCard from '@/app/components/TeamCard'
import type { Team } from '@/app/types/team'
export default function AdminTeamsView() {
/* ────────────────────────── Session ─────────────────────────── */
const { data: session } = useSession()
/* ─────────────────────────── State ───────────────────────────── */
const [teams, setTeams] = useState<Team[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [saving, setSaving] = useState(false)
/* ───────────────────── fetchTeams (memoisiert) ─────────────── */
const fetchTeams = useCallback(async () => {
setLoading(true)
try {
const res = await fetch('/api/team/list')
const json = await res.json()
setTeams(json.teams ?? [])
} catch (err) {
console.error('[AdminTeamsView] /api/team/list:', err)
setTeams([])
} finally {
setLoading(false)
}
}, [])
/* ─────────────────── einmaliger Aufruf sichern ──────────────── */
const fetchedOnce = useRef(false)
useEffect(() => {
if (fetchedOnce.current) return // im Strict-Mode zweiter Aufruf → abbrechen
fetchedOnce.current = true
fetchTeams()
}, [fetchTeams])
/* ───────────────────────── Team anlegen ─────────────────────── */
// Ausschnitt aus AdminTeamsView.tsx
const createTeam = async () => {
if (!newName.trim()) return
setSaving(true)
try {
const res = await fetch('/api/team/create', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ teamname: newName.trim() }), // ⇦ nur Name
})
if (!res.ok) {
alert((await res.json()).message ?? 'Fehler beim Erstellen')
return
}
await fetchTeams()
setShowCreate(false)
setNewName('')
} catch (err) {
console.error('[AdminTeamsView] Team erstellen:', err)
alert('Team konnte nicht erstellt werden.')
} finally {
setSaving(false)
}
}
/* ─────────────────────────── Render ─────────────────────────── */
if (loading) {
return (
<p className="text-gray-500 dark:text-gray-400">
Lade Teams&nbsp;&hellip;
</p>
)
}
return (
<>
{/* Kopfzeile */}
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
Teams verwalten
</h2>
<Button color="blue" onClick={() => setShowCreate(true)}>
Neues Team erstellen
</Button>
</div>
{/* Team-Grid */}
{teams.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">
Es wurden noch keine Teams erstellt.
</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{teams.map(t => (
<TeamCard
key={t.id}
team={t}
currentUserSteamId=""
invitationId={undefined}
onUpdateInvitation={() => {}}
adminMode
/>
))}
</div>
)}
{/* Modal zum Erstellen */}
<Modal
id="create-team-modal"
title="Neues Team erstellen"
show={showCreate}
onClose={() => { setShowCreate(false); setNewName('') }}
onSave={createTeam}
closeButtonColor="blue"
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
>
<Input
label="Teamname"
value={newName}
placeholder="z. B. Ironie eSports"
onChange={e => setNewName(e.target.value)}
/>
</Modal>
</>
)
}

View File

@ -62,7 +62,7 @@ export default function AppearanceSettings() {
checked={isChecked} checked={isChecked}
onChange={() => setTheme(id)} onChange={() => setTheme(id)}
/> />
<img className="rounded-t-[14px] -mt-px" src={img ? `/assets/img/themes/${img}` : '/assets/img/logos/placeholder.png'} alt={label} loading="lazy" /> <img className="rounded-t-[14px] -mt-px" src={img ? `/assets/img/themes/${img}` : '/assets/img/logos/cs2.webp'} alt={label} loading="lazy" />
<span <span
className={`py-3 px-2 text-sm font-semibold rounded-b-xl className={`py-3 px-2 text-sm font-semibold rounded-b-xl
${isChecked ${isChecked

View File

@ -1,3 +1,4 @@
// LatestKnownCodeSettings.tsx
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
@ -80,15 +81,31 @@ export default function LatestKnownCodeSettings({
required required
/> />
{!showError && ( {!showError && (
<div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none"> <div className="absolute top-1/2 end-3 -translate-y-1/2 pointer-events-none">
<svg className="shrink-0 size-4 text-teal-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
className="size-4 text-teal-500"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
</div> </div>
)} )}
{showError && ( {showError && (
<p className="text-sm text-red-600 mt-2"> <p className="text-sm text-red-600 mt-2">
Abgelaufener Austauschcode Abgelaufener Austauschcode! Deinen neuen Austauschcode findest du&nbsp;
<Link
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
target="_blank"
className="text-red-600 underline hover:text-blue-800"
>
hier
</Link>.
</p> </p>
)} )}
{isSaved && !showError && ( {isSaved && !showError && (

View File

@ -1,65 +1,47 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Team } from '../types/team' import type { Team, Player } from '../types/team'
const relevantEvents = [ const events = [
'ws-team-renamed', 'ws-team-renamed',
'ws-team-member-joined', 'ws-team-member-joined',
'ws-team-member-left', 'ws-team-member-left',
'ws-team-kick', 'ws-team-kick',
'ws-team-kick-other', 'ws-team-kick-other',
'ws-team-leader-changed', 'ws-team-leader-changed',
'ws-team-logo-updated' 'ws-team-logo-updated',
] ]
export function useLiveTeam(initialTeam: Team) { export function useLiveTeam(initialTeam: Team) {
const [data, setData] = useState<Team>(initialTeam) /** Der lokale Zustand hat dieselbe Form wie `Team` */
const [team, setTeam] = useState<Team>(initialTeam)
useEffect(() => { useEffect(() => {
const update = async () => { const refresh = async () => {
try { const res = await fetch(`/api/team/get?id=${initialTeam.id}`)
const res = await fetch(`/api/team/get?id=${initialTeam.id}`) if (!res.ok) return
if (!res.ok) return const { team: t } = await res.json()
if (!t) return
const json = await res.json() setTeam({
const updatedTeam = json?.team id : t.id,
if (!updatedTeam) return name : t.teamname,
logo : t.logo,
const players = [ leader: t.leader,
...(updatedTeam.activePlayers ?? []), activePlayers : t.activePlayers ?? [],
...(updatedTeam.inactivePlayers ?? []), inactivePlayers: t.inactivePlayers ?? [],
] })
setData({
id: updatedTeam.id,
teamname: updatedTeam.teamname,
logo: updatedTeam.logo,
leader: updatedTeam.leader,
players,
})
} catch (err) {
console.error('Fehler beim Nachladen des Teams:', err)
}
} }
const handler = (e: Event) => { const handler = (e: Event) => {
const customEvent = e as CustomEvent const ev = e as CustomEvent
if (customEvent.detail?.teamId === initialTeam.id) { if (ev.detail?.teamId === initialTeam.id) refresh()
update()
}
} }
for (const evt of relevantEvents) { events.forEach(evt => window.addEventListener(evt, handler))
window.addEventListener(evt, handler) return () => events.forEach(evt => window.removeEventListener(evt, handler))
}
return () => {
for (const evt of relevantEvents) {
window.removeEventListener(evt, handler)
}
}
}, [initialTeam.id]) }, [initialTeam.id])
return data return team
} }

View File

@ -11,7 +11,7 @@ export type Invitation = {
} }
export function useTeamManager( export function useTeamManager(
props: { refetchKey?: string }, props: { refetchKey?: string; teamId?: string },
ref: React.Ref<any> ref: React.Ref<any>
) { ) {
const [team, setTeam] = useState<Team | null>(null) const [team, setTeam] = useState<Team | null>(null)
@ -29,7 +29,11 @@ export function useTeamManager(
const fetchTeam = async () => { const fetchTeam = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const res = await fetch('/api/team') const url = props.teamId
? `/api/team/${encodeURIComponent(props.teamId)}`
: '/api/team'
const res = await fetch(url)
if (res.status === 404) { if (res.status === 404) {
setTeam(null) setTeam(null)
@ -42,22 +46,59 @@ export function useTeamManager(
const data = await res.json() const data = await res.json()
if (!data.team) { const teamData = data.team ?? data
if (!teamData || !teamData.id) {
setTeam(null) setTeam(null)
setactivePlayers([]) setactivePlayers([])
setInactivePlayers([]) setInactivePlayers([])
return return
} }
const newActive = data.team.activePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name)) // ── 1. evtl. nur Steam-IDs? ───────────────────────────────
const newInactive = data.team.inactivePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name)) let newActive = teamData.activePlayers ?? []
let newInactive = teamData.inactivePlayers ?? []
const playersAreStrings =
typeof newActive[0] === 'string' || typeof newInactive[0] === 'string'
if (playersAreStrings && (newActive.length || newInactive.length)) {
/* Alle IDs sammeln und User-Infos nachladen */
const steamIds = [...newActive, ...newInactive]
const resUsers = await fetch('/api/user/list', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ steamIds })
})
const json = await resUsers.json()
const users: Player[] = Array.isArray(json) ? json
: Array.isArray(json.users) ? json.users
: []
const map = Object.fromEntries(users.map(u => [u.steamId, u]))
newActive = newActive
.map((id: string) => map[id])
.filter(Boolean)
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
newInactive = newInactive
.map((id: string) => map[id])
.filter(Boolean)
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
} else {
newActive = newActive .sort((a: Player, b: Player) => a.name.localeCompare(b.name))
newInactive = newInactive.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
}
setTeam({ setTeam({
id: data.team.id, id: teamData.id,
teamname: data.team.teamname, name: teamData.name,
leader: data.team.leader, leader: teamData.leader,
logo: data.team.logo, logo: teamData.logo,
players: [...newActive, ...newInactive], activePlayers : newActive,
inactivePlayers: newInactive,
}) })
setactivePlayers(newActive) setactivePlayers(newActive)
setInactivePlayers(newInactive) setInactivePlayers(newInactive)
@ -87,10 +128,13 @@ export function useTeamManager(
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
if (props.teamId) // 👉 Admin-Detail: nur Team holen
await fetchTeam()
else // 👉 eigener User: Team + Einladungen
await Promise.all([fetchTeam(), fetchInvitations()]) await Promise.all([fetchTeam(), fetchInvitations()])
} }
load() load()
}, [props.refetchKey]) }, [props.refetchKey, props.teamId]) // teamId als Dep nicht vergessen
useWebSocketListener('ws-invitation', fetchInvitations) useWebSocketListener('ws-invitation', fetchInvitations)
useWebSocketListener('ws-team-invite', fetchInvitations) useWebSocketListener('ws-team-invite', fetchInvitations)

23
src/app/lib/allstar.ts Normal file
View File

@ -0,0 +1,23 @@
// lib/allstar.ts
import ky from 'ky'
export interface Clip {
id: string
url: string // direct mp4/hls URL
title?: string
createdAt: string // ISO-Datum
// … beliebige weitere Felder der API …
}
const api = ky.create({
prefixUrl : 'https://partner-api.allstar.gg/v1',
headers : { Authorization: `Bearer ${process.env.ALLSTAR_TOKEN}` },
timeout : 10_000,
retry : 1,
})
export async function getClips(steamId: string) {
return api.get('clips', { searchParams: { steamId, limit: 25 } })
.json<{ clips: Clip[] }>()
}

4
src/app/messages/de.json Normal file
View File

@ -0,0 +1,4 @@
{
"hello": "Hallo Welt",
"profile.title": "Dein Profil"
}

4
src/app/messages/en.json Normal file
View File

@ -0,0 +1,4 @@
{
"hello": "Hello world",
"profile.title": "My Profile"
}

View File

@ -1,11 +0,0 @@
'use client'
import { use } from 'react'
import { notFound } from 'next/navigation'
import MatchDetails from '@/app/components/MatchDetails'
export default function Page({ params }: { params: Promise<{ matchId: string }> }) {
const { matchId } = use(params)
return <MatchDetails matchId={matchId} />
}

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Switch from '@/app/components/Switch' import Switch from '@/app/components/Switch'
@ -10,8 +11,12 @@ type Match = {
id: string id: string
title: string title: string
matchDate: string matchDate: string
teamA: { id: string; teamname: string; logo?: string | null } teamA: { id: string; name: string; logo?: string | null }
teamB: { id: string; teamname: string; logo?: string | null } teamB: { id: string; name: string; logo?: string | null }
}
function getTeamLogo(logo?: string | null) {
return logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
} }
export default function MatchesPage() { export default function MatchesPage() {
@ -21,16 +26,11 @@ export default function MatchesPage() {
useEffect(() => { useEffect(() => {
fetch('/api/schedule') fetch('/api/schedule')
.then(res => res.json()) .then(r => r.json())
.then(data => { .then(data => setMatches(Array.isArray(data.matches) ? data.matches : []))
if (Array.isArray(data)) { .catch(err => {
setMatches(data) console.error('[MatchesPage] /api/schedule fehlt oder Antwort fehlerhaft:', err)
} else if (Array.isArray(data.schedules)) { setMatches([])
setMatches(data.schedules)
} else {
console.error("❌ Unerwartetes API-Format", data)
setMatches([])
}
}) })
}, []) }, [])
@ -69,29 +69,47 @@ export default function MatchesPage() {
{filteredMatches.map(match => ( {filteredMatches.map(match => (
<li key={match.id}> <li key={match.id}>
<Link <Link
href={`/matches/${match.id}`} href={`/match-details/${match.id}`}
className="block border rounded p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition" className="block border rounded p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition"
> >
<div className="flex items-center justify-between text-center"> <div className="flex items-center justify-between text-center">
{/* Team A */} {/* Team A */}
<div className="flex flex-col items-center w-1/4"> <div className="flex flex-col items-center w-1/4">
<div className="size-16 rounded-full border bg-gray-100 dark:bg-neutral-700" /> <Image
src={getTeamLogo(match.teamA?.logo)}
alt={match.teamA?.name || 'Team A'}
width={64}
height={64}
className="rounded-full border object-cover bg-white"
/>
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200"> <span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{match.teamA?.teamname ?? 'Team A'} {match.teamA?.name ?? 'Team A'}
</span> </span>
</div> </div>
{/* Datum / Zeit */} {/* Datum / Zeit */}
<div className="text-sm text-gray-600 dark:text-gray-300 w-1/2"> <div className="text-sm text-gray-600 dark:text-gray-300 w-1/2">
<div>{new Date(match.matchDate).toLocaleDateString('de-DE')}</div> <div>{new Date(match.matchDate).toLocaleDateString('de-DE')}</div>
<div>{new Date(match.matchDate).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr</div> <div>
{new Date(match.matchDate).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}{' '}
Uhr
</div>
</div> </div>
{/* Team B */} {/* Team B */}
<div className="flex flex-col items-center w-1/4"> <div className="flex flex-col items-center w-1/4">
<div className="size-16 rounded-full border bg-gray-100 dark:bg-neutral-700" /> <Image
src={getTeamLogo(match.teamB?.logo)}
alt={match.teamB?.name || 'Team B'}
width={64}
height={64}
className="rounded-full border object-cover bg-white"
/>
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200"> <span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{match.teamB?.teamname ?? 'Team B'} {match.teamB?.name ?? 'Team B'}
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,17 +1,18 @@
// /types/team.ts // /types/team.ts
export type Player = { export type Player = {
steamId: string steamId : string
name: string name : string
avatar: string avatar : string
location?: string location? : string
premierRank?: number premierRank?: number
isAdmin?: boolean isAdmin? : boolean
} }
export type Team = { export type Team = {
id: string id : string
teamname: string | null name?: string | null
logo: string | null logo?: string | null
leader: string leader?: string | null
players?: Player[] activePlayers? : Player[]
inactivePlayers?: Player[]
} }

File diff suppressed because one or more lines are too long

View File

@ -16185,7 +16185,6 @@ export namespace Prisma {
export type UserWhereUniqueInput = Prisma.AtLeast<{ export type UserWhereUniqueInput = Prisma.AtLeast<{
steamId?: string steamId?: string
teamId?: string
AND?: UserWhereInput | UserWhereInput[] AND?: UserWhereInput | UserWhereInput[]
OR?: UserWhereInput[] OR?: UserWhereInput[]
NOT?: UserWhereInput | UserWhereInput[] NOT?: UserWhereInput | UserWhereInput[]
@ -16193,6 +16192,7 @@ export namespace Prisma {
avatar?: StringNullableFilter<"User"> | string | null avatar?: StringNullableFilter<"User"> | string | null
location?: StringNullableFilter<"User"> | string | null location?: StringNullableFilter<"User"> | string | null
isAdmin?: BoolFilter<"User"> | boolean isAdmin?: BoolFilter<"User"> | boolean
teamId?: StringNullableFilter<"User"> | string | null
premierRank?: IntNullableFilter<"User"> | number | null premierRank?: IntNullableFilter<"User"> | number | null
authCode?: StringNullableFilter<"User"> | string | null authCode?: StringNullableFilter<"User"> | string | null
lastKnownShareCode?: StringNullableFilter<"User"> | string | null lastKnownShareCode?: StringNullableFilter<"User"> | string | null
@ -16210,7 +16210,7 @@ export namespace Prisma {
demoFiles?: DemoFileListRelationFilter demoFiles?: DemoFileListRelationFilter
createdSchedules?: ScheduleListRelationFilter createdSchedules?: ScheduleListRelationFilter
confirmedSchedules?: ScheduleListRelationFilter confirmedSchedules?: ScheduleListRelationFilter
}, "steamId" | "teamId"> }, "steamId">
export type UserOrderByWithAggregationInput = { export type UserOrderByWithAggregationInput = {
steamId?: SortOrder steamId?: SortOrder

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"name": "prisma-client-79a53e7403334d4969ea3ed05480c7d287e1fc81fe58f157629c22acbdb9958c", "name": "prisma-client-42c2f4122e5c92abceced92f5ccc724f4e2068f151760c3339b2243eb9d75d8e",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",