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) {
case 'matches':
return (
<Card title="Matches">
<Card title="Matches" maxWidth='auto'>
<MatchesAdminManager />
</Card>
)

View File

@ -1,53 +1,53 @@
// /app/api/matches/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET() {
export async function GET(req: Request) {
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({
where : matchType ? { matchType } : undefined,
orderBy: { demoDate: 'desc' },
include: {
teamA: true,
teamB: true,
players: {
include: {
user: true,
stats: true,
team: true,
},
},
teamA : true,
teamB : true,
players: { include: { user: true, stats: true, team: true } },
},
})
const formatted = matches.map(match => ({
id: match.id,
map: match.map,
demoDate: match.demoDate,
matchType: match.matchType,
scoreA: match.scoreA,
scoreB: match.scoreB,
winnerTeam: match.winnerTeam ?? null,
/* … rest bleibt unverändert … */
const formatted = matches.map(m => ({
id : m.id,
map : m.map,
demoDate: m.demoDate,
matchType: m.matchType,
scoreA : m.scoreA,
scoreB : m.scoreB,
winnerTeam: m.winnerTeam ?? null,
teamA: {
id: match.teamA?.id ?? null,
name: match.teamA?.name ?? 'CT',
logo: match.teamA?.logo ?? null,
score: match.scoreA,
id : m.teamA?.id ?? null,
name: m.teamA?.name ?? 'CT',
logo: m.teamA?.logo ?? null,
score: m.scoreA,
},
teamB: {
id: match.teamB?.id ?? null,
name: match.teamB?.name ?? 'T',
logo: match.teamB?.logo ?? null,
score: match.scoreB,
id : m.teamB?.id ?? null,
name: m.teamB?.name ?? 'T',
logo: m.teamB?.logo ?? null,
score: m.scoreB,
},
players: match.players.map(p => ({
steamId: p.steamId,
name: p.user?.name,
avatar: p.user?.avatar,
stats: p.stats,
teamId: p.teamId,
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,
})),
}));
}))
return NextResponse.json(formatted)
} catch (err) {

View File

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

View File

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

View File

@ -1,91 +1,107 @@
// /app/api/user/[steamId]/matches/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET(
_req: Request,
{ params }: { params: { steamId: string } }
req : NextRequest, // ← Request wird gebraucht!
{ params }: { params: { steamId: string } },
) {
const steamId = params.steamId
if (!steamId) {
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 {
const matchPlayers = await prisma.matchPlayer.findMany({
where: { steamId },
where: {
steamId,
/* nur wenn Filter gesetzt ist */
...(types.length && {
match: { matchType: { in: types } },
}),
},
select: {
teamId: true,
team: true,
match: {
team : true,
match : {
select: {
id: true,
demoDate: true,
map: true,
roundCount: true,
scoreA: true,
scoreB: true,
matchType: true,
teamAId: true,
teamBId: true,
teamAUsers: true,
teamBUsers: true,
winnerTeam: true,
id : true,
demoDate : true,
map : true,
roundCount : true,
scoreA : true,
scoreB : true,
matchType : true,
teamAId : true,
teamBId : true,
teamAUsers : { select: { steamId: true } },
teamBUsers : { select: { steamId: true } },
winnerTeam : true,
},
},
stats: true,
},
orderBy: {
match: {
demoDate: 'desc',
},
},
orderBy: { match: { demoDate: 'desc' } },
})
const data = matchPlayers.map((mp) => {
const match = mp.match
/* ───────── Aufbereiten fürs Frontend ───────── */
const data = matchPlayers.map(mp => {
const m = mp.match
const stats = mp.stats
const kills = stats?.kills ?? 0
const kills = stats?.kills ?? 0
const deaths = stats?.deaths ?? 0
const kdr = deaths > 0 ? (kills / deaths).toFixed(2) : '∞'
const roundCount = match.roundCount
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
const rankOld = stats?.rankOld ?? null
const rankNew = stats?.rankNew ?? null
const rankChange =
typeof rankNew === 'number' && typeof rankOld === 'number'
? rankNew - rankOld
: null
const matchType = match.matchType ?? 'community'
rankNew != null && rankOld != null ? rankNew - rankOld : null
const isInTeamA = match.teamAUsers.some((user) => user.steamId === steamId)
const playerTeam = isInTeamA ? 'CT' : 'T'
/* Team des Spielers ermitteln */
const playerTeam =
m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
const scoreCT = match.scoreA ?? 0
const scoreT = match.scoreB ?? 0
const score = `${scoreCT} : ${scoreT}`
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
return {
id: match.id,
map: match.map ?? 'Unknown',
date: match.demoDate,
matchType,
id : m.id,
map : m.map ?? 'Unknown',
date : m.demoDate?.toISOString() ?? '',
matchType : m.matchType ?? 'community',
score,
roundCount,
roundCount: m.roundCount,
rankOld,
rankNew,
rankChange,
kills,
deaths,
kdr,
winnerTeam: match.winnerTeam ?? null,
team: playerTeam,
winnerTeam: m.winnerTeam ?? null,
team : playerTeam, // „CT“ oder „T“
}
})
return NextResponse.json(data)
} catch (error) {
console.error('[API] Fehler beim Laden der Matches:', error)
} catch (err) {
console.error('[API] Fehler beim Laden der Matches:', err)
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
children: React.ReactNode
activeDragItem: Player | null
saveSuccess?: boolean
}
export function DroppableZone({
id,
label,
children,
saveSuccess = false,
}: DroppableZoneProps) {
const { isOver, setNodeRef } = useDroppable({ id })
@ -31,9 +33,29 @@ export function DroppableZone({
return (
<div className="space-y-2">
<h3 className="text-md font-semibold text-gray-700 dark:text-gray-300">
{label}
</h3>
<div className="flex items-center justify-between">
<h3 className="text-md font-semibold text-gray-700 dark:text-gray-300">
{label}
</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 */}
<div ref={setNodeRef} className={zoneClasses}>

View File

@ -18,6 +18,7 @@ import EditMatchPlayersModal from './EditMatchPlayersModal'
import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
import type { Match, MatchPlayer } from '../types/match'
import Button from './Button'
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
const kdr = (k?: number, d?: number) =>
@ -165,13 +166,14 @@ export function MatchDetails ({ match }: { match: Match }) {
</h2>
{canEditA && isFutureMatch && (
<button
<Button
size='sm'
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>
</Button>
)}
</div>
@ -186,13 +188,14 @@ export function MatchDetails ({ match }: { match: Match }) {
</h2>
{canEditB && isFutureMatch && (
<button
<Button
size='sm'
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>
</Button>
)}
</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>
{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}
</label>
)}

View File

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

View File

@ -73,6 +73,7 @@ export default function TeamMemberView({
const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [logoFile, setLogoFile] = useState<File | null>(null)
const [teamState, setTeamState] = useState<Team | null>(team)
const [saveSuccess, setSaveSuccess] = useState(false)
useEffect(() => {
if (session?.user?.steamId) {
@ -205,6 +206,8 @@ export default function TeamMemberView({
setactivePlayers(newActive)
setInactivePlayers(newInactive)
await updateTeamMembers(team!.id, newActive, newInactive)
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 3000) // 3 Sekunden sichtbar
}
const confirmKick = async () => {
@ -488,13 +491,13 @@ export default function TeamMemberView({
<DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<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}>
{renderMemberList(activePlayers)}
</SortableContext>
</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}>
{renderMemberList(inactivePlayers)}
{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 Image from 'next/image'
import Switch from '../Switch'
import CommunityMatchList from '../CommunityMatchList'
import Card from '../Card'
function getRoundedDate() {
const now = new Date()
@ -27,16 +29,12 @@ function getTeamLogo(logo?: string | null) {
}
export default function MatchesAdminManager() {
const { data: session } = useSession()
const [teams, setTeams] = useState<any[]>([])
const [matches, setMatches] = useState<any[]>([])
const [teamAId, setTeamAId] = useState('')
const [teamBId, setTeamBId] = useState('')
const [title, setTitle] = useState('')
const [titleManuallySet, setTitleManuallySet] = useState(false)
const [description, setDescription] = useState('')
const [matchDate, setMatchDate] = useState(getRoundedDate())
const [showModal, setShowModal] = useState(false)
useEffect(() => {
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())
}
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 (
<>
<div className="max-w-4xl mx-auto py-8 px-4 space-y-4">
<div className="max-w-4xl mx-auto px-4 flex justify-between items-center mb-4">
<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>
</>
<Card maxWidth='auto'>
<CommunityMatchList matchType="community" />
</Card>
)
}

View File

@ -7,6 +7,7 @@ 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'
import LoadingSpinner from '../../LoadingSpinner'
export default function AdminTeamsView() {
/* ────────────────────────── Session ─────────────────────────── */
@ -75,7 +76,7 @@ export default function AdminTeamsView() {
if (loading) {
return (
<p className="text-gray-500 dark:text-gray-400">
Lade Teams&nbsp;&hellip;
<LoadingSpinner />
</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 Chart from '@/app/components/Chart'
import { MatchStats } from '@/app/types/match'
import Card from './Card'
import UserClips from './UserClips'
import Card from '../../../Card'
import UserClips from '../../../UserClips'
type MatchStatsProps = {
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
import UserMatchesTable from '@/app/components/UserMatchesTable'
import UserMatchesList from '@/app/components/profile/[steamId]/matches/UserMatchesList'
import Card from '@/app/components/Card'
export default function MatchesPage({ params }: { params: { steamId: string } }) {
@ -10,7 +10,7 @@ export default function MatchesPage({ params }: { params: { steamId: string } })
</h1>
<Card maxWidth="auto">
<UserMatchesTable steamId={params.steamId} />
<UserMatchesList steamId={params.steamId} />
</Card>
</div>
)

View File

@ -1,11 +1,11 @@
// /app/profile/[steamId]/matches/page.tsx
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 } }) {
return (
<Card maxWidth="auto">
<UserMatchesTable steamId={params.steamId} />
<UserMatchesList steamId={params.steamId} />
</Card>
)
}

View File

@ -1,5 +1,5 @@
// /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'
async function getStats(steamId: string) {

View File

@ -6,6 +6,8 @@ import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Switch from '@/app/components/Switch'
import Button from '../components/Button'
import CommunityMatchList from '../components/CommunityMatchList'
import Card from '../components/Card'
type Match = {
id: string
@ -15,10 +17,6 @@ type Match = {
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() {
const { data: session } = useSession()
const [matches, setMatches] = useState<Match[]>([])
@ -41,83 +39,8 @@ export default function MatchesPage() {
: 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 text-gray-700 dark:text-neutral-300">Geplante Matches</h1>
{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>
<Card maxWidth='auto'>
<CommunityMatchList matchType="community" />
</Card>
)
}

View File

@ -1,70 +1,80 @@
// src/app/types/match.ts
import { Player } from './team'
export type Match = {
id: string
title: string
demoDate: Date
/* Basis-Infos ---------------------------------------------------- */
id : string
title : string
demoDate : string // ⇐ Backend kommt als ISO-String
description?: string
map: string
matchType: string
roundCount: number
scoreA?: number | null
scoreB?: number | null
map : string
matchType : 'premier' | 'competitive' | 'community' | string
roundCount : number
/* Ergebnis ------------------------------------------------------- */
scoreA? : number | null
scoreB? : number | null
/** CT | T | Draw | null null, solange noch kein Ergebnis vorliegt */
winnerTeam? : 'CT' | 'T' | 'Draw' | null
/* Teams ---------------------------------------------------------- */
teamA: {
id: string
name: string
logo?: string | null
id : string
name : string
logo? : string | null
leader?: string | null
players: MatchPlayer[]
}
teamB: {
id: string
name: string
logo?: string | null
id : string
name : string
logo? : string | null
leader?: string | null
players: MatchPlayer[]
}
}
/* --------------------------------------------------------------- */
export type MatchPlayer = {
user: Player
user : Player
stats?: {
kills: number
deaths: number
assists: number
totalDamage: number
utilityDamage: number
headshotPct: number
flashAssists: number
mvps: number
knifeKills: number
zeusKills: number
wallbangKills: number
smokeKills: number
headshots: number
noScopes: number
blindKills: Number
rankOld: number
rankNew: number
rankChange: number
k1: number
k2: number
k3: number
k4: number
k5: number
kills : number
deaths : number
assists : number
totalDamage : number
utilityDamage : number
headshotPct : number
flashAssists : number
mvps : number
knifeKills : number
zeusKills : number
wallbangKills : number
smokeKills : number
headshots : number
noScopes : number
blindKills : number
rankOld : number
rankNew : number
rankChange : number
k1 : number
k2 : number
k3 : number
k4 : number
k5 : number
}
}
/* --------------------------------------------------------------- */
export type MatchStats = {
date: string
kills: number
deaths: number
assists: number
headshotPct: number,
totalDamage: number,
map: string,
matchType: 'premier' | 'competitive' | 'community' // falls du auch andere hast
rankNew: number | null
rankOld?: number | null
date : string // ISO
kills : number
deaths : number
assists : number
headshotPct : number
totalDamage : number
map : string
matchType : 'premier' | 'competitive' | 'community'
rankNew : number | null
rankOld? : number | null
}

View File

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