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

3
.env
View File

@ -14,4 +14,5 @@ STEAM_SHARED_SECRET=test
STEAMCMD_PATH=C:\Users\Rother\Desktop\dev\ironie\steamcmd\steamcmd.exe
NEXTAUTH_SECRET=ironieopen
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,
"scripts": {
"dev": "next dev",
"fast": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint",
@ -18,8 +19,9 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.10.1",
"@prisma/client": "^6.13.0",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2",
"datatables.net": "^2.2.2",
"date-fns": "^4.1.0",
@ -28,10 +30,12 @@
"flag-icons": "^7.3.2",
"framer-motion": "^12.18.1",
"jquery": "^3.7.1",
"ky": "^1.8.2",
"lodash": "^4.17.21",
"lzma-native": "^8.0.6",
"next": "15.3.0",
"next-auth-steam": "^0.4.0",
"next-intl": "^4.3.4",
"next-themes": "^0.4.6",
"node-cron": "^3.0.3",
"node-fetch": "^3.3.2",
@ -56,7 +60,7 @@
"@types/ws": "^8.18.1",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"prisma": "^6.10.1",
"prisma": "^6.13.0",
"tailwindcss": "^4.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.19.4",

View File

@ -21,7 +21,7 @@ model User {
location String?
isAdmin Boolean @default(false)
teamId String? @unique
teamId String?
team Team? @relation("UserTeam", fields: [teamId], references: [id])
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'
import { use, useState } from 'react'
import { notFound } from 'next/navigation'
import Card from '@/app/components/Card'
import TeamCardComponent from '@/app/components/TeamCardComponent'
import MatchesAdminManager from '@/app/components/MatchesAdminManager'
import MatchList from '@/app/components/MatchList'
import { notFound, usePathname } from 'next/navigation'
import Card from '@/app/components/Card'
import MatchesAdminManager from '@/app/components/admin/MatchesAdminManager'
import AdminTeamsView from '@/app/components/admin/teams/AdminTeamsView'
export default function Page({ params }: { params: Promise<{ tab: string }> }) {
const { tab } = use(params)
export default function AdminPage() {
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 (tab) {
case 'matches':
return (
<Card title="Matches" description="">
<MatchesAdminManager />
</Card>
)
case 'privacy':
return (
<Card title="Datenschutz" description="Einstellungen zum Schutz deiner Daten." />
)
case 'team':
return (
<Card title="Team" description="Verwalte dein Team und lade Mitglieder ein.">
<div className="mb-4">
<TeamCardComponent refetchKey={refetchKey} />
</div>
</Card>
)
default:
return notFound()
}
switch (activeTab) {
case 'matches':
return (
<Card title="Matches">
<MatchesAdminManager />
</Card>
)
case 'privacy':
return (
<Card title="Datenschutz"
description="Einstellungen zum Schutz deiner Daten." />
)
case 'teams':
return (
<Card title="Teams"
description="Verwalte dein Team und lade Mitglieder ein."
maxWidth="auto">
<AdminTeamsView />
</Card>
)
default:
return notFound()
}
return <>{renderTabContent()}</>
}

View File

@ -7,7 +7,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<Tabs>
<Tab name="Spielpläne" href="/admin/matches" />
<Tab name="Privacy" href="/admin/privacy" />
<Tab name="Team" href="/admin/team" />
<Tab name="Teams" href="/admin/teams" />
</Tabs>
<div className="mt-6">
{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
/* eslint-disable @typescript-eslint/return-await */
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-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
if (!id) {
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
}
try {
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 })
}
/*
Hilfs-Typen
*/
type PlayerOut = {
user : { steamId: string; name: string | null; avatar: string | null }
stats: any | null
team : string
}
export async function PUT(req: NextRequest, context: { params: { id: string } }) {
const { id } = context.params
const session = await getServerSession(authOptions(req))
const userId = session?.user?.steamId
const isAdmin = session?.user?.isAdmin
if (!userId) {
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 },
});
/* ───────────────────────────── GET ───────────────────────────── */
export async function GET (
_req: Request,
{ params: { id } }: { params: { id: string } },
) {
if (!id)
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
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) {
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
}
if (!match)
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
const isTeamLeaderA = match.teamAId && user?.ledTeam?.id === match.teamAId;
const isTeamLeaderB = match.teamBId && user?.ledTeam?.id === match.teamBId;
/* ---------- Editierbarkeit bestimmen ---------- */
const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
const editable = match.matchType === 'community' && isFuture
if (!isAdmin && !isTeamLeaderA && !isTeamLeaderB) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
/* ---------- Spielerlisten zusammenstellen --------------------------------- */
let playersA: PlayerOut[] = []
let playersB: PlayerOut[] = []
// 🛡️ Validierung: Nur eigene Spieler
if (!isAdmin) {
const ownTeamId = isTeamLeaderA ? match.teamAId : match.teamBId
if (!ownTeamId) {
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
}
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,
},
},
if (editable) {
/* ───── Spieler kommen direkt aus der Match-Relation ───── */
/* ▸ teamAUsers / teamBUsers enthalten bereits User-Objekte */
const mapUser = (u: any, fallbackTeam: string) => ({
user : { // nur die Felder, die das Frontend braucht
steamId: u.steamId,
name : u.name ?? 'Unbekannt',
avatar : u.avatar ?? null,
},
stats: null, // noch keine Stats vorhanden
team : fallbackTeam,
})
if (!updated) {
return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })
playersA = match.teamAUsers.map(u => mapUser(u, match.teamA?.name ?? 'CT'))
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
const playersA = updated.players
.filter(p => p.teamId === updated.teamAId)
.map(p => ({
user: p.user,
stats: p.stats,
team: p.team?.name ?? 'CT',
}))
playersA = match.players
.filter(p => setA.has(p.steamId))
.map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'CT' }))
const playersB = updated.players
.filter(p => p.teamId === updated.teamBId)
.map(p => ({
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 })
playersB = match.players
.filter(p => setB.has(p.steamId))
.map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'T' }))
}
/* ---------- 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 } }) {
const { id } = context.params
/* ───────────────────────────── PUT ───────────────────────────── */
export async function PUT (
req: NextRequest,
{ params: { id } }: { params: { id: string } },
) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.isAdmin) {
const me = session?.user
if (!me?.steamId)
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 {
// Lösche Match inklusive aller zugehörigen MatchPlayer-Einträge (wenn onDelete: Cascade nicht aktiv)
await prisma.matchPlayer.deleteMany({ where: { matchId: id } })
/* ► Listen pro Team aus dem Payload aufteilen */
const teamAIds = players
.filter((p: any) => p.teamId === match.teamAId)
.map((p: any) => p.steamId)
// Lösche das Match
await prisma.match.delete({ where: { id } })
const teamBIds = players
.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 })
} catch (err) {
console.error(`DELETE /matches/${id} failed:`, err)

View File

@ -1,37 +1,32 @@
// /app/api/matches/create/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
export async function POST (req: NextRequest) {
/* ▸ Berechtigung ---------------------------------------------------------------- */
/* ── Auth ▸ nur Admins ───────────────────────────── */
const session = await getServerSession(authOptions(req))
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status : 403 })
}
if (!session?.user?.isAdmin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
/* ▸ Eingaben aus dem Body -------------------------------------------------------- */
/* ── Body auslesen ──────────────────────────────── */
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) {
return NextResponse.json({ error: 'Missing fields' }, { status : 400 })
}
/* ── Teams inkl. aktiver Spieler 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 + Spieler in EINER Transaktion ────────── */
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) => {
/* 1. Match */
/* 1) Match mit verbundenen Team-User-Arrays anlegen */
const newMatch = await tx.match.create({
data: {
teamAId,
@ -39,28 +34,21 @@ export async function POST (req: NextRequest) {
title : title?.trim() || `${teamAId}-${teamBId}`,
description : description?.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 = [
...teamA.activePlayers.map((steamId: string) => ({
matchId: newMatch.id,
steamId,
teamId : teamAId
})),
...teamB.activePlayers.map((steamId: string) => ({
matchId: newMatch.id,
steamId,
teamId : teamBId
}))
...teamA.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamAId })),
...teamB.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamBId })),
]
/* 3. Anlegen (nur wenn Spieler vorhanden) */
if (playersData.length) {
if (playersData.length)
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
}
return newMatch
})
@ -68,6 +56,6 @@ export async function POST (req: NextRequest) {
return NextResponse.json(result, { status: 201 })
} catch (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 { prisma } from '@/app/lib/prisma'
import { prisma } from '@/app/lib/prisma'
export async function GET() {
try {
const schedules = await prisma.schedule.findMany({
orderBy: {
date: 'asc',
},
include: {
teamA: true,
teamB: true,
createdBy: {
select: {
steamId: true,
name: true,
avatar: true,
},
},
confirmedBy: {
select: {
steamId: true,
name: true,
avatar: true,
},
},
linkedMatch: {
select: {
id: true,
map: true,
scoreA: true,
scoreB: true,
demoDate: true,
/* 1) nur Community-Matches holen ------------------------------ */
const matches = await prisma.match.findMany({
where : { matchType: 'community' },
orderBy : { demoDate: 'desc' }, // falls demoDate null ⇒ älter oben
include : {
teamA : true,
teamB : true,
players: {
include: {
user : true,
stats: true,
team : true,
},
},
},
})
return NextResponse.json({ schedules })
} catch (error) {
console.error('❌ Fehler beim Abrufen der geplanten Matches:', error)
return new NextResponse('Serverfehler beim Laden der geplanten Matches', {
status: 500,
/* 2) API-Response vereinheitlichen ---------------------------- */
const formatted = matches.map(m => {
/** ➜ einheitliches Datumsfeld für Frontend */
const matchDate =
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 { prisma } from '@/app/lib/prisma'
import type { Player } from '@/app/types/team'
export async function GET(
req: NextRequest,
{ params }: { params: { teamId: string } }
_req: NextRequest,
{ params }: { params: { teamId: string } },
) {
try {
const param = await params
/* ─── 1) Team holen ─────────────────────────────── */
const team = await prisma.team.findUnique({
where: { id: param.teamId },
where: { id: params.teamId },
})
if (!team) {
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
}
const activePlayers = await prisma.user.findMany({
where: { steamId: { in: team.activePlayers } },
select: { steamId: true, name: true, avatar: true, location: true },
/* ─── 2) Alle Steam-IDs sammeln und Users laden ─── */
const allIds = Array.from(
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({
where: { steamId: { in: team.inactivePlayers } },
select: { steamId: true, name: true, avatar: true, location: true },
})
/* Map steamId → Player */
const byId: Record<string, Player> = Object.fromEntries(
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) {
console.error('Fehler beim Laden des Teams:', error)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
console.error('GET /api/team/[teamId] failed:', error)
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
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma';
import { sendServerSSEMessage } from '@/app/lib/sse-server-client';
import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export async function POST(req: NextRequest) {
try {
const { teamname, leader } = await req.json();
/* ───── Request-Body ───── */
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
if (!teamname || !leader) {
return NextResponse.json({ message: 'Fehlende Eingaben.' }, { status: 400 });
/* ► Teamname pflicht */
if (!teamname?.trim()) {
return NextResponse.json({ message: 'Teamname fehlt.' }, { status: 400 })
}
const existingTeam = await prisma.team.findFirst({ where: { name: teamname } });
if (existingTeam) {
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 });
/* ► Name schon vergeben? */
const dup = await prisma.team.findFirst({ where: { name: teamname } })
if (dup) {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
}
/* ───── Team anlegen ───── */
const newTeam = await prisma.team.create({
data: {
name: teamname,
leaderId: leader,
activePlayers: [leader],
inactivePlayers: [],
name : teamname,
leaderId : leader ?? null, // ← nur setzen, wenn übergeben
activePlayers : leader ? [leader] : [],
inactivePlayers : [],
},
});
})
await prisma.user.update({
where: { steamId: leader },
data: { teamId: newTeam.id },
});
/* ───── Optional: Leader verknüpfen ───── */
if (leader) {
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({
data: {
steamId: leader,
title: 'Team erstellt',
message: `Du hast erfolgreich das Team "${teamname}" erstellt.`,
},
});
await prisma.user.update({
where: { steamId: leader },
data : { teamId: newTeam.id },
})
// 📢 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({
type: 'team-created',
title: 'Team erstellt',
message: `Das Team "${teamname}" wurde erstellt.`,
teamId: newTeam.id,
});
type : 'team-created',
title : 'Team erstellt',
message: `Das Team ${teamname} wurde erstellt.`,
teamId : newTeam.id,
})
return NextResponse.json({ message: 'Team erstellt', team: newTeam });
} catch (error: any) {
console.error('Fehler beim Team erstellen:', error.message, error.stack);
return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 });
return NextResponse.json({ message: 'Team erstellt', team: newTeam })
} catch (error) {
console.error('❌ Fehler beim Team erstellen:', error)
return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 })
}
}

View File

@ -1,67 +1,80 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
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) {
try {
const body = await req.json()
const { teamId, userIds: rawUserIds, invitedBy } = body
const { teamId, userIds: rawUserIds, invitedBy } = await req.json()
if (!teamId || !rawUserIds || !invitedBy) {
/* ------------------------------------------------------------ */
/* Eingaben prüfen */
/* ------------------------------------------------------------ */
if (!teamId || !Array.isArray(rawUserIds) || rawUserIds.length === 0) {
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({
where: { id: teamId },
where : { id: teamId },
select: { name: true },
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
const teamName = team.name ?? 'Unbekanntes Team'
const teamName = team.name || 'Unbekanntes Team'
const results = await Promise.all(
userIds.map(async (userId: string) => {
const invitation = await prisma.teamInvite.create({
/* ------------------------------------------------------------ */
/* Einladungen + Benachrichtigungen erzeugen */
/* ------------------------------------------------------------ */
const invitationIds = await Promise.all(
steamIds.map(async (steamId: string) => {
/* TeamInvite anlegen FELD-NAMEN ans Schema anpassen! */
const invite = await prisma.teamInvite.create({
data: {
userId,
teamId,
steamId,
type: 'team-invite',
},
})
/* Notification anlegen */
const notification = await prisma.notification.create({
data: {
userId,
title: 'Teameinladung',
message: `Du wurdest in das Team "${teamName}" eingeladen.`,
steamId,
title : 'Teameinladung',
message : `Du wurdest in das Team "${teamName}" eingeladen.`,
actionType: 'team-invite',
actionData: invitation.id,
actionData: invite.id,
},
})
/* SSE pushen */
await sendServerSSEMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [userId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
type : notification.actionType ?? 'notification',
targetUserIds: [steamId],
message : notification.message,
id : notification.id,
actionType : notification.actionType ?? undefined,
actionData : notification.actionData ?? undefined,
createdAt : notification.createdAt.toISOString(),
})
return invitation.id
})
return invite.id
}),
)
return NextResponse.json({ message: 'Einladungen versendet', invitationIds: results })
} catch (error) {
console.error('Fehler beim Versenden der Einladungen:', error)
return NextResponse.json({ message: 'Fehler beim Einladen' }, { status: 500 })
return NextResponse.json(
{ message: 'Einladungen versendet', invitationIds },
{ status: 200 },
)
} 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 { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
@ -6,12 +7,18 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
/* ------------------------------------------------------------------ *
* 1) Payload-Validierung *
* ------------------------------------------------------------------ */
const { teamId, steamId } = await req.json()
if (!teamId || !steamId) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
}
/* ------------------------------------------------------------------ *
* 2) Team & User laden *
* ------------------------------------------------------------------ */
const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) {
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({
where: { steamId },
select: { name: true }
select: { name: true },
})
const userName = user?.name ?? 'Ein Mitglied'
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({
where: { id: teamId },
data: {
activePlayers: { set: active },
inactivePlayers: { set: inactive },
data : {
activePlayers : { set: active },
inactivePlayers : { set: inactive },
},
})
/* der gekickte User gehört zu keinem Team mehr */
await prisma.user.update({
where: { steamId },
data: { teamId: null },
})
// 🟥 Gekickter User>
const notification = await prisma.notification.create({
data: {
userId: steamId,
title: 'Teamverlassen',
message: `Du wurdest aus dem Team "${teamName}" geworfen.`,
actionType: 'team-kick',
actionData: null,
},
})
await sendServerSSEMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [steamId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
data : { teamId: null },
})
// 🟩 Verbleibende Mitglieder
/* ------------------------------------------------------------------ *
* 4) Notifikation für den gekickten User *
* ------------------------------------------------------------------ */
const kickedNotification = await prisma.notification.create({
data: {
title : 'Team verlassen',
message : `Du wurdest aus dem Team „${teamName}“ geworfen.`,
actionType : 'team-kick',
actionData : null,
user : { connect: { steamId } }, // <-- Relation herstellen
},
})
await sendServerSSEMessage({
type : kickedNotification.actionType ?? 'notification',
targetUserIds: [steamId],
message : kickedNotification.message,
id : kickedNotification.id,
actionType : kickedNotification.actionType ?? undefined,
actionData : kickedNotification.actionData ?? undefined,
createdAt : kickedNotification.createdAt.toISOString(),
})
/* ------------------------------------------------------------------ *
* 5) Notifikation für verbleibende Mitglieder *
* ------------------------------------------------------------------ */
const remainingUserIds = [...active, ...inactive]
await Promise.all(
remainingUserIds.map(async (userId) => {
const notification = await prisma.notification.create({
remainingUserIds.map(async memberSteamId => {
const n = await prisma.notification.create({
data: {
userId,
title: 'Teamupdate',
message: `${userName} wurde aus dem Team "${teamName}" geworfen.`,
actionType: 'team-kick-other',
actionData: null,
title : 'Team-Update',
message : `${userName} wurde aus dem Team „${teamName}“ geworfen.`,
actionType : 'team-kick-other',
actionData : null,
user : { connect: { steamId: memberSteamId } }, // <-- Relation
},
})
await sendServerSSEMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [userId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
type : n.actionType ?? 'notification',
targetUserIds: [memberSteamId],
message : n.message,
id : n.id,
actionType : n.actionType ?? undefined,
actionData : n.actionData ?? undefined,
createdAt : n.createdAt.toISOString(),
})
})
)
/* ------------------------------------------------------------------ *
* 6) Erfolg *
* ------------------------------------------------------------------ */
return NextResponse.json({ message: 'Mitglied entfernt' })
} catch (error) {
console.error('[KICK] Fehler:', error)

View File

@ -58,7 +58,9 @@ export async function POST(req: NextRequest) {
const notification = await prisma.notification.create({
data: {
userId: steamId,
user: {
connect: { steamId },
},
title: 'Teamupdate',
message: `Du hast das Team "${team.name}" verlassen.`,
actionType: 'team-left',
@ -85,7 +87,9 @@ export async function POST(req: NextRequest) {
allRemainingPlayers.map(async (userId) => {
const notification = await prisma.notification.create({
data: {
userId,
user: {
connect: { steamId: userId },
},
title: 'Teamupdate',
message: `${user?.name ?? 'Ein Spieler'} hat das Team verlassen.`,
actionType: 'team-member-left',
@ -102,6 +106,12 @@ export async function POST(req: NextRequest) {
actionData: notification.actionData ?? undefined,
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
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() {
try {
/* 1. Alle Teams holen */
const teams = await prisma.team.findMany()
/* 1) Alle Teams mit reinen Steam-ID-Arrays holen ---------------- */
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 */
const teamsWithPlayers = await Promise.all(
teams.map(async (t) => {
const steamIds = [...t.activePlayers, ...t.inactivePlayers]
/* 2) Einmalig ALLE vorkommenden Steam-IDs sammeln --------------- */
const uniqueIds = new Set<string>()
teams.forEach(t => {
t.activePlayers.forEach(id => uniqueIds.add(id))
t.inactivePlayers.forEach(id => uniqueIds.add(id))
})
// User abrufen, die in active+inactive vorkommen
const players = await prisma.user.findMany({
where: { steamId: { in: steamIds } },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
},
})
/* 3) Die zugehörigen User-Objekte laden (ein Query) ------------- */
const users = await prisma.user.findMany({
where : { steamId: { in: [...uniqueIds] } },
select: {
steamId : true,
name : true,
avatar : true,
location : true,
premierRank: true,
},
})
return {
...t,
players,
}
})
)
/* 4) steamId → Player: Null-Werte abfangen ------------------------ */
const byId: Record<string, Player> = {}
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) {
console.error('GET /api/team/list failed:', err)
return NextResponse.json(
{ 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) {
try {
/* ---- Session prüfen ------------------------------------------ */
/* ───────────────── Session prüfen ────────────────────── */
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const requesterSteamId = session.user.steamId
/* ---- Body validieren ----------------------------------------- */
/* ───────────────── Body validieren ────────────────────── */
const { teamId } = await req.json()
if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
}
/* ---- Team holen ---------------------------------------------- */
/* ───────────────── Team holen ─────────────────────────── */
const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
/* ---- Bereits Mitglied? --------------------------------------- */
/* ───────────────── Bereits Mitglied? ──────────────────── */
if (
requesterSteamId === team.leaderId ||
team.activePlayers.includes(requesterSteamId) ||
@ -35,43 +35,47 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Du bist bereits Mitglied' }, { status: 400 })
}
/* ---- Doppelte Anfrage vermeiden ------------------------------ */
/* ───────────────── Doppelte Anfrage vermeiden ─────────── */
const existingInvite = await prisma.teamInvite.findFirst({
where: { steamId: requesterSteamId, teamId },
where: {
steamId: requesterSteamId,
teamId,
type : 'team-join-request',
},
})
if (existingInvite) {
return NextResponse.json({ message: 'Anfrage läuft bereits' }, { status: 200 })
}
/* ---- Invitation anlegen -------------------------------------- */
await prisma.teamInvite.create({
/* ───────────────── Invitation anlegen ─────────────────── */
const invitation = await prisma.teamInvite.create({
data: {
steamId: requesterSteamId, // User.steamId
teamId,
type: 'team-join-request',
steamId: requesterSteamId,
teamId ,
type : 'team-join-request',
},
})
/* ---- Leader benachrichtigen ---------------------------------- */
/* ───────────────── Leader benachrichtigen ─────────────── */
const notification = await prisma.notification.create({
data: {
steamId: team.leaderId!,
title: 'Beitrittsanfrage',
message: `${session.user.name ?? 'Ein Spieler'} möchte deinem Team beitreten.`,
steamId : team.leaderId!, // garantiert vorhanden
title : 'Beitrittsanfrage',
message : `${session.user.name ?? 'Ein Spieler'} möchte deinem Team beitreten.`,
actionType: 'team-join-request',
actionData: teamId,
actionData: invitation.id, // ← WICHTIG: invitationId
},
})
/* ---- SSE Event (optional) ------------------------------ */
/* ───────────────── SSE Event auslösen ─────────────────── */
await sendServerSSEMessage({
type: notification.actionType ?? 'notification',
type : notification.actionType ?? 'notification',
targetUserIds: [team.leaderId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
message : notification.message,
id : notification.id,
actionType : notification.actionType ?? undefined,
actionData : notification.actionData ?? undefined, // invitation.id
createdAt : notification.createdAt.toISOString(),
})
return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 })

View File

@ -61,7 +61,7 @@ export async function GET() {
return NextResponse.json({
team: {
id: team.id,
teamname: team.name,
name: team.name,
logo: team.logo,
leader: team.leaderId,
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 { NextResponse, type NextRequest } from 'next/server'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
@ -8,48 +7,112 @@ export async function POST(req: NextRequest) {
try {
const { teamId, newLeaderSteamId } = await req.json()
/* ────────────── Parameter prüfen ────────────── */
if (!teamId || !newLeaderSteamId) {
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
}
const team = await prisma.team.findUnique({
where: { id: teamId },
})
/* ────────────── Team holen ───────────────────── */
const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
}
const allPlayerIds = Array.from(new Set([
...(team.activePlayers || []),
...(team.inactivePlayers || []),
]))
/* ────────────── Mitgliedschaft prüfen ────────── */
const allPlayerIds = Array.from(
new Set([
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
]),
)
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({
where: { id: teamId },
data: { leader: newLeaderSteamId },
data : { leaderId: newLeaderSteamId },
})
/* ────────────── Namen des neuen Leaders ───────── */
const newLeader = await prisma.user.findUnique({
where: { steamId: newLeaderSteamId },
where : { steamId: newLeaderSteamId },
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({
type: 'team-leader-changed',
title: 'Neuer Teamleader',
message: `${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader.`,
teamId,
type : leaderNote.actionType ?? 'notification',
targetUserIds: [newLeaderSteamId],
message : leaderNote.message,
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,
teamId,
})
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
} catch (error) {
console.error('Fehler beim Leaderwechsel:', error)
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
return NextResponse.json({
message: 'Serverfehler beim Leaderwechsel.',
}, { status: 500 })
}
}

View File

@ -95,6 +95,12 @@ export async function POST(
actionData: notification.actionData ?? undefined,
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)
setRefetchKey(Date.now().toString()) // 🔥 Neuer Key zum Reload
setRefetchKey(Date.now().toString())
}, 1500)
} catch (err: any) {
@ -94,6 +94,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
setStatus('idle')
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
${
status === 'error'

View File

@ -2,6 +2,7 @@
import { useDroppable } from '@dnd-kit/core'
import { Player } from '../types/team'
import clsx from 'clsx'
type DroppableZoneProps = {
id: string
@ -10,19 +11,40 @@ type DroppableZoneProps = {
activeDragItem: Player | null
}
export function DroppableZone({ id, label, children, activeDragItem }: DroppableZoneProps) {
export function DroppableZone({
id,
label,
children,
}: DroppableZoneProps) {
const { isOver, setNodeRef } = useDroppable({ id })
const baseClasses = `
p-4 rounded-lg border-2 min-h-[200px] transition-all
${isOver ? 'border-blue-400 border-dashed bg-gray-200 dark:bg-neutral-800' : 'border-gray-300 dark:border-neutral-700'}
`
/* ───────────── sichtbare Zone ───────────── */
const zoneClasses = clsx(
// 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 (
<div ref={setNodeRef} className={baseClasses}>
<h3 className="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">{label}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-4">
{children}
<div className="space-y-2">
<h3 className="text-md font-semibold text-gray-700 dark:text-gray-300">
{label}
</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>
)

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'
import { useEffect, useState } from 'react'
import Modal from '@/app/components/Modal'
import MiniCard from '@/app/components/MiniCard'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import { useSession } from 'next-auth/react'
import { Player } from '@/app/types/team'
import { Team } from '@/app/types/team'
import { useSession } from 'next-auth/react'
import {
DndContext, closestCenter, DragOverlay,
} from '@dnd-kit/core'
import {
SortableContext, verticalListSortingStrategy,
} from '@dnd-kit/sortable'
type Props = {
show: boolean
onClose: () => void
matchId: string
teamA: Team
teamB: Team
initialPlayersA: string[]
initialPlayersB: string[]
import Modal from '@/app/components/Modal'
import SortableMiniCard from '@/app/components/SortableMiniCard'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import { DroppableZone } from '@/app/components/DroppableZone'
import type { Player, Team } from '@/app/types/team'
/* ───────────────────────── 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
}
export default function EditMatchPlayersModal({
show,
onClose,
matchId,
teamA,
teamB,
initialPlayersA,
initialPlayersB,
onSaved,
}: Props) {
/* ───────────────────── Komponente ──────────────────────── */
export default function EditMatchPlayersModal (props: Props) {
const {
show, onClose, matchId,
teamA, teamB, side,
initialA, initialB,
onSaved,
} = props
/* ---- Rollen-Check --------------------------------------- */
const { data: session } = useSession()
const [playersA, setPlayersA] = useState<Player[]>([])
const [playersB, setPlayersB] = useState<Player[]>([])
const [selectedA, setSelectedA] = useState<string[]>([])
const [selectedB, setSelectedB] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [saved, setSaved] = useState(false)
const meSteam = session?.user?.steamId
const isAdmin = session?.user?.isAdmin
const isLeader = side === 'A'
? meSteam === teamA.leader
: meSteam === teamB.leader
const canEdit = isAdmin || isLeader
const steamId = session?.user?.steamId
const isLeaderA = steamId && teamA?.leader && steamId === teamA.leader
const isLeaderB = steamId && teamB?.leader && steamId === teamB.leader
const isAdmin = session?.user?.isAdmin
/* ---- States --------------------------------------------- */
const [players, setPlayers] = useState<Player[]>([])
const [selected, setSelected] = useState<string[]>([])
const [dragItem, setDragItem] = useState<Player | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const canEdit = isAdmin || isLeaderA || isLeaderB
if (!teamA || !teamB) return <LoadingSpinner />
/* ---- Team-Info ------------------------------------------ */
const team = side === 'A' ? teamA : teamB
const other = side === 'A' ? teamB : teamA
const otherInit = side === 'A' ? initialB : initialA
const myInit = side === 'A' ? initialA : initialB
/* ---- Komplett-Spielerliste laden ------------------------ */
useEffect(() => {
if (show) {
fetchTeamPlayers()
setSelectedA(initialPlayersA)
setSelectedB(initialPlayersB)
setSaved(false)
}
}, [show])
if (!show) return
(async () => {
try {
const res = await fetch(`/api/team/${team.id}`)
const data = await res.json()
const fetchTeamPlayers = async () => {
try {
const [resA, resB] = await Promise.all([
fetch(`/api/team/${teamA.id}`).then(res => res.json()),
fetch(`/api/team/${teamB.id}`).then(res => res.json()),
])
setPlayersA(resA.activePlayers || [])
setPlayersB(resB.activePlayers || [])
} catch (err) {
console.error('Fehler beim Laden der Spieler:', err)
}
/* ❶ aktive + inaktive Spieler zusammenführen */
const all = [
...(data.activePlayers as Player[] ?? []),
...(data.inactivePlayers as Player[] ?? []),
].filter((p, i, arr) => arr.findIndex(x => x.steamId === p.steamId) === i) // dedupe
setPlayers(all.sort((a, b) => a.name.localeCompare(b.name)))
setSelected(myInit) // übernommene Line-up
setSaved(false)
} 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) => {
if (team === 'A') {
setSelectedA(prev =>
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
)
} else {
setSelectedB(prev =>
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
)
}
const onDragEnd = ({ active, over }: any) => {
setDragItem(null)
if (!over) return
const id = active.id as string
const dropZone = over.id as string // "active" | "inactive"
const already = selected.includes(id)
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 () => {
setLoading(true)
setSaving(true)
try {
const players = [
...selectedA.map(userId => ({ userId, teamId: teamA.id })),
...selectedB.map(userId => ({ userId, teamId: teamB.id })),
]
const body = {
players: [
/* 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}`, {
method: 'PUT',
method : 'PUT',
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)
onSaved?.()
} catch (err) {
console.error('Speichern fehlgeschlagen:', err)
} catch (e) {
console.error('[EditMatchPlayersModal] save error:', e)
} 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 (
<Modal
id="edit-match-players-modal"
title="Spieler bearbeiten"
show={show}
id="edit-match-players"
title={`Spieler bearbeiten ${team.name ?? 'Team'}`}
show
onClose={onClose}
onSave={handleSave}
closeButtonTitle={saved ? '✓ gespeichert' : 'Speichern'}
closeButtonTitle={
saved ? '✓ gespeichert' : saving ? 'Speichern …' : 'Speichern'
}
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">
Du bist kein Teamleiter dieses Matches.
Du darfst dieses Team nicht bearbeiten.
</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>
<h3 className="font-semibold mb-2">{teamB.teamname}</h3>
{playersB.length === 0 ? (
<LoadingSpinner />
) : (
<div className="space-y-2">
{playersB.map((p) => (
<MiniCard
{canEdit && (
<>
{players.length === 0 && <LoadingSpinner />}
{players.length > 0 && (
<DndContext
collisionDetection={closestCenter}
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}
title={p.name}
avatar={p.avatar}
steamId={p.steamId}
location={p.location}
selected={selectedB.includes(p.steamId)}
onSelect={() => toggleSelect('B', p.steamId)}
currentUserSteamId={steamId!}
teamLeaderSteamId={teamB.leader}
hideActions
player={p}
currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader}
isAdmin={!!session?.user?.isAdmin}
hideOverlay
/>
))}
</div>
)}
</div>
</div>
</SortableContext>
</DroppableZone>
{/* --- 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>

View File

@ -14,9 +14,10 @@ type Props = {
onClose: () => void
onSuccess: () => void
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 steamId = session?.user?.steamId
@ -63,14 +64,16 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
if (selectedIds.length === 0 || !steamId) return
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teamId: team.id,
userIds: selectedIds,
invitedBy: steamId,
}),
body: JSON.stringify(body),
})
@ -124,15 +127,21 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
return (
<Modal
id="invite-members-modal"
title="Mitglieder einladen"
title={directAdd ? 'Mitglieder hinzufügen' : 'Mitglieder einladen'}
show={show}
onClose={onClose}
onSave={handleInvite}
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">
Wähle Benutzer aus, die du in dein Team einladen möchtest:
<p className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
{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>
{/* Ausgewählte Benutzer anzeigen */}
{selectedIds.length > 0 && (
@ -184,7 +193,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
/>
{isSuccess && (
<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 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 />
) : filteredUsers.length === 0 ? (
<div className="col-span-full text-center text-gray-500 dark:text-neutral-400">
{allUsers.length === 0
? 'Niemand zum Einladen verfügbar :('
: 'Keine Benutzer gefunden.'}
{allUsers.length === 0
? directAdd
? 'Keine Benutzer verfügbar :('
: 'Niemand zum Einladen verfügbar :('
: 'Keine Benutzer gefunden.'}
</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)
if (success) {
onSuccess()
onClose()
}
} catch (err) {
console.error('Fehler beim Verlassen:', err)
} finally {
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:
</p>
<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)
.map((player: Player) => (
<MiniCard

View File

@ -1,136 +1,150 @@
// /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'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { Match, MatchPlayer } from '../types/match'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Table from './Table'
import { useRouter } from 'next/navigation';
import PremierRankBadge from './PremierRankBadge'
import CompRankBadge from './CompRankBadge'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
interface MatchDetailsProps {
match: Match
}
import Table from './Table'
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 {
if (typeof kills !== 'number' || typeof deaths !== 'number') return '-';
if (deaths === 0) return '∞';
return (kills / deaths).toFixed(2);
}
import type { Match, MatchPlayer } from '../types/match'
function calcADR(totalDamage?: number, roundCount?: number): string {
if (typeof totalDamage !== 'number' || typeof roundCount !== 'number' || roundCount === 0) {
return '-';
}
return (totalDamage / roundCount).toFixed(1);
}
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
const kdr = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number'
? d === 0 ? '∞' : (k / d).toFixed(2)
: '-'
export function MatchDetails({ match }: MatchDetailsProps) {
const router = useRouter();
const adr = (dmg?: number, rounds?: number) =>
typeof dmg === 'number' && typeof rounds === 'number' && rounds > 0
? (dmg / rounds).toFixed(1)
: '-'
const matchDate = match.demoDate
? format(new Date(match.demoDate), 'PPpp', { locale: de })
: 'Unbekannt'
/* ─────────────────── Komponente ─────────────────────────────── */
export function MatchDetails ({ match }: { match: Match }) {
const { data: session } = useSession()
const router = useRouter()
const renderPlayerTable = (players: MatchPlayer[]) => {
const sortedPlayers = [...players].sort((a, b) => {
const dmgA = a.stats?.totalDamage ?? 0;
const dmgB = b.stats?.totalDamage ?? 0;
return dmgB - dmgA;
});
/* ─── 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
console.log(match);
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
const dateString = match.matchDate ?? match.demoDate
const isFutureMatch = !!dateString && new Date(dateString).getTime() > Date.now()
/* ─── 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 (
<Table>
<ColGroup />
<Table.Head>
<Table.Row>
<Table.Cell as='th'>Spieler</Table.Cell>
<Table.Cell as='th'>Rank</Table.Cell>
<Table.Cell as='th'>K</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>
{['Spieler','Rank','K','A','D','1K','2K','3K','4K','5K',
'K/D','ADR','HS%','Damage'].map(h => (
<Table.Cell key={h} as="th">{h}</Table.Cell>
))}
</Table.Row>
</Table.Head>
<Table.Body>
{sortedPlayers.map((p: MatchPlayer, i) => (
{sorted.map(p => (
<Table.Row
key={i}
key={p.user.steamId}
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
<Table.Cell className="py-1 flex items-center gap-2">
{(
<img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name}
className="w-8 h-8 rounded-full"
/>
)}
<img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name}
className="w-8 h-8 rounded-full"
/>
{p.user.name ?? 'Unbekannt'}
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-[6px]">
{match.matchType === 'premier' ? (
<PremierRankBadge rank={p.stats?.rankNew ?? 0} />
) : (
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
)}
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span
className={`text-sm ${
p.stats?.rankChange > 0
? 'text-green-500'
: p.stats?.rankChange < 0
? 'text-red-500'
: ''
}`}
>
{p.stats?.rankChange > 0 ? '+' : ''}
{p.stats?.rankChange}
</span>
)}
{match.matchType === 'premier'
? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
: <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
{match.matchType === 'premier' &&
typeof p.stats?.rankChange === 'number' && (
<span className={`text-sm ${
p.stats.rankChange > 0 ? 'text-green-500'
: p.stats.rankChange < 0 ? 'text-red-500' : ''}`}>
{p.stats.rankChange > 0 ? '+' : ''}
{p.stats.rankChange}
</span>
)}
</div>
</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k5 ?? '-'}</Table.Cell>
<Table.Cell>{calcKDR(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{calcADR(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k5 ?? '-'}</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?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)
)
}
/* ─── Ausgabe-Datum ───────────────────────────────────────── */
const readableDate = dateString
? format(new Date(dateString), 'PPpp', { locale: de })
: 'Unbekannt'
/* ─── Render ─────────────────────────────────────────────── */
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">
Match auf {match.map} ({match.matchType})
</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">
<strong>Teams:</strong>{' '}
@ -141,21 +155,65 @@ export function MatchDetails({ match }: MatchDetailsProps) {
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</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>
<h2 className="text-xl font-semibold mb-2">
{match.teamA?.name ?? 'Team A'}
</h2>
{renderPlayerTable(match.teamA.players)}
<div className="flex items-center justify-between mb-2">
<h2 className="text-xl font-semibold">
{match.teamA?.name ?? 'Team A'}
</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>
{/* Team B */}
<div>
<h2 className="text-xl font-semibold mb-2">
{match.teamB?.name ?? 'Team B'}
</h2>
{renderPlayerTable(match.teamB.players)}
<div className="flex items-center justify-between mb-2">
<h2 className="text-xl font-semibold">
{match.teamB?.name ?? 'Team B'}
</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>
{/* ───────── 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>
)
}

View File

@ -14,7 +14,7 @@ type MiniCardProps = {
isLeader?: boolean
draggable?: boolean
currentUserSteamId: string
teamLeaderSteamId: string
teamLeaderSteamId?: string | null
location?: string
rank?: number
dragListeners?: any
@ -22,6 +22,8 @@ type MiniCardProps = {
onPromote?: (steamId: string) => void
hideActions?: boolean
hideOverlay?: boolean
isSelectable?: boolean
isAdmin?: boolean
}
export default function MiniCard({
@ -42,13 +44,15 @@ export default function MiniCard({
onPromote,
hideActions = false,
hideOverlay = false,
isSelectable = true,
isAdmin = false,
}: MiniCardProps) {
const isSelectable = typeof onSelect === 'function'
const canKick = currentUserSteamId === teamLeaderSteamId && steamId !== teamLeaderSteamId
//const isSelectable = typeof onSelect === 'function'
const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
const cardClasses = `
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
${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' : ''}
@ -62,10 +66,14 @@ export default function MiniCard({
}
const handleKickClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onKick?.(steamId)
}
const handlePromoteClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onPromote?.(steamId)
}
@ -75,7 +83,7 @@ export default function MiniCard({
return (
<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 ${
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
onClick={onClick}
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
border-gray-300 dark:border-neutral-700
`}

View File

@ -2,6 +2,14 @@
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 = {
id: string
title: string
@ -10,8 +18,10 @@ type ModalProps = {
onClose?: () => void
onSave?: () => void
hideCloseButton?: boolean
closeButtonColor?: string
closeButtonColor?: 'blue' | 'red' | 'green' | 'teal'
closeButtonTitle?: string
disableSave?: boolean
maxWidth?: Width
}
export default function Modal({
@ -22,132 +32,136 @@ export default function Modal({
onClose,
onSave,
hideCloseButton = false,
closeButtonColor = "blue",
closeButtonTitle = "Speichern"
closeButtonColor = 'blue',
closeButtonTitle = 'Speichern',
disableSave,
maxWidth = 'sm:max-w-lg',
}: ModalProps) {
/* ───────── Overlay-Lifecycle ───────── */
useEffect(() => {
const modalEl = document.getElementById(id);
const hs = (window as any).HSOverlay;
const modalEl = document.getElementById(id)
const hs = (window as any).HSOverlay
if (!modalEl || !hs) return
const handleClose = () => {
if (typeof onClose === 'function') {
onClose();
/* Collection kann undefined oder ein Objekt sein.
Wir sichern uns ab und behandeln nur echte Arrays. */
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 {
if (typeof hs?.autoInit === 'function') {
hs.autoInit();
}
if (modalEl && typeof hs?.open === 'function') {
hs.open(modalEl);
}
} catch (err) {
console.error('[Modal] Fehler beim Öffnen des Modals:', err);
try {
if (show) {
destroyIfExists()
hs.autoInit?.()
hs.open?.(modalEl)
} else {
hs.close?.(modalEl)
destroyIfExists()
}
};
const tryClose = () => {
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();
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Modal] HSOverlay Fehler:', err)
}
return () => {
modalEl?.removeEventListener('hsOverlay:close', handleClose);
};
}, [show, id]);
modalEl.removeEventListener('hsOverlay:close', handleClose)
destroyIfExists()
}
}, [show, id, onClose])
/* ───────── Render ───────── */
return (
<div
id={id}
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"
tabIndex={-1}
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">
<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="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>
{/* Backdrop */}
<div className="fixed inset-0 -z-10 bg-black/50 dark:bg-neutral-900/70" />
<div className="p-4 overflow-visible">
{children}
</div>
{/* Dialog */}
<div className={`hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500
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 && (
<button
type="button"
onClick={onClose}
data-hs-overlay={`#${id}`}
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"
{!hideCloseButton && (
<button
type="button"
aria-label="Close"
data-hs-overlay={`#${id}`}
onClick={onClose}
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
</button>
)}
{onSave && (
<button
type="button"
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`}
>
{closeButtonTitle}
</button>
)}
</div>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
{/* Body */}
<div className="p-4 overflow-visible">{children}</div>
{/* Footer */}
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
{!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>
);
)
}

View File

@ -20,7 +20,7 @@ export default function Navbar({ children }: { children?: React.ReactNode }) {
</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="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">

View File

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

View File

@ -7,6 +7,9 @@ import { useSession } from 'next-auth/react'
import { useTeamManager } from '../hooks/useTeamManager'
import { useRouter } from 'next/navigation'
/* ────────────────────────────────────────────────────────── */
/* Typen */
/* ────────────────────────────────────────────────────────── */
type Notification = {
id: string
text: string
@ -16,8 +19,11 @@ type Notification = {
createdAt?: string
}
/* ────────────────────────────────────────────────────────── */
/* Komponente */
/* ────────────────────────────────────────────────────────── */
export default function NotificationCenter() {
/* --- Hooks & States ------------------------------------ */
const { data: session } = useSession()
const [notifications, setNotifications] = useState<Notification[]>([])
const [open, setOpen] = useState(false)
@ -28,20 +34,18 @@ export default function NotificationCenter() {
const [showPreview, setShowPreview] = useState(false)
const [animateBell, setAnimateBell] = useState(false)
/* --- Aktionen beim Klick auf eine Notification ---------- */
const onNotificationClick = (notification: Notification) => {
if (!notification.actionData) return;
if (!notification.actionData) return
try {
const data = JSON.parse(notification.actionData);
console.error('Weiterleitung: ', notification.actionData);
if (data.redirectUrl) {
router.push(data.redirectUrl);
}
const data = JSON.parse(notification.actionData)
if (data.redirectUrl) router.push(data.redirectUrl)
} catch (err) {
console.error('Ungültige actionData:', err);
console.error('[NotificationCenter] Ungültige actionData:', err)
}
}
/* --- Initiale Daten laden + SSE verbinden --------------- */
useEffect(() => {
const steamId = session?.user?.steamId
if (!steamId) return
@ -52,12 +56,12 @@ export default function NotificationCenter() {
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
const loaded = data.notifications.map((n: any) => ({
id: n.id,
text: n.message,
read: n.read,
id : n.id,
text : n.message,
read : n.read,
actionType: n.actionType,
actionData: n.actionData,
createdAt: n.createdAt,
createdAt : n.createdAt,
}))
setNotifications(loaded)
} catch (err) {
@ -66,44 +70,33 @@ export default function NotificationCenter() {
}
loadNotifications()
connect(steamId)
connect(steamId) // SSE starten
}, [session?.user?.steamId, connect])
/* --- Live-Updates über SSE empfangen -------------------- */
useEffect(() => {
if (!source) return
/* Handler für JEDES eintreffende Paket ------------------ */
const handleEvent = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'heartbeat') return
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
if (data.type === 'heartbeat') return // Ping ignorieren
/* Neues Notification-Objekt erzeugen */
const newNotification: Notification = {
id: data.id,
text: data.message || 'Neue Benachrichtigung',
read: false,
id : data.id ?? crypto.randomUUID(),
text : data.message ?? 'Neue Benachrichtigung',
read : false,
actionType: data.actionType,
actionData: data.actionData,
createdAt: data.createdAt,
createdAt : data.createdAt ?? new Date().toISOString(),
}
/* State updaten (immer oben einsortieren) */
setNotifications(prev => [newNotification, ...prev])
/* Glocke & Vorschau animieren ---------------------- */
setPreviewText(newNotification.text)
setShowPreview(true)
setAnimateBell(true)
@ -114,16 +107,46 @@ export default function NotificationCenter() {
setAnimateBell(false)
}, 3000)
} catch (err) {
console.error('[SSE] Ungültige Nachricht:', event)
console.error('[SSE] Ungültige Nachricht:', event.data, err)
}
}
source.addEventListener('notification', handleEvent)
return () => source.removeEventListener('notification', handleEvent)
/* Liste aller Event-Namen, die der Server schicken kann */
const eventNames = [
'notification',
'invitation',
'team-invite',
'team-joined',
'team-member-joined',
'team-kick',
'team-kick-other',
'team-left',
'team-member-left',
'team-leader-changed',
'team-leader-self',
'team-join-request',
'expired-sharecode',
]
/* Named Events abonnieren ------------------------------ */
eventNames.forEach(evt => source.addEventListener(evt, handleEvent))
/* Fallback: Server sendet evtl. Events ohne „event:“----- */
source.onmessage = handleEvent
/* Aufräumen bei Unmount -------------------------------- */
return () => {
eventNames.forEach(evt => source.removeEventListener(evt, handleEvent))
source.onmessage = null
}
}, [source])
/* ────────────────────────────────────────────────────────── */
/* Render */
/* ────────────────────────────────────────────────────────── */
return (
<div className="fixed bottom-6 right-6 z-50">
{/* Glocke -------------------------------------------------- */}
<button
type="button"
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
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
>
{/* Vorschautext */}
{/* Vorschau-Text --------------------------------------- */}
{previewText && (
<span className="truncate text-sm text-gray-800 dark:text-white">
{previewText}
</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">
<svg
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
@ -155,7 +178,8 @@ export default function NotificationCenter() {
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z"
/>
</svg>
{/* Badge (ungelesen) -------------------------------- */}
{notifications.some(n => !n.read) && (
<span className="flex absolute top-0 end-0 -mt-1 -me-1">
<span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span>
@ -166,8 +190,8 @@ export default function NotificationCenter() {
)}
</div>
</button>
{/* Dropdown */}
{/* Dropdown --------------------------------------------- */}
{open && (
<NotificationDropdown
notifications={notifications}
@ -177,7 +201,9 @@ export default function NotificationCenter() {
}}
onSingleRead={async (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)}
onAction={async (action, id) => {
@ -186,8 +212,8 @@ export default function NotificationCenter() {
prev.map(n =>
n.actionData === id
? { ...n, read: true, actionType: undefined, actionData: undefined }
: n
)
: n,
),
)
if (action === 'accept') router.refresh()
}}
@ -195,5 +221,5 @@ export default function NotificationCenter() {
/>
)}
</div>
)
)
}

View File

@ -69,6 +69,7 @@ export default function Pagination({
) : (
<button
key={page}
type="button"
onClick={() => onPageChange(page)}
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

View File

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

View File

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

View File

@ -1,99 +1,174 @@
// components/TeamCard.tsx
'use client'
import { useEffect, useState } from 'react'
import Button from './Button'
import { Team, Player } from '../types/team'
import { useLiveTeam } from '../hooks/useLiveTeam'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge'
import { useLiveTeam } from '../hooks/useLiveTeam'
import type { Team, Player } from '../types/team'
import LoadingSpinner from './LoadingSpinner'
type Props = {
team: Team
currentUserSteamId: string
invitationId?: string
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)
/* ---------- Live-Daten ---------- */
const data = useLiveTeam(team)
if (!data || !data.players) {
return <p className="text-sm text-gray-400">Lade Team </p>
}
if (!data) return <LoadingSpinner />
const players: Player[] = [
...(data.activePlayers ?? []),
...(data.inactivePlayers ?? []),
]
/* ---------- Join / Reject ---------- */
const isRequested = Boolean(invitationId)
const isDisabled = joining || currentUserSteamId === data.leader
const handleClick = async () => {
if (joining) return
setJoining(true)
try {
if (invitationId) {
if (isRequested) {
await fetch('/api/user/invitations/reject', {
method: 'POST',
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId }),
body : JSON.stringify({ invitationId }),
})
onUpdateInvitation(data.id, null)
} else {
const res = await fetch('/api/team/request-join', {
method: 'POST',
await fetch('/api/team/request-join', {
method : 'POST',
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, 'dummy-id') // ← bei Bedarf mit realer ID aktualisieren
onUpdateInvitation(data.id, 'pending')
}
} catch (err) {
console.error('Fehler bei Join/Reject:', err)
console.error('[TeamCard] Join/Reject-Fehler:', err)
} finally {
setJoining(false)
}
}
const isRequested = !!invitationId
const isDisabled = joining || currentUserSteamId === data.leader
/* ---------- Ziel-URL berechnen ---------- */
const targetHref = adminMode
? `/admin/teams/${data.id}`
: `/team/${data.id}`
/* ---------- Render ---------- */
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 gap-3">
<img
src={data.logo ? `/assets/img/logos/${data.logo}` : '/assets/img/logos/placeholder.png'}
alt={data.teamname ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
src={
data.logo
? `/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'}
</span>
<div className="flex items-center gap-2">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
{data.name ?? 'Team'}
</span>
<TeamPremierRankBadge players={players} />
</div>
</div>
<Button
title={isRequested ? 'Angefragt (zurückziehen)' : 'Beitreten'}
size="sm"
color={isRequested ? 'gray' : 'blue'}
disabled={isDisabled}
onClick={(e: any) => {
e.stopPropagation()
handleClick()
}}
>
{joining ? '...' : isRequested ? 'Angefragt' : 'Beitreten'}
</Button>
{adminMode ? (
<Button
title="Verwalten"
size="sm"
color="blue"
onClick={e => {
e.stopPropagation() // ▼ Navigation hier unterbinden
router.push(`/admin/teams/${data.id}`)
}}
>
Verwalten
</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>
{/* Avatare */}
<div className="flex -space-x-3">
{data.players.slice(0, 5).map((p) => (
{players.slice(0, 5).map(p => (
<img
key={p.steamId}
src={p.avatar}
alt={p.name}
title={p.name}
className="w-8 h-8 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
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">
+{data.players.length - 5}
{players.length > 5 && (
<span
key="more"
className="w-8 h-8 flex items-center justify-center
rounded-full bg-gray-200 text-xs"
>
+{players.length - 5}
</span>
)}
</div>

View File

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

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { DroppableZone } from './DroppableZone'
import MiniCard from './MiniCard'
import MiniCardDummy from './MiniCardDummy'
import SortableMiniCard from './SortableMiniCard'
import LeaveTeamModal from './LeaveTeamModal'
@ -17,6 +18,7 @@ import { useTeamManager } from '../hooks/useTeamManager'
import Button from './Button'
import Image from 'next/image'
import TeamPremierRankBadge from './TeamPremierRankBadge'
import Link from 'next/link'
type Props = {
team: Team | null
@ -33,6 +35,7 @@ type Props = {
setIsDragging: (v: boolean) => void
setactivePlayers: (players: Player[]) => void
setInactivePlayers: (players: Player[]) => void
adminMode?: boolean
}
export default function TeamMemberView({
@ -49,6 +52,7 @@ export default function TeamMemberView({
setIsDragging,
setactivePlayers,
setInactivePlayers,
adminMode = false,
}: Props) {
const { data: session } = useSession()
const { source, connect } = useSSE()
@ -57,11 +61,14 @@ export default function TeamMemberView({
const currentUserSteamId = session?.user?.steamId || ''
const isLeader = currentUserSteamId === team?.leader
const canManage = adminMode || isLeader
const canInvite = isLeader && !adminMode
const canAddDirect = adminMode
const { leaveTeam, reloadTeam, renameTeam, deleteTeam } = useTeamManager({}, null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = 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 [logoPreview, setLogoPreview] = useState<string | null>(null)
const [logoFile, setLogoFile] = useState<File | null>(null)
@ -79,44 +86,58 @@ export default function TeamMemberView({
useEffect(() => {
if (!source || !team?.id) return
if (!source || !teamState?.id) return
const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data)
const handleMessage = (e: MessageEvent) => {
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 = [
'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') {
/* EIN Aufruf genügt holt Team + Spieler + setzt States */
fetch(`/api/team/${encodeURIComponent(data.teamId)}`)
.then((res) => res.json())
.then((data) => {
setactivePlayers(
(data.activePlayers ?? [])
.filter((p: Player) => p?.name)
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
);
setInactivePlayers(
(data.inactivePlayers ?? [])
.filter((p: Player) => p?.name)
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
);
})
.then(r => r.json())
.then(fresh => {
setTeamState(fresh)
setactivePlayers((fresh.activePlayers ?? [])
.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
setInactivePlayers((fresh.inactivePlayers ?? [])
.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
})
} catch (err) {
console.error('SSE parse error:', err)
}
}
source.addEventListener('message', handleMessage)
return () => source.removeEventListener('message', handleMessage)
}, [source, team?.id])
const eventNames = [
'team-updated',
'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) => {
@ -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[]) => (
<AnimatePresence>
{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 }}>
<SortableMiniCard
player={player}
onKick={setKickCandidate}
onPromote={() => setPromoteCandidate(player)}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader}
isDraggingGlobal={isDragging}
hideOverlay={isDragging}
matchParentBg={true}
/>
<motion.div
key={player.steamId}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className='max-w-[160px]'
>
{/* ✨ Link zur Profil-Seite */}
<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>
))}
</AnimatePresence>
)
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 items-center gap-4">
{/* Teamlogo mit Fallback */}
<div className="relative group">
<div
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
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"
fill
sizes="64px"
@ -268,7 +311,7 @@ export default function TeamMemberView({
/>
{/* 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">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -283,7 +326,7 @@ export default function TeamMemberView({
</div>
{/* Hidden file input */}
{isLeader && (
{canManage && (
<input
type="file"
accept="image/*"
@ -359,7 +402,7 @@ export default function TeamMemberView({
variant="ghost"
onClick={() => {
setIsEditingName(false)
setEditedName(teamState.teamname ?? '')
setEditedName(teamState.name ?? '')
}}
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">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
{teamState.teamname ?? 'Team'}
{teamState.name ?? 'Team'}
</h2>
<TeamPremierRankBadge players={activePlayers} />
</div>
{isLeader && (
{canManage && (
<Button
title="Bearbeiten"
color="blue"
@ -393,7 +436,7 @@ export default function TeamMemberView({
variant="soft"
onClick={() => {
setIsEditingName(true)
setEditedName(teamState.teamname || '')
setEditedName(teamState.name || '')
}}
className="h-[34px] px-3 flex items-center justify-center"
>
@ -414,8 +457,9 @@ export default function TeamMemberView({
{/* Aktionen */}
<div className="flex gap-2">
{isLeader && (
{canManage && (
<button
type="button"
onClick={() => setShowDeleteModal(true)}
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
type="button"
onClick={async () => {
if (isLeader) {
setShowLeaveModal(true)
@ -452,10 +497,10 @@ export default function TeamMemberView({
<DroppableZone id="inactive" label="Inaktives Team" activeDragItem={activeDragItem}>
<SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{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 }}>
<MiniCardDummy
title="Einladen"
title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
onClick={() => {
setShowInviteModal(false)
setTimeout(() => setShowInviteModal(true), 0)
@ -479,21 +524,46 @@ export default function TeamMemberView({
player={activeDragItem}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader}
isAdmin={!!session?.user?.isAdmin}
hideOverlay
matchParentBg
/>
)}
</DragOverlay>
</DndContext>
{isLeader && (
<>
<LeaveTeamModal show={showLeaveModal} onClose={() => setShowLeaveModal(false)} onSuccess={() => setShowLeaveModal(false)} team={teamState} />
<InvitePlayersModal show={showInviteModal} onClose={() => setShowInviteModal(false)} onSuccess={() => {}} team={teamState} />
</>
{/* Modal(s) */}
{canInvite && (
<InvitePlayersModal
show={showInviteModal}
onClose={() => setShowInviteModal(false)}
onSuccess={() => {}}
team={teamState}
/>
)}
{canAddDirect && (
<InvitePlayersModal
show={showInviteModal}
onClose={() => setShowInviteModal(false)}
onSuccess={() => {}}
team={teamState}
directAdd
/>
)}
{isLeader && promoteCandidate && (
{/* Leader-spezifische Modale (z. B. Team verlassen) */}
{isLeader && (
<LeaveTeamModal
show={showLeaveModal}
onClose={() => setShowLeaveModal(false)}
onSuccess={() => setShowLeaveModal(false)}
team={teamState}
/>
)}
{canManage && promoteCandidate && (
<Modal
id={`modal-promote-player-${promoteCandidate.steamId}`}
title="Leader übertragen"
@ -506,13 +576,30 @@ export default function TeamMemberView({
closeButtonTitle="Übertragen"
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">
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Teamleader machen?
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
</p>
</Modal>
)}
{isLeader && kickCandidate && (
{canManage && kickCandidate && (
<Modal
id={`modal-kick-player-${kickCandidate.steamId}`}
title="Mitglied entfernen"
@ -522,13 +609,30 @@ export default function TeamMemberView({
closeButtonTitle="Entfernen"
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">
Möchtest du <strong>{kickCandidate.name}</strong> wirklich aus dem Team entfernen?
</p>
</Modal>
)}
{isLeader && (
{canManage && (
<Modal
id="modal-delete-team"
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'
import { useSession } from 'next-auth/react'
import Chart from '@/app/components/Chart'
import { MatchStats } from '@/app/types/match'
import Card from './Card'
import UserClips from './UserClips'
type MatchStatsProps = {
stats: { matches: MatchStats[] }
}
export default function UserProfile({ stats }: MatchStatsProps) {
const { data: session } = useSession()
const steamId = session?.user?.steamId ?? '' // ← für UserClips
const { matches } = stats
const totalKills = matches.reduce((sum, m) => sum + m.kills, 0)
@ -77,6 +82,14 @@ export default function UserProfile({ stats }: MatchStatsProps) {
]}
/>
</Card>
{/* ► Allstar-Clips des aktuellen Users -------------------------- */}
{steamId && (
<Card>
<h3 className="text-lg font-semibold mb-2">Highlights</h3>
<UserClips steamId={steamId} />
</Card>
)}
</div>
{/* Breite Diagramme */}

View File

@ -4,12 +4,12 @@ import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Modal from '@/app/components/Modal'
import Select from '@/app/components/Select'
import Input from './Input'
import Button from './Button'
import DatePickerWithTime from './DatePickerWithTime'
import Input from '../Input'
import Button from '../Button'
import DatePickerWithTime from '../DatePickerWithTime'
import Link from 'next/link'
import Image from 'next/image'
import Switch from './Switch'
import Switch from '../Switch'
function getRoundedDate() {
const now = new Date()
@ -37,7 +37,6 @@ export default function MatchesAdminManager() {
const [description, setDescription] = useState('')
const [matchDate, setMatchDate] = useState(getRoundedDate())
const [showModal, setShowModal] = useState(false)
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
useEffect(() => {
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 teamB = teams.find(t => t.id === teamBId)
if (teamA && teamB) {
setTitle(`${teamA.teamname} vs ${teamB.teamname}`)
setTitle(`${teamA.name} vs ${teamB.name}`)
}
}
}, [teamAId, teamBId, teams, titleManuallySet])
const fetchMatches = async () => {
const res = await fetch('/api/matches')
if (res.ok) {
const data = await res.json()
setMatches(data)
}
if (res.ok) setMatches(await res.json())
}
const filteredMatches = onlyOwnTeam && session?.user?.team
? matches.filter((m: any) =>
m.teamA.id === session.user.team || m.teamB.id === session.user.team)
: matches
const filteredMatches = matches.filter(
(m: any) => m.matchType === 'community'
)
const resetFields = () => {
setTitle('')
@ -117,13 +112,13 @@ export default function MatchesAdminManager() {
<div className="flex flex-col items-center w-1/4">
<Image
src={getTeamLogo(match.teamA?.logo)}
alt={match.teamA?.teamname || 'Team A'}
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">
{match.teamA?.teamname || 'Team A'}
{match.teamA?.name || 'Team A'}
</span>
</div>
<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">
<Image
src={getTeamLogo(match.teamB?.logo)}
alt={match.teamB?.teamname || 'Team B'}
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">
{match.teamB?.teamname || 'Team B'}
{match.teamB?.name || 'Team B'}
</span>
</div>
</div>
@ -171,20 +166,20 @@ export default function MatchesAdminManager() {
>
<div className="grid grid-cols-2 gap-4 mb-4">
<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
value={teamAId}
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"
/>
</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
value={teamBId}
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"
/>
</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}
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
className={`py-3 px-2 text-sm font-semibold rounded-b-xl
${isChecked

View File

@ -1,3 +1,4 @@
// LatestKnownCodeSettings.tsx
'use client'
import Link from 'next/link'
@ -80,15 +81,31 @@ export default function LatestKnownCodeSettings({
required
/>
{!showError && (
<div className="absolute inset-y-0 end-0 flex items-center pe-3 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">
<div className="absolute top-1/2 end-3 -translate-y-1/2 pointer-events-none">
<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" />
</svg>
</div>
)}
{showError && (
<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>
)}
{isSaved && !showError && (

View File

@ -1,65 +1,47 @@
'use client'
import { useEffect, useState } from 'react'
import { Team } from '../types/team'
import type { Team, Player } from '../types/team'
const relevantEvents = [
'ws-team-renamed',
'ws-team-member-joined',
'ws-team-member-left',
'ws-team-kick',
'ws-team-kick-other',
'ws-team-leader-changed',
'ws-team-logo-updated'
]
const events = [
'ws-team-renamed',
'ws-team-member-joined',
'ws-team-member-left',
'ws-team-kick',
'ws-team-kick-other',
'ws-team-leader-changed',
'ws-team-logo-updated',
]
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(() => {
const update = async () => {
try {
const res = await fetch(`/api/team/get?id=${initialTeam.id}`)
if (!res.ok) return
const refresh = async () => {
const res = await fetch(`/api/team/get?id=${initialTeam.id}`)
if (!res.ok) return
const { team: t } = await res.json()
if (!t) return
const json = await res.json()
const updatedTeam = json?.team
if (!updatedTeam) return
const players = [
...(updatedTeam.activePlayers ?? []),
...(updatedTeam.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)
}
setTeam({
id : t.id,
name : t.teamname,
logo : t.logo,
leader: t.leader,
activePlayers : t.activePlayers ?? [],
inactivePlayers: t.inactivePlayers ?? [],
})
}
const handler = (e: Event) => {
const customEvent = e as CustomEvent
if (customEvent.detail?.teamId === initialTeam.id) {
update()
}
const ev = e as CustomEvent
if (ev.detail?.teamId === initialTeam.id) refresh()
}
for (const evt of relevantEvents) {
window.addEventListener(evt, handler)
}
return () => {
for (const evt of relevantEvents) {
window.removeEventListener(evt, handler)
}
}
events.forEach(evt => window.addEventListener(evt, handler))
return () => events.forEach(evt => window.removeEventListener(evt, handler))
}, [initialTeam.id])
return data
return team
}

View File

@ -11,7 +11,7 @@ export type Invitation = {
}
export function useTeamManager(
props: { refetchKey?: string },
props: { refetchKey?: string; teamId?: string },
ref: React.Ref<any>
) {
const [team, setTeam] = useState<Team | null>(null)
@ -29,7 +29,11 @@ export function useTeamManager(
const fetchTeam = async () => {
setIsLoading(true)
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) {
setTeam(null)
@ -39,25 +43,62 @@ export function useTeamManager(
}
if (!res.ok) throw new Error('Fehler beim Abrufen des Teams')
const data = await res.json()
if (!data.team) {
const teamData = data.team ?? data
if (!teamData || !teamData.id) {
setTeam(null)
setactivePlayers([])
setInactivePlayers([])
return
}
const newActive = data.team.activePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
const newInactive = data.team.inactivePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
// ── 1. evtl. nur Steam-IDs? ───────────────────────────────
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({
id: data.team.id,
teamname: data.team.teamname,
leader: data.team.leader,
logo: data.team.logo,
players: [...newActive, ...newInactive],
id: teamData.id,
name: teamData.name,
leader: teamData.leader,
logo: teamData.logo,
activePlayers : newActive,
inactivePlayers: newInactive,
})
setactivePlayers(newActive)
setInactivePlayers(newInactive)
@ -87,10 +128,13 @@ export function useTeamManager(
useEffect(() => {
const load = async () => {
if (props.teamId) // 👉 Admin-Detail: nur Team holen
await fetchTeam()
else // 👉 eigener User: Team + Einladungen
await Promise.all([fetchTeam(), fetchInvitations()])
}
load()
}, [props.refetchKey])
}, [props.refetchKey, props.teamId]) // teamId als Dep nicht vergessen
useWebSocketListener('ws-invitation', 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'
import Link from 'next/link'
import Image from 'next/image'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Switch from '@/app/components/Switch'
@ -10,8 +11,12 @@ type Match = {
id: string
title: string
matchDate: string
teamA: { id: string; teamname: string; logo?: string | null }
teamB: { id: string; teamname: string; logo?: string | null }
teamA: { id: string; name: 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() {
@ -21,16 +26,11 @@ export default function MatchesPage() {
useEffect(() => {
fetch('/api/schedule')
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) {
setMatches(data)
} else if (Array.isArray(data.schedules)) {
setMatches(data.schedules)
} else {
console.error("❌ Unerwartetes API-Format", data)
setMatches([])
}
.then(r => r.json())
.then(data => setMatches(Array.isArray(data.matches) ? data.matches : []))
.catch(err => {
console.error('[MatchesPage] /api/schedule fehlt oder Antwort fehlerhaft:', err)
setMatches([])
})
}, [])
@ -69,29 +69,47 @@ export default function MatchesPage() {
{filteredMatches.map(match => (
<li key={match.id}>
<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"
>
<div className="flex items-center justify-between text-center">
{/* Team A */}
<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">
{match.teamA?.teamname ?? 'Team A'}
{match.teamA?.name ?? 'Team A'}
</span>
</div>
{/* Datum / Zeit */}
<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).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>
{/* Team B */}
<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">
{match.teamB?.teamname ?? 'Team B'}
{match.teamB?.name ?? 'Team B'}
</span>
</div>
</div>

View File

@ -1,17 +1,18 @@
// /types/team.ts
export type Player = {
steamId: string
name: string
avatar: string
location?: string
premierRank?: number
isAdmin?: boolean
}
steamId : string
name : string
avatar : string
location? : string
premierRank?: number
isAdmin? : boolean
}
export type Team = {
id: string
teamname: string | null
logo: string | null
leader: string
players?: Player[]
}
id : string
name?: string | null
logo?: string | null
leader?: string | null
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<{
steamId?: string
teamId?: string
AND?: UserWhereInput | UserWhereInput[]
OR?: UserWhereInput[]
NOT?: UserWhereInput | UserWhereInput[]
@ -16193,6 +16192,7 @@ export namespace Prisma {
avatar?: StringNullableFilter<"User"> | string | null
location?: StringNullableFilter<"User"> | string | null
isAdmin?: BoolFilter<"User"> | boolean
teamId?: StringNullableFilter<"User"> | string | null
premierRank?: IntNullableFilter<"User"> | number | null
authCode?: StringNullableFilter<"User"> | string | null
lastKnownShareCode?: StringNullableFilter<"User"> | string | null
@ -16210,7 +16210,7 @@ export namespace Prisma {
demoFiles?: DemoFileListRelationFilter
createdSchedules?: ScheduleListRelationFilter
confirmedSchedules?: ScheduleListRelationFilter
}, "steamId" | "teamId">
}, "steamId">
export type UserOrderByWithAggregationInput = {
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",
"types": "index.d.ts",
"browser": "index-browser.js",