This commit is contained in:
Linrador 2025-08-04 23:40:53 +02:00
parent 90a3bdeb35
commit ad4fe7c29a
24 changed files with 692 additions and 781 deletions

View File

@ -17,7 +17,7 @@ export default function AdminPage() {
switch (activeTab) { switch (activeTab) {
case 'matches': case 'matches':
return ( return (
<Card title="Matches"> <Card title="Matches" maxWidth='auto'>
<MatchesAdminManager /> <MatchesAdminManager />
</Card> </Card>
) )

View File

@ -1,45 +1,45 @@
// /app/api/matches/route.ts
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
export async function GET() { export async function GET(req: Request) {
try { try {
/* optionalen Query-Parameter lesen */
const { searchParams } = new URL(req.url)
const matchType = searchParams.get('type') // z. B. "community"
/* falls übergeben ⇒ danach filtern */
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
where : matchType ? { matchType } : undefined,
orderBy: { demoDate: 'desc' }, orderBy: { demoDate: 'desc' },
include: { include: {
teamA : true, teamA : true,
teamB : true, teamB : true,
players: { players: { include: { user: true, stats: true, team: true } },
include: {
user: true,
stats: true,
team: true,
},
},
}, },
}) })
const formatted = matches.map(match => ({ /* … rest bleibt unverändert … */
id: match.id, const formatted = matches.map(m => ({
map: match.map, id : m.id,
demoDate: match.demoDate, map : m.map,
matchType: match.matchType, demoDate: m.demoDate,
scoreA: match.scoreA, matchType: m.matchType,
scoreB: match.scoreB, scoreA : m.scoreA,
winnerTeam: match.winnerTeam ?? null, scoreB : m.scoreB,
winnerTeam: m.winnerTeam ?? null,
teamA: { teamA: {
id: match.teamA?.id ?? null, id : m.teamA?.id ?? null,
name: match.teamA?.name ?? 'CT', name: m.teamA?.name ?? 'CT',
logo: match.teamA?.logo ?? null, logo: m.teamA?.logo ?? null,
score: match.scoreA, score: m.scoreA,
}, },
teamB: { teamB: {
id: match.teamB?.id ?? null, id : m.teamB?.id ?? null,
name: match.teamB?.name ?? 'T', name: m.teamB?.name ?? 'T',
logo: match.teamB?.logo ?? null, logo: m.teamB?.logo ?? null,
score: match.scoreB, score: m.scoreB,
}, },
players: match.players.map(p => ({ players: m.players.map(p => ({
steamId : p.steamId, steamId : p.steamId,
name : p.user?.name, name : p.user?.name,
avatar : p.user?.avatar, avatar : p.user?.avatar,
@ -47,7 +47,7 @@ export async function GET() {
teamId : p.teamId, teamId : p.teamId,
teamName: p.team?.name ?? null, teamName: p.team?.name ?? null,
})), })),
})); }))
return NextResponse.json(formatted) return NextResponse.json(formatted)
} catch (err) { } catch (err) {

View File

@ -1,40 +1,30 @@
// src/app/api/team/kick/route.ts import { NextRequest, NextResponse } from 'next/server'
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { removePlayerFromMatches } from '@/app/lib/removePlayerFromMatches'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
/* ------------------------------------------------------------------ * /* ───────── 1) Payload prüfen ───────── */
* 1) Payload-Validierung *
* ------------------------------------------------------------------ */
const { teamId, steamId } = await req.json() const { teamId, steamId } = await req.json()
if (!teamId || !steamId) { if (!teamId || !steamId) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 }) return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
} }
/* ------------------------------------------------------------------ * /* ───────── 2) Team + User laden ─────── */
* 2) Team & User laden *
* ------------------------------------------------------------------ */
const team = await prisma.team.findUnique({ where: { id: teamId } }) const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) { if (!team) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where : { steamId }, where : { steamId },
select: { name: true }, select: { name: true },
}) })
const userName = user?.name ?? 'Ein Mitglied' const userName = user?.name ?? 'Ein Mitglied'
const teamName = team.name ?? 'Unbekanntes Team' const teamName = team.name ?? 'Unbekanntes Team'
/* ------------------------------------------------------------------ * /* ───────── 3) Spieler aus Team-Arrays entfernen ───────── */
* 3) Spielerlisten aktualisieren *
* ------------------------------------------------------------------ */
const active = team.activePlayers.filter(id => id !== steamId) const active = team.activePlayers.filter(id => id !== steamId)
const inactive = team.inactivePlayers.filter(id => id !== steamId) const inactive = team.inactivePlayers.filter(id => id !== steamId)
@ -46,70 +36,63 @@ export async function POST(req: NextRequest) {
}, },
}) })
/* der gekickte User gehört zu keinem Team mehr */ /* ───────── 4) User vom Team lösen ───────── */
await prisma.user.update({ await prisma.user.update({ where: { steamId }, data: { teamId: null } })
where: { steamId },
data : { teamId: null },
})
/* ------------------------------------------------------------------ * /* ───────── 5) Spieler aus offenen Matches werfen ───────── */
* 4) Notifikation für den gekickten User * await removePlayerFromMatches(teamId, steamId)
* ------------------------------------------------------------------ */
const kickedNotification = await prisma.notification.create({ /* ───────── 6) Notifications & SSE ───────── */
/* an gekickten User */
const kickedN = await prisma.notification.create({
data: { data: {
user : { connect: { steamId } },
title : 'Team verlassen', title : 'Team verlassen',
message : `Du wurdest aus dem Team „${teamName}“ geworfen.`, message : `Du wurdest aus dem Team „${teamName}“ geworfen.`,
actionType : 'team-kick', actionType : 'team-kick',
actionData : null,
user : { connect: { steamId } }, // <-- Relation herstellen
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type : kickedNotification.actionType ?? 'notification', type : kickedN.actionType ?? 'notification',
targetUserIds: [steamId], targetUserIds: [steamId],
message : kickedNotification.message, id : kickedN.id,
id : kickedNotification.id, message : kickedN.message,
actionType : kickedNotification.actionType ?? undefined, createdAt : kickedN.createdAt.toISOString(),
actionData : kickedNotification.actionData ?? undefined,
createdAt : kickedNotification.createdAt.toISOString(),
}) })
/* ------------------------------------------------------------------ * /* an verbleibende Mitglieder */
* 5) Notifikation für verbleibende Mitglieder * const remaining = [...active, ...inactive]
* ------------------------------------------------------------------ */
const remainingUserIds = [...active, ...inactive]
await Promise.all( await Promise.all(
remainingUserIds.map(async memberSteamId => { remaining.map(async uid => {
const n = await prisma.notification.create({ const n = await prisma.notification.create({
data: { data: {
user : { connect: { steamId: uid } },
title : 'Team-Update', title : 'Team-Update',
message : `${userName} wurde aus dem Team „${teamName}“ geworfen.`, message : `${userName} wurde aus dem Team „${teamName}“ gekickt.`,
actionType : 'team-kick-other', actionType : 'team-kick-other',
actionData : null,
user : { connect: { steamId: memberSteamId } }, // <-- Relation
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type : n.actionType ?? 'notification', type : n.actionType ?? 'notification',
targetUserIds: [memberSteamId], targetUserIds: [uid],
message : n.message,
id : n.id, id : n.id,
actionType : n.actionType ?? undefined, message : n.message,
actionData : n.actionData ?? undefined,
createdAt : n.createdAt.toISOString(), createdAt : n.createdAt.toISOString(),
}) })
}) }),
) )
/* ------------------------------------------------------------------ * /* ► UI neu laden lassen */
* 6) Erfolg * await sendServerSSEMessage({
* ------------------------------------------------------------------ */ type : 'team-updated',
teamId,
targetUserIds : remaining,
})
return NextResponse.json({ message: 'Mitglied entfernt' }) return NextResponse.json({ message: 'Mitglied entfernt' })
} catch (error) { } catch (err) {
console.error('[KICK] Fehler:', error) console.error('[KICK] Fehler:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
} }
} }

View File

@ -1,16 +1,19 @@
import { NextResponse, type NextRequest } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { removePlayerFromTeam } from '@/app/lib/removePlayerFromTeam' import { removePlayerFromTeam } from '@/app/lib/removePlayerFromTeam'
import { removePlayerFromMatches } from '@/app/lib/removePlayerFromMatches'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { steamId } = await req.json() const { steamId } = await req.json()
if (!steamId) { if (!steamId) {
return NextResponse.json({ message: 'Steam ID fehlt' }, { status: 400 }) return NextResponse.json({ message: 'Steam-ID fehlt' }, { status: 400 })
} }
/* ───────── 1) Team ermitteln ───────── */
const team = await prisma.team.findFirst({ const team = await prisma.team.findFirst({
where: { where: {
OR: [ OR: [
@ -19,105 +22,89 @@ export async function POST(req: NextRequest) {
], ],
}, },
}) })
if (!team) return NextResponse.json({ message: 'Kein Team gefunden' }, { status: 404 })
if (!team) {
return NextResponse.json({ message: 'Kein Team gefunden.' }, { status: 404 })
}
const { activePlayers, inactivePlayers, leader } = removePlayerFromTeam( const { activePlayers, inactivePlayers, leader } = removePlayerFromTeam(
{ { activePlayers: team.activePlayers, inactivePlayers: team.inactivePlayers, leader: team.leaderId },
activePlayers: team.activePlayers, steamId,
inactivePlayers: team.inactivePlayers, )
leader: team.leaderId,
}, steamId)
/* ───────── 2) Team anpassen / löschen ───────── */
if (!leader) { if (!leader) {
await prisma.team.delete({ where: { id: team.id } }) await prisma.team.delete({ where: { id: team.id } })
} else { } else {
await prisma.team.update({ await prisma.team.update({
where: { id: team.id }, where: { id: team.id },
data : { data : {
leader: { leader: { connect: { steamId: leader } },
connect: { steamId: leader },
},
activePlayers, activePlayers,
inactivePlayers, inactivePlayers,
}, },
}) })
} }
await prisma.user.update({ /* ───────── 3) User lösen ───────── */
where: { steamId }, await prisma.user.update({ where: { steamId }, data: { teamId: null } })
data: { teamId: null },
})
const user = await prisma.user.findUnique({ /* ───────── 4) Spieler aus Matches entfernen ───────── */
where: { steamId }, await removePlayerFromMatches(team.id, steamId)
select: { name: true },
})
const notification = await prisma.notification.create({ /* ───────── 5) Notifications ───────── */
const user = await prisma.user.findUnique({ where: { steamId }, select: { name: true } })
const userName = user?.name ?? 'Ein Spieler'
const teamName = team.name ?? 'Dein Team'
const remaining = [...activePlayers, ...inactivePlayers].filter(id => id !== steamId)
/* an leavenden User */
const leaveN = await prisma.notification.create({
data: { data: {
user: { user : { connect: { steamId } },
connect: { steamId },
},
title : 'Teamupdate', title : 'Teamupdate',
message: `Du hast das Team "${team.name}" verlassen.`, message : `Du hast das Team „${teamName}“ verlassen.`,
actionType : 'team-left', actionType : 'team-left',
actionData: null,
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type: notification.actionType ?? 'notification', type : leaveN.actionType ?? 'notification',
targetUserIds: [steamId], targetUserIds: [steamId],
message: notification.message, id : leaveN.id,
id: notification.id, message : leaveN.message,
actionType: notification.actionType ?? undefined, createdAt : leaveN.createdAt.toISOString(),
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
}) })
const allRemainingPlayers = Array.from(new Set([ /* an verbleibende Mitglieder */
...activePlayers,
...inactivePlayers,
])).filter(id => id !== steamId)
await Promise.all( await Promise.all(
allRemainingPlayers.map(async (userId) => { remaining.map(async uid => {
const notification = await prisma.notification.create({ const n = await prisma.notification.create({
data: { data: {
user: { user : { connect: { steamId: uid } },
connect: { steamId: userId },
},
title : 'Teamupdate', title : 'Teamupdate',
message: `${user?.name ?? 'Ein Spieler'} hat das Team verlassen.`, message : `${userName} hat das Team verlassen.`,
actionType : 'team-member-left', actionType : 'team-member-left',
actionData: null,
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type: notification.actionType ?? 'notification', type : n.actionType ?? 'notification',
targetUserIds: [userId], targetUserIds: [uid],
message: notification.message, id : n.id,
id: notification.id, message : n.message,
actionType: notification.actionType ?? undefined, createdAt : n.createdAt.toISOString(),
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
}) })
}),
)
/* ► UI neu laden lassen */
if (remaining.length) {
await sendServerSSEMessage({ await sendServerSSEMessage({
type : 'team-updated', type : 'team-updated',
teamId : team.id, teamId : team.id,
targetUserIds: allRemainingPlayers, targetUserIds : remaining,
}) })
}) }
)
return NextResponse.json({ message: 'Erfolgreich aus dem Team entfernt' }) return NextResponse.json({ message: 'Erfolgreich aus dem Team entfernt' })
} catch (error) { } catch (err) {
console.error('Fehler beim Verlassen des Teams:', error) console.error('[LEAVE] Fehler:', err)
return NextResponse.json({ message: 'Fehler beim Verlassen des Teams' }, { status: 500 }) return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
} }
} }

View File

@ -1,20 +1,36 @@
// /app/api/user/[steamId]/matches/route.ts // /app/api/user/[steamId]/matches/route.ts
import { NextResponse } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
export async function GET( export async function GET(
_req: Request, req : NextRequest, // ← Request wird gebraucht!
{ params }: { params: { steamId: string } } { params }: { params: { steamId: string } },
) { ) {
const steamId = params.steamId const steamId = params.steamId
if (!steamId) { if (!steamId) {
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 }) return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
} }
/* ───────── Query-Parameter „types“ auslesen ───────── */
const { searchParams } = new URL(req.url)
// ?types=premier,competitive
const typesParam = searchParams.get('types') // string | null
const types = typesParam
? typesParam.split(',').map(t => t.trim()).filter(Boolean)
: [] // leer ⇒ kein Filter
/* ───────── Daten holen ───────── */
try { try {
const matchPlayers = await prisma.matchPlayer.findMany({ const matchPlayers = await prisma.matchPlayer.findMany({
where: { steamId }, where: {
steamId,
/* nur wenn Filter gesetzt ist */
...(types.length && {
match: { matchType: { in: types } },
}),
},
select: { select: {
teamId: true, teamId: true,
team : true, team : true,
@ -29,63 +45,63 @@ export async function GET(
matchType : true, matchType : true,
teamAId : true, teamAId : true,
teamBId : true, teamBId : true,
teamAUsers: true, teamAUsers : { select: { steamId: true } },
teamBUsers: true, teamBUsers : { select: { steamId: true } },
winnerTeam : true, winnerTeam : true,
}, },
}, },
stats: true, stats: true,
}, },
orderBy: {
match: { orderBy: { match: { demoDate: 'desc' } },
demoDate: 'desc',
},
},
}) })
const data = matchPlayers.map((mp) => { /* ───────── Aufbereiten fürs Frontend ───────── */
const match = mp.match const data = matchPlayers.map(mp => {
const m = mp.match
const stats = mp.stats const stats = mp.stats
const kills = stats?.kills ?? 0 const kills = stats?.kills ?? 0
const deaths = stats?.deaths ?? 0 const deaths = stats?.deaths ?? 0
const kdr = deaths > 0 ? (kills / deaths).toFixed(2) : '∞' const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
const roundCount = match.roundCount
const rankOld = stats?.rankOld ?? null const rankOld = stats?.rankOld ?? null
const rankNew = stats?.rankNew ?? null const rankNew = stats?.rankNew ?? null
const rankChange = const rankChange =
typeof rankNew === 'number' && typeof rankOld === 'number' rankNew != null && rankOld != null ? rankNew - rankOld : null
? rankNew - rankOld
: null
const matchType = match.matchType ?? 'community'
const isInTeamA = match.teamAUsers.some((user) => user.steamId === steamId) /* Team des Spielers ermitteln */
const playerTeam = isInTeamA ? 'CT' : 'T' const playerTeam =
m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
const scoreCT = match.scoreA ?? 0 const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
const scoreT = match.scoreB ?? 0
const score = `${scoreCT} : ${scoreT}`
return { return {
id: match.id, id : m.id,
map: match.map ?? 'Unknown', map : m.map ?? 'Unknown',
date: match.demoDate, date : m.demoDate?.toISOString() ?? '',
matchType, matchType : m.matchType ?? 'community',
score, score,
roundCount, roundCount: m.roundCount,
rankOld, rankOld,
rankNew, rankNew,
rankChange, rankChange,
kills, kills,
deaths, deaths,
kdr, kdr,
winnerTeam: match.winnerTeam ?? null,
team: playerTeam, winnerTeam: m.winnerTeam ?? null,
team : playerTeam, // „CT“ oder „T“
} }
}) })
return NextResponse.json(data) return NextResponse.json(data)
} catch (error) { } catch (err) {
console.error('[API] Fehler beim Laden der Matches:', error) console.error('[API] Fehler beim Laden der Matches:', err)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
} }
} }

View File

@ -0,0 +1,197 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Switch from '@/app/components/Switch'
import Button from './Button'
import { Match } from '../types/match'
import { differenceInMinutes } from 'date-fns'
type Props = { matchType?: string }
/* ------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------ */
const getTeamLogo = (logo?: string | null) =>
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
const toDateKey = (d: Date) => d.toISOString().slice(0, 10)
const weekdayDE = new Intl.DateTimeFormat('de-DE', { weekday: 'long' })
/* ------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------ */
export default function CommunityMatchList({ matchType }: Props) {
const { data: session } = useSession()
const router = useRouter()
const [matches, setMatches] = useState<Match[]>([])
const [onlyOwn, setOnlyOwn] = useState(false)
/* Daten laden */
useEffect(() => {
const url = `/api/matches${matchType ? `?type=${encodeURIComponent(matchType)}` : ''}`
fetch(url)
.then(r => (r.ok ? r.json() : []))
.then(setMatches)
.catch(err => console.error('[MatchList] Laden fehlgeschlagen:', err))
}, [matchType])
/* Sortieren + Gruppieren (ohne vorher zu filtern!) */
const grouped = (() => {
const sorted = [...matches].sort(
(a, b) => new Date(a.demoDate).getTime() - new Date(b.demoDate).getTime(),
)
const map = new Map<string, Match[]>()
for (const m of sorted) {
const key = toDateKey(new Date(m.demoDate))
map.set(key, [...(map.get(key) ?? []), m])
}
return Array.from(map.entries()) // [ [ '2025-08-28', [ … ] ], … ]
})()
/* Render */
return (
<div className="max-w-7xl mx-auto py-8 px-4 space-y-6">
{/* Kopfzeile ----------------------------------------------------- */}
<div className="flex items-center justify-between flex-wrap gap-y-4">
<h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300">
Geplante Matches
</h1>
<div className="flex items-center gap-4">
<Switch
id="only-own-team"
checked={onlyOwn}
onChange={setOnlyOwn}
labelRight="Nur mein Team anzeigen"
/>
{session?.user?.isAdmin && (
<Link href="/admin/matches">
<Button color="blue" onClick={() => router.push(`/admin/matches`)}>Match erstellen</Button>
</Link>
)}
</div>
</div>
{/* Inhalt ------------------------------------------------------- */}
{grouped.length === 0 ? (
<p className="text-gray-700 dark:text-neutral-300">Keine Matches geplant.</p>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{grouped.map(([dateKey, dayMatches], dayIdx) => {
const dateObj = new Date(dateKey + 'T00:00:00')
const dayLabel = `Tag #${dayIdx + 1} ${weekdayDE.format(dateObj)}`
return (
<div key={dateKey} className="flex flex-col gap-4">
{/* Tages-Header */}
<div className="bg-yellow-300 dark:bg-yellow-500 text-center py-2 font-bold tracking-wider">
{dayLabel}<br />
{dateKey}
</div>
{/* Matches des Tages */}
{dayMatches.map(m => {
/* 1⃣ Regeln --------------------------------------------- */
const demoDate = new Date(m.demoDate)
const started = demoDate <= Date.now()
const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null
const isLive = started && unfinished // ← live-Flag
const isOwnTeam =
session?.user?.team &&
(m.teamA.id === session.user.team || m.teamB.id === session.user.team)
/* Wenn nur-Own aktiv & nicht eigenes Match → abdunkeln */
const dimmed = onlyOwn && !isOwnTeam
return (
<Link
key={m.id}
href={`/match-details/${m.id}`}
className={`
flex flex-col items-center gap-4 bg-neutral-300 dark:bg-neutral-800
text-gray-800 dark:text-white rounded-sm py-4
hover:scale-105 hover:bg-neutral-400 hover:dark:bg-neutral-700
hover:shadow-md transition-transform h-[172px]
${dimmed ? 'opacity-40' : ''}
`}
>
{/** ⏱ kleine Live-Marke, falls gewünscht */}
{isLive && (
<span className="absolute px-2 py-0.5 text-xs font-semibold rounded-full bg-red-600 text-white">
LIVE
</span>
)}
{/* Teams -------------------------------------------------- */}
<div className="flex w-full justify-around items-center">
{/* Team A */}
<div className="flex flex-col items-center w-1/3">
<Image
src={getTeamLogo(m.teamA.logo)}
alt={m.teamA.name}
width={48}
height={48}
className="rounded-full border bg-white"
/>
<span className="mt-2 text-xs">{m.teamA.name}</span>
</div>
{/* vs */}
<span className="font-bold">vs</span>
{/* Team B */}
<div className="flex flex-col items-center w-1/3">
<Image
src={getTeamLogo(m.teamB.logo)}
alt={m.teamB.name}
width={48}
height={48}
className="rounded-full border bg-white"
/>
<span className="mt-2 text-xs">{m.teamB.name}</span>
</div>
</div>
{/* Datum + Uhrzeit --------------------------------------- */}
<div className="flex flex-col items-center space-y-1 mt-2">
{/* Datum */}
<span className={`px-3 py-0.5 rounded-full text-sm font-semibold ${isLive ? 'bg-red-300 dark:bg-red-500' : 'bg-yellow-300 dark:bg-yellow-500'}`}>
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
</span>
{/* Zeit */}
<span className="flex items-center gap-1 text-xs opacity-80">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-3.5 h-3.5"
fill="currentColor"
viewBox="0 0 512 512"
>
<path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" />
</svg>
{format(new Date(m.demoDate), 'HH:mm', { locale: de })} Uhr
</span>
</div>
</Link>
)
})}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -9,12 +9,14 @@ type DroppableZoneProps = {
label: string label: string
children: React.ReactNode children: React.ReactNode
activeDragItem: Player | null activeDragItem: Player | null
saveSuccess?: boolean
} }
export function DroppableZone({ export function DroppableZone({
id, id,
label, label,
children, children,
saveSuccess = false,
}: DroppableZoneProps) { }: DroppableZoneProps) {
const { isOver, setNodeRef } = useDroppable({ id }) const { isOver, setNodeRef } = useDroppable({ id })
@ -31,10 +33,30 @@ export function DroppableZone({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-md font-semibold text-gray-700 dark:text-gray-300"> <h3 className="text-md font-semibold text-gray-700 dark:text-gray-300">
{label} {label}
</h3> </h3>
{saveSuccess && (
<div className="flex items-center gap-1 text-green-600 text-sm font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 00-1.414 0L8 12.586 4.707 9.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 000-1.414z"
clipRule="evenodd"
/>
</svg>
Änderungen gespeichert!
</div>
)}
</div>
{/* Hier sitzt der Droppable-Ref */} {/* Hier sitzt der Droppable-Ref */}
<div ref={setNodeRef} className={zoneClasses}> <div ref={setNodeRef} className={zoneClasses}>
<div <div

View File

@ -18,6 +18,7 @@ import EditMatchPlayersModal from './EditMatchPlayersModal'
import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B' import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
import type { Match, MatchPlayer } from '../types/match' import type { Match, MatchPlayer } from '../types/match'
import Button from './Button'
/* ─────────────────── Hilfsfunktionen ────────────────────────── */ /* ─────────────────── Hilfsfunktionen ────────────────────────── */
const kdr = (k?: number, d?: number) => const kdr = (k?: number, d?: number) =>
@ -165,13 +166,14 @@ export function MatchDetails ({ match }: { match: Match }) {
</h2> </h2>
{canEditA && isFutureMatch && ( {canEditA && isFutureMatch && (
<button <Button
size='sm'
onClick={() => setEditSide('A')} onClick={() => setEditSide('A')}
className="px-3 py-1.5 text-sm rounded-lg className="px-3 py-1.5 text-sm rounded-lg
bg-blue-600 hover:bg-blue-700 text-white" bg-blue-600 hover:bg-blue-700 text-white"
> >
Spieler bearbeiten Spieler bearbeiten
</button> </Button>
)} )}
</div> </div>
@ -186,13 +188,14 @@ export function MatchDetails ({ match }: { match: Match }) {
</h2> </h2>
{canEditB && isFutureMatch && ( {canEditB && isFutureMatch && (
<button <Button
size='sm'
onClick={() => setEditSide('B')} onClick={() => setEditSide('B')}
className="px-3 py-1.5 text-sm rounded-lg className="px-3 py-1.5 text-sm rounded-lg
bg-blue-600 hover:bg-blue-700 text-white" bg-blue-600 hover:bg-blue-700 text-white"
> >
Spieler bearbeiten Spieler bearbeiten
</button> </Button>
)} )}
</div> </div>

View File

@ -1,121 +0,0 @@
'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'
type Match = {
id: string
title: string
description?: string
matchDate: string
teamA: { id: string; teamname: string; logo?: string | null }
teamB: { id: string; teamname: string; logo?: string | null }
}
function getTeamLogo(logo?: string | null) {
return logo ? `/assets/img/logos/${logo}` : '/default-logo.png'
}
export default function MatchList() {
const { data: session } = useSession()
const [matches, setMatches] = useState<Match[]>([])
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
useEffect(() => {
fetch('/api/matches')
.then((res) => res.ok ? res.json() : [])
.then(setMatches)
.catch((err) => console.error('Fehler beim Laden der Matches:', err))
}, [])
const filteredMatches = onlyOwnTeam && session?.user?.team
? matches.filter(m =>
m.teamA.id === session.user.team || m.teamB.id === session.user.team
)
: matches
return (
<div className="max-w-4xl mx-auto py-8 px-4 space-y-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">Geplante Matches</h1>
{session?.user?.team && (
<Switch
id="only-own-team"
checked={onlyOwnTeam}
onChange={setOnlyOwnTeam}
labelRight="Nur mein Team"
/>
)}
</div>
{filteredMatches.length === 0 ? (
<p className="text-gray-500">Keine Matches geplant.</p>
) : (
<ul className="space-y-4">
{filteredMatches.map((match) => (
<li key={match.id}>
<Link
href={`/matches/${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">
<Image
src={getTeamLogo(match.teamA.logo)}
alt={match.teamA.teamname}
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}
</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>
{/* Team B */}
<div className="flex flex-col items-center w-1/4">
<Image
src={getTeamLogo(match.teamB.logo)}
alt={match.teamB.teamname}
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}
</span>
</div>
</div>
{/* Match-Titel */}
<div className="mt-3 text-sm font-medium text-center text-gray-700 dark:text-gray-300">
{match.title}
</div>
{/* Match-Beschreibung (optional) */}
{match.description && (
<div className="text-sm text-center text-gray-500 dark:text-gray-400 mt-1">
{match.description}
</div>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -41,7 +41,7 @@ export default function Switch({
</label> </label>
{labelRight && ( {labelRight && (
<label htmlFor={id} className="text-sm text-gray-500 dark:text-neutral-400"> <label htmlFor={id} className="text-sm text-gray-500 dark:text-neutral-400 cursor-pointer">
{labelRight} {labelRight}
</label> </label>
)} )}

View File

@ -82,7 +82,7 @@ export default function TeamCard({
className=" className="
p-4 border rounded-lg bg-white dark:bg-neutral-800 p-4 border rounded-lg bg-white dark:bg-neutral-800
dark:border-neutral-700 shadow-sm hover:shadow-md dark:border-neutral-700 shadow-sm hover:shadow-md
transition cursor-pointer focus:outline-none transition cursor-pointer focus:outline-none hover:scale-105 hover:bg-neutral-200 hover:dark:bg-neutral-700
" "
> >
{/* Kopfzeile */} {/* Kopfzeile */}
@ -111,8 +111,9 @@ export default function TeamCard({
{adminMode ? ( {adminMode ? (
<Button <Button
title="Verwalten" title="Verwalten"
size="sm" size="md"
color="blue" color="blue"
variant='solid'
onClick={e => { onClick={e => {
e.stopPropagation() // ▼ Navigation hier unterbinden e.stopPropagation() // ▼ Navigation hier unterbinden
router.push(`/admin/teams/${data.id}`) router.push(`/admin/teams/${data.id}`)

View File

@ -73,6 +73,7 @@ export default function TeamMemberView({
const [logoPreview, setLogoPreview] = useState<string | null>(null) const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [logoFile, setLogoFile] = useState<File | null>(null) const [logoFile, setLogoFile] = useState<File | null>(null)
const [teamState, setTeamState] = useState<Team | null>(team) const [teamState, setTeamState] = useState<Team | null>(team)
const [saveSuccess, setSaveSuccess] = useState(false)
useEffect(() => { useEffect(() => {
if (session?.user?.steamId) { if (session?.user?.steamId) {
@ -205,6 +206,8 @@ export default function TeamMemberView({
setactivePlayers(newActive) setactivePlayers(newActive)
setInactivePlayers(newInactive) setInactivePlayers(newInactive)
await updateTeamMembers(team!.id, newActive, newInactive) await updateTeamMembers(team!.id, newActive, newInactive)
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 3000) // 3 Sekunden sichtbar
} }
const confirmKick = async () => { const confirmKick = async () => {
@ -488,13 +491,13 @@ export default function TeamMemberView({
<DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8"> <div className="space-y-8">
<DroppableZone id="active" label={`Aktives Team (${activePlayers.length} / 5)`} activeDragItem={activeDragItem}> <DroppableZone id="active" label={`Aktives Team (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<SortableContext items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}> <SortableContext items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(activePlayers)} {renderMemberList(activePlayers)}
</SortableContext> </SortableContext>
</DroppableZone> </DroppableZone>
<DroppableZone id="inactive" label="Inaktives Team" activeDragItem={activeDragItem}> <DroppableZone id="inactive" label="Inaktives Team" activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}> <SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(inactivePlayers)} {renderMemberList(inactivePlayers)}
{canManage && ( {canManage && (

View File

@ -1,144 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import Table from './Table';
import Link from 'next/link';
import { mapNameMap } from '../lib/mapNameMap';
import { useRouter } from 'next/navigation';
import PremierRankBadge from './PremierRankBadge';
import CompRankBadge from './CompRankBadge';
interface Match {
id: string;
map: string;
date: string;
score: string;
winnerTeam?: string;
team?: 'CT' | 'T';
matchType: string;
rating: string;
kills: number;
deaths: number;
kdr: string;
rankNew: number;
rankOld: number;
rankChange: number;
}
export default function UserMatchesTable({ steamId }: { steamId: string }) {
const [matches, setMatches] = useState<Match[]>([]);
const router = useRouter();
useEffect(() => {
if (!steamId) return
fetch(`/api/user/${steamId}/matches`)
.then((res) => res.json())
.then(setMatches)
.catch(console.error);
}, [steamId])
return (
<Table>
<Table.Head>
<Table.Row>
<Table.Cell as="th">Map</Table.Cell>
<Table.Cell as="th">Date</Table.Cell>
<Table.Cell as="th">Score</Table.Cell>
<Table.Cell as="th">Rank</Table.Cell>
<Table.Cell as="th">Kills</Table.Cell>
<Table.Cell as="th">Deaths</Table.Cell>
<Table.Cell as="th">K/D</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{matches.map((m: Match) => {
const mapInfo = mapNameMap[m.map] ?? mapNameMap['lobby_mapveto'];
const [scoreCT, scoreT] = m.score.split(':').map(s => parseInt(s.trim(), 10));
let left = scoreCT;
let right = scoreT;
// Score-Reihenfolge anhand des eigenen Teams und Sieger drehen
if (m.team === 'T') {
left = scoreT;
right = scoreCT;
}
// Score-Farbe bestimmen
let scoreClass = '';
if (!isNaN(left) && !isNaN(right)) {
if (left > right) {
scoreClass = 'bg-green-50 dark:bg-green-950';
} else if (left < right) {
scoreClass = 'bg-red-50 dark:bg-red-950';
} else {
scoreClass = 'bg-yellow-50 dark:bg-yellow-950';
}
}
return (
<Table.Row
key={m.id}
hoverable
onClick={() => router.push(`/match-details/${m.id}`)}
className="cursor-pointer"
>
<Table.Cell>
<div className="flex items-center gap-2">
<img
src={`/assets/img/mapicons/${m.map}.webp`}
alt={mapInfo.name}
height={32}
width={32}
/>
{mapInfo.name}
</div>
</Table.Cell>
<Table.Cell>{new Date(m.date).toLocaleString()}</Table.Cell>
<Table.Cell>
<span
className={
m.winnerTeam === m.team
? 'text-green-600 dark:text-green-400'
: m.winnerTeam && m.winnerTeam !== 'Draw'
? 'text-red-600 dark:text-red-400'
: 'text-yellow-600 dark:text-yellow-400'
}
>
{`${left} : ${right}`}
</span>
</Table.Cell>
<Table.Cell className="whitespace-nowrap">
<div className="flex items-center gap-[6px]">
{m.matchType === 'premier' ? (
<PremierRankBadge rank={m.rankNew} />
) : (
<CompRankBadge rank={m.rankNew} />
)}
{m.rankChange !== null && m.matchType === 'premier' && (
<span
className={`text-sm ${
m.rankChange > 0
? 'text-green-500'
: m.rankChange < 0
? 'text-red-500'
: ''
}`}
>
{m.rankChange > 0 ? '+' : ''}
{m.rankChange}
</span>
)}
</div>
</Table.Cell>
<Table.Cell>{m.kills}</Table.Cell>
<Table.Cell>{m.deaths}</Table.Cell>
<Table.Cell>{m.kdr}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
);
}

View File

@ -10,6 +10,8 @@ import DatePickerWithTime from '../DatePickerWithTime'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import Switch from '../Switch' import Switch from '../Switch'
import CommunityMatchList from '../CommunityMatchList'
import Card from '../Card'
function getRoundedDate() { function getRoundedDate() {
const now = new Date() const now = new Date()
@ -27,16 +29,12 @@ function getTeamLogo(logo?: string | null) {
} }
export default function MatchesAdminManager() { export default function MatchesAdminManager() {
const { data: session } = useSession()
const [teams, setTeams] = useState<any[]>([]) const [teams, setTeams] = useState<any[]>([])
const [matches, setMatches] = useState<any[]>([]) const [matches, setMatches] = useState<any[]>([])
const [teamAId, setTeamAId] = useState('') const [teamAId, setTeamAId] = useState('')
const [teamBId, setTeamBId] = useState('') const [teamBId, setTeamBId] = useState('')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [titleManuallySet, setTitleManuallySet] = useState(false) const [titleManuallySet, setTitleManuallySet] = useState(false)
const [description, setDescription] = useState('')
const [matchDate, setMatchDate] = useState(getRoundedDate())
const [showModal, setShowModal] = useState(false)
useEffect(() => { useEffect(() => {
fetch('/api/admin/teams').then(res => res.json()).then(setTeams) fetch('/api/admin/teams').then(res => res.json()).then(setTeams)
@ -58,153 +56,9 @@ export default function MatchesAdminManager() {
if (res.ok) setMatches(await res.json()) if (res.ok) setMatches(await res.json())
} }
const filteredMatches = matches.filter(
(m: any) => m.matchType === 'community'
)
const resetFields = () => {
setTitle('')
setTitleManuallySet(false)
setDescription('')
setMatchDate(getRoundedDate())
setTeamAId('')
setTeamBId('')
}
const createMatch = async () => {
if (!teamAId || !teamBId || !title || !matchDate || teamAId === teamBId) {
alert('Bitte alle Felder korrekt ausfüllen.')
return
}
const res = await fetch('/api/matches/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamAId, teamBId, title, description, matchDate })
})
if (res.ok) {
resetFields()
setShowModal(false)
fetchMatches()
} else {
alert('Fehler beim Erstellen')
}
}
return ( return (
<> <Card maxWidth='auto'>
<div className="max-w-4xl mx-auto py-8 px-4 space-y-4"> <CommunityMatchList matchType="community" />
<div className="max-w-4xl mx-auto px-4 flex justify-between items-center mb-4"> </Card>
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">Geplante Matches</h1>
<Button onClick={() => setShowModal(true)} color="blue">
Neues Match erstellen
</Button>
</div>
{filteredMatches.length === 0 ? (
<p className="text-gray-500">Keine Matches geplant.</p>
) : (
<ul className="space-y-4">
{filteredMatches.map((match: any) => (
<li key={match.id}>
<Link href={`/matches/${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">
<div className="flex flex-col items-center w-1/4">
<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?.name || 'Team A'}
</span>
</div>
<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>
<div className="flex flex-col items-center w-1/4">
<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?.name || 'Team B'}
</span>
</div>
</div>
{match.description && (
<div className="text-sm text-center text-gray-500 dark:text-gray-400 mt-1">
{match.description}
</div>
)}
</Link>
</li>
))}
</ul>
)}
</div>
{/* Modal zum Erstellen */}
<Modal
id="create-match-modal"
title="Match erstellen"
show={showModal}
onClose={() => setShowModal(false)}
onSave={createMatch}
closeButtonTitle="Erstellen"
closeButtonColor="blue"
>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<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.name }))}
placeholder="Wählen"
/>
</div>
<div>
<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.name }))}
placeholder="Wählen"
/>
</div>
<div className="col-span-2">
<Input
label="Titel"
value={title}
onChange={(e) => {
setTitle(e.target.value)
setTitleManuallySet(true)
}}
/>
</div>
<div className="col-span-2">
<Input
label="Beschreibung"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="col-span-2">
<DatePickerWithTime value={matchDate} onChange={setMatchDate} />
</div>
</div>
</Modal>
</>
) )
} }

View File

@ -7,6 +7,7 @@ import Modal from '@/app/components/Modal'
import Input from '@/app/components/Input' import Input from '@/app/components/Input'
import TeamCard from '@/app/components/TeamCard' import TeamCard from '@/app/components/TeamCard'
import type { Team } from '@/app/types/team' import type { Team } from '@/app/types/team'
import LoadingSpinner from '../../LoadingSpinner'
export default function AdminTeamsView() { export default function AdminTeamsView() {
/* ────────────────────────── Session ─────────────────────────── */ /* ────────────────────────── Session ─────────────────────────── */
@ -75,7 +76,7 @@ export default function AdminTeamsView() {
if (loading) { if (loading) {
return ( return (
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Lade Teams&nbsp;&hellip; <LoadingSpinner />
</p> </p>
) )
} }

View File

@ -0,0 +1,137 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Table from '../../../Table'
import PremierRankBadge from '../../../PremierRankBadge'
import CompRankBadge from '../../../CompRankBadge'
import { mapNameMap } from '@/app/lib/mapNameMap'
/* ───────── Typen ───────── */
interface Match {
id : string
map : string
date : string
score : string | null
winnerTeam?: 'CT' | 'T' | 'Draw'
team? : 'CT' | 'T'
matchType : 'premier' | 'competitive' | string
rating : string
kills : number
deaths : number
kdr : string
rankNew : number
rankOld : number
rankChange : number | null
}
/* ───────── Hilfsfunktionen ───────── */
const parseScore = (raw?: string | null): [number, number] => {
if (!raw) return [0, 0]
const [a, b] = raw.split(':').map(n => Number(n.trim()))
return [Number.isNaN(a) ? 0 : a, Number.isNaN(b) ? 0 : b]
}
/* ───────── Komponente ───────── */
export default function UserMatchesList({ steamId }: { steamId: string }) {
const [matches, setMatches] = useState<Match[]>([])
const router = useRouter()
useEffect(() => {
if (!steamId) return
fetch(`/api/user/${steamId}/matches?types=premier,competitive`)
.then(r => r.ok ? r.json() : [])
.then(setMatches)
.catch(console.error)
}, [steamId])
return (
<Table>
{/* Kopf */}
<Table.Head>
<Table.Row>
{['Map','Date','Score','Rank','Kills','Deaths','K/D'].map(h => (
<Table.Cell key={h} as="th">{h}</Table.Cell>
))}
</Table.Row>
</Table.Head>
{/* Daten */}
<Table.Body>
{matches.map(m => {
const mapInfo = mapNameMap[m.map] ?? mapNameMap.lobby_mapveto
const [scoreCT, scoreT] = parseScore(m.score)
/* Score aus Sicht des Spielers drehen */
const ownCTSide = m.team !== 'T'
const left = ownCTSide ? scoreCT : scoreT
const right = ownCTSide ? scoreT : scoreCT
/* Text-Farbe für Score */
const scoreColor =
left > right ? 'text-green-600 dark:text-green-400'
: left < right ? 'text-red-600 dark:text-red-400'
: 'text-yellow-600 dark:text-yellow-400'
return (
<Table.Row
key={m.id}
hoverable
onClick={() => router.push(`/match-details/${m.id}`)}
className="cursor-pointer"
>
{/* Map + Icon */}
<Table.Cell>
<div className="flex items-center gap-2">
<img
src={`/assets/img/mapicons/${m.map}.webp`}
alt={mapInfo.name}
width={32}
height={32}
/>
{mapInfo.name}
</div>
</Table.Cell>
{/* Datum */}
<Table.Cell>{new Date(m.date).toLocaleString()}</Table.Cell>
{/* Score */}
<Table.Cell>
<span className={`font-medium ${scoreColor}`}>
{left} : {right}
</span>
</Table.Cell>
{/* Rank + Delta */}
<Table.Cell className="whitespace-nowrap">
<div className="flex items-center gap-[6px]">
{m.matchType === 'premier'
? <PremierRankBadge rank={m.rankNew} />
: <CompRankBadge rank={m.rankNew} />}
{m.rankChange !== null && m.matchType === 'premier' && (
<span
className={
m.rankChange > 0 ? 'text-green-500'
: m.rankChange < 0 ? 'text-red-500'
: ''
}
>
{m.rankChange > 0 ? '+' : ''}{m.rankChange}
</span>
)}
</div>
</Table.Cell>
{/* Stats */}
<Table.Cell>{m.kills}</Table.Cell>
<Table.Cell>{m.deaths}</Table.Cell>
<Table.Cell>{m.kdr}</Table.Cell>
</Table.Row>
)
})}
</Table.Body>
</Table>
)
}

View File

@ -3,8 +3,8 @@
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Chart from '@/app/components/Chart' import Chart from '@/app/components/Chart'
import { MatchStats } from '@/app/types/match' import { MatchStats } from '@/app/types/match'
import Card from './Card' import Card from '../../../Card'
import UserClips from './UserClips' import UserClips from '../../../UserClips'
type MatchStatsProps = { type MatchStatsProps = {
stats: { matches: MatchStats[] } stats: { matches: MatchStats[] }

View File

@ -0,0 +1,38 @@
import { prisma } from '@/app/lib/prisma'
/**
* Entfernt einen Spieler aus allen Matches, in denen er noch in teamAUsers
* oder teamBUsers steht und löscht zugleich den MatchPlayer-Eintrag.
*
* @param teamId Team, das bearbeitet wird
* @param steamId Spieler-SteamID
*/
export async function removePlayerFromMatches(teamId: string, steamId: string) {
// alle betroffenen Matches holen
const matches = await prisma.match.findMany({
where: {
OR: [
{ teamAId: teamId, teamAUsers: { some: { steamId } } },
{ teamBId: teamId, teamBUsers: { some: { steamId } } },
],
},
select: { id: true },
})
await prisma.$transaction(
matches.flatMap(m => [
// Relation aus teamAUsers / teamBUsers lösen
prisma.match.update({
where: { id: m.id },
data : {
teamAUsers: { disconnect: { steamId } },
teamBUsers: { disconnect: { steamId } },
},
}),
// ggf. vorhandenen MatchPlayer-Satz löschen
prisma.matchPlayer.deleteMany({
where: { matchId: m.id, steamId },
}),
]),
)
}

View File

@ -1,5 +1,5 @@
// /app/profile/[steamId]/matches/page.tsx // /app/profile/[steamId]/matches/page.tsx
import UserMatchesTable from '@/app/components/UserMatchesTable' import UserMatchesList from '@/app/components/profile/[steamId]/matches/UserMatchesList'
import Card from '@/app/components/Card' import Card from '@/app/components/Card'
export default function MatchesPage({ params }: { params: { steamId: string } }) { export default function MatchesPage({ params }: { params: { steamId: string } }) {
@ -10,7 +10,7 @@ export default function MatchesPage({ params }: { params: { steamId: string } })
</h1> </h1>
<Card maxWidth="auto"> <Card maxWidth="auto">
<UserMatchesTable steamId={params.steamId} /> <UserMatchesList steamId={params.steamId} />
</Card> </Card>
</div> </div>
) )

View File

@ -1,11 +1,11 @@
// /app/profile/[steamId]/matches/page.tsx // /app/profile/[steamId]/matches/page.tsx
import Card from '@/app/components/Card' import Card from '@/app/components/Card'
import UserMatchesTable from '@/app/components/UserMatchesTable' import UserMatchesList from '@/app/components/profile/[steamId]/matches/UserMatchesList'
export default function MatchesPage({ params }: { params: { steamId: string } }) { export default function MatchesPage({ params }: { params: { steamId: string } }) {
return ( return (
<Card maxWidth="auto"> <Card maxWidth="auto">
<UserMatchesTable steamId={params.steamId} /> <UserMatchesList steamId={params.steamId} />
</Card> </Card>
) )
} }

View File

@ -1,5 +1,5 @@
// /app/profile/[steamId]/stats/page.tsx // /app/profile/[steamId]/stats/page.tsx
import UserProfile from '@/app/components/UserProfile' import UserProfile from '@/app/components/profile/[steamId]/stats/UserProfile'
import { MatchStats } from '@/app/types/match' import { MatchStats } from '@/app/types/match'
async function getStats(steamId: string) { async function getStats(steamId: string) {

View File

@ -6,6 +6,8 @@ import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Switch from '@/app/components/Switch' import Switch from '@/app/components/Switch'
import Button from '../components/Button' import Button from '../components/Button'
import CommunityMatchList from '../components/CommunityMatchList'
import Card from '../components/Card'
type Match = { type Match = {
id: string id: string
@ -15,10 +17,6 @@ type Match = {
teamB: { 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() { export default function MatchesPage() {
const { data: session } = useSession() const { data: session } = useSession()
const [matches, setMatches] = useState<Match[]>([]) const [matches, setMatches] = useState<Match[]>([])
@ -41,83 +39,8 @@ export default function MatchesPage() {
: matches : matches
return ( return (
<div className="max-w-4xl mx-auto py-8 px-4 space-y-4"> <Card maxWidth='auto'>
<div className="flex justify-between items-center mb-4"> <CommunityMatchList matchType="community" />
<h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300">Geplante Matches</h1> </Card>
{session?.user?.team && (
<Switch
id="only-own-team"
checked={onlyOwnTeam}
onChange={setOnlyOwnTeam}
labelRight="Nur mein Team"
/>
)}
{session?.user?.isAdmin && (
<Link href="/admin/matches">
<Button color="blue">Match erstellen</Button>
</Link>
)}
</div>
{filteredMatches.length === 0 ? (
<p className="text-gray-700 dark:text-neutral-300">Keine Matches geplant.</p>
) : (
<ul className="space-y-4">
{filteredMatches.map(match => (
<li key={match.id}>
<Link
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">
<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?.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>
{/* Team B */}
<div className="flex flex-col items-center w-1/4">
<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?.name ?? 'Team B'}
</span>
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
) )
} }

View File

@ -1,15 +1,24 @@
// src/app/types/match.ts
import { Player } from './team' import { Player } from './team'
export type Match = { export type Match = {
/* Basis-Infos ---------------------------------------------------- */
id : string id : string
title : string title : string
demoDate: Date demoDate : string // ⇐ Backend kommt als ISO-String
description?: string description?: string
map : string map : string
matchType: string matchType : 'premier' | 'competitive' | 'community' | string
roundCount : number roundCount : number
/* Ergebnis ------------------------------------------------------- */
scoreA? : number | null scoreA? : number | null
scoreB? : number | null scoreB? : number | null
/** CT | T | Draw | null null, solange noch kein Ergebnis vorliegt */
winnerTeam? : 'CT' | 'T' | 'Draw' | null
/* Teams ---------------------------------------------------------- */
teamA: { teamA: {
id : string id : string
name : string name : string
@ -26,6 +35,7 @@ export type Match = {
} }
} }
/* --------------------------------------------------------------- */
export type MatchPlayer = { export type MatchPlayer = {
user : Player user : Player
stats?: { stats?: {
@ -43,7 +53,7 @@ export type MatchPlayer = {
smokeKills : number smokeKills : number
headshots : number headshots : number
noScopes : number noScopes : number
blindKills: Number blindKills : number
rankOld : number rankOld : number
rankNew : number rankNew : number
rankChange : number rankChange : number
@ -55,16 +65,16 @@ export type MatchPlayer = {
} }
} }
/* --------------------------------------------------------------- */
export type MatchStats = { export type MatchStats = {
date: string date : string // ISO
kills : number kills : number
deaths : number deaths : number
assists : number assists : number
headshotPct: number, headshotPct : number
totalDamage: number, totalDamage : number
map: string, map : string
matchType: 'premier' | 'competitive' | 'community' // falls du auch andere hast matchType : 'premier' | 'competitive' | 'community'
rankNew : number | null rankNew : number | null
rankOld? : number | null rankOld? : number | null
} }

View File

@ -15,4 +15,5 @@ export type Team = {
leader?: string | null leader?: string | null
activePlayers? : Player[] activePlayers? : Player[]
inactivePlayers?: Player[] inactivePlayers?: Player[]
invitedPlayers?: Player[]
} }