update
This commit is contained in:
parent
c8945e55d8
commit
90a3bdeb35
3
.env
3
.env
@ -14,4 +14,5 @@ STEAM_SHARED_SECRET=test
|
||||
STEAMCMD_PATH=C:\Users\Rother\Desktop\dev\ironie\steamcmd\steamcmd.exe
|
||||
NEXTAUTH_SECRET=ironieopen
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
||||
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
||||
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
|
||||
741
package-lock.json
generated
741
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"fast": "next dev --turbo",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@ -18,8 +19,9 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@preline/dropdown": "^3.0.1",
|
||||
"@preline/tooltip": "^3.0.0",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@prisma/client": "^6.13.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
"csgo-sharecode": "^3.1.2",
|
||||
"datatables.net": "^2.2.2",
|
||||
"date-fns": "^4.1.0",
|
||||
@ -28,10 +30,12 @@
|
||||
"flag-icons": "^7.3.2",
|
||||
"framer-motion": "^12.18.1",
|
||||
"jquery": "^3.7.1",
|
||||
"ky": "^1.8.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lzma-native": "^8.0.6",
|
||||
"next": "15.3.0",
|
||||
"next-auth-steam": "^0.4.0",
|
||||
"next-intl": "^4.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
@ -56,7 +60,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"prisma": "^6.10.1",
|
||||
"prisma": "^6.13.0",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.4",
|
||||
|
||||
@ -21,7 +21,7 @@ model User {
|
||||
location String?
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
teamId String? @unique
|
||||
teamId String?
|
||||
team Team? @relation("UserTeam", fields: [teamId], references: [id])
|
||||
ledTeam Team? @relation("TeamLeader")
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@ -1,41 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Card from '@/app/components/Card'
|
||||
import TeamCardComponent from '@/app/components/TeamCardComponent'
|
||||
import MatchesAdminManager from '@/app/components/MatchesAdminManager'
|
||||
import MatchList from '@/app/components/MatchList'
|
||||
import { notFound, usePathname } from 'next/navigation'
|
||||
import Card from '@/app/components/Card'
|
||||
import MatchesAdminManager from '@/app/components/admin/MatchesAdminManager'
|
||||
import AdminTeamsView from '@/app/components/admin/teams/AdminTeamsView'
|
||||
|
||||
export default function Page({ params }: { params: Promise<{ tab: string }> }) {
|
||||
const { tab } = use(params)
|
||||
export default function AdminPage() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const [refetchKey, setRefetchKey] = useState<string>('') // 🔥 Gemeinsamer Reload-Key
|
||||
const activeTab: 'teams' | 'matches' | 'privacy' | '' =
|
||||
pathname.startsWith('/admin/teams') ? 'teams' :
|
||||
pathname.startsWith('/admin/matches') ? 'matches' :
|
||||
pathname.startsWith('/admin/privacy') ? 'privacy' :
|
||||
''
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (tab) {
|
||||
case 'matches':
|
||||
return (
|
||||
<Card title="Matches" description="">
|
||||
<MatchesAdminManager />
|
||||
</Card>
|
||||
)
|
||||
case 'privacy':
|
||||
return (
|
||||
<Card title="Datenschutz" description="Einstellungen zum Schutz deiner Daten." />
|
||||
)
|
||||
case 'team':
|
||||
return (
|
||||
<Card title="Team" description="Verwalte dein Team und lade Mitglieder ein.">
|
||||
<div className="mb-4">
|
||||
<TeamCardComponent refetchKey={refetchKey} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
default:
|
||||
return notFound()
|
||||
}
|
||||
switch (activeTab) {
|
||||
case 'matches':
|
||||
return (
|
||||
<Card title="Matches">
|
||||
<MatchesAdminManager />
|
||||
</Card>
|
||||
)
|
||||
|
||||
case 'privacy':
|
||||
return (
|
||||
<Card title="Datenschutz"
|
||||
description="Einstellungen zum Schutz deiner Daten." />
|
||||
)
|
||||
|
||||
case 'teams':
|
||||
return (
|
||||
<Card title="Teams"
|
||||
description="Verwalte dein Team und lade Mitglieder ein."
|
||||
maxWidth="auto">
|
||||
<AdminTeamsView />
|
||||
</Card>
|
||||
)
|
||||
|
||||
default:
|
||||
return notFound()
|
||||
}
|
||||
|
||||
return <>{renderTabContent()}</>
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<Tabs>
|
||||
<Tab name="Spielpläne" href="/admin/matches" />
|
||||
<Tab name="Privacy" href="/admin/privacy" />
|
||||
<Tab name="Team" href="/admin/team" />
|
||||
<Tab name="Teams" href="/admin/teams" />
|
||||
</Tabs>
|
||||
<div className="mt-6">
|
||||
{children}
|
||||
|
||||
31
src/app/admin/teams/[teamId]/TeamAdminClient.tsx
Normal file
31
src/app/admin/teams/[teamId]/TeamAdminClient.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
// ───────────────────────────────────────────────────────────
|
||||
// src/app/(admin)/admin/teams/[teamId]/TeamAdminClient.tsx
|
||||
// ───────────────────────────────────────────────────────────
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||
import TeamMemberView from '@/app/components/TeamMemberView'
|
||||
import { useTeamManager } from '@/app/hooks/useTeamManager'
|
||||
|
||||
export default function TeamAdminClient({ teamId }: { teamId: string }) {
|
||||
const [refetchKey, setRefetchKey] = useState<string>()
|
||||
const { data: session } = useSession()
|
||||
|
||||
// jetzt wird die ID korrekt übergeben ➜ /api/team/[id]
|
||||
const teamManager = useTeamManager({ teamId, refetchKey }, null)
|
||||
|
||||
if (teamManager.isLoading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<TeamMemberView
|
||||
{...teamManager}
|
||||
currentUserSteamId={session?.user?.steamId ?? ''}
|
||||
adminMode={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
src/app/admin/teams/[teamId]/page.tsx
Normal file
8
src/app/admin/teams/[teamId]/page.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
// ───────────────────────────────────────────────────────────
|
||||
// src/app/(admin)/admin/teams/[teamId]/page.tsx (SERVER)
|
||||
// ───────────────────────────────────────────────────────────
|
||||
import TeamAdminClient from './TeamAdminClient'
|
||||
|
||||
export default function Page({ params }: { params: { teamId: string } }) {
|
||||
return <TeamAdminClient teamId={params.teamId} />
|
||||
}
|
||||
12
src/app/admin/teams/page.tsx
Normal file
12
src/app/admin/teams/page.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import Card from '@/app/components/Card'
|
||||
import AdminTeamsView from '@/app/components/admin/teams/AdminTeamsView'
|
||||
|
||||
export default function AdminTeamsPage() {
|
||||
return (
|
||||
<Card title="Teams" description="Alle Teams im Überblick" maxWidth="auto">
|
||||
<AdminTeamsView />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -1,227 +1,234 @@
|
||||
// /app/api/matches/[id]/route.ts
|
||||
/* eslint-disable @typescript-eslint/return-await */
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/app/lib/auth'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/app/lib/auth'
|
||||
import { isAfter } from 'date-fns'
|
||||
|
||||
export async function GET(_: Request, context: { params: { id: string } }) {
|
||||
const { id } = context.params
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
players: {
|
||||
include: {
|
||||
user: true,
|
||||
stats: true,
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
teamAUsers: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
teamBUsers: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!match) {
|
||||
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const teamAIds = new Set(match.teamAUsers.map(u => u.steamId));
|
||||
const teamBIds = new Set(match.teamBUsers.map(u => u.steamId));
|
||||
|
||||
const playersA = match.players
|
||||
.filter(p => teamAIds.has(p.steamId))
|
||||
.map(p => ({
|
||||
user: p.user,
|
||||
stats: p.stats,
|
||||
team: p.team?.name ?? 'Team A',
|
||||
}));
|
||||
|
||||
const playersB = match.players
|
||||
.filter(p => teamBIds.has(p.steamId))
|
||||
.map(p => ({
|
||||
user: p.user,
|
||||
stats: p.stats,
|
||||
team: p.team?.name ?? 'Team B',
|
||||
}));
|
||||
|
||||
const teamA = {
|
||||
name: match.teamAUsers[0]?.team?.name ?? 'Team A',
|
||||
logo: null,
|
||||
score: match.scoreA,
|
||||
players: playersA,
|
||||
};
|
||||
|
||||
const teamB = {
|
||||
name: match.teamBUsers[0]?.team?.name ?? 'Team B',
|
||||
logo: null,
|
||||
score: match.scoreB,
|
||||
players: playersB,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
id: match.id,
|
||||
title: match.title,
|
||||
description: match.description,
|
||||
demoDate: match.demoDate,
|
||||
matchType: match.matchType,
|
||||
roundCount: match.roundCount,
|
||||
map: match.map,
|
||||
teamA,
|
||||
teamB,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`GET /matches/${id} failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
|
||||
}
|
||||
/* ───────────────────────────────────────────────
|
||||
Hilfs-Typen
|
||||
──────────────────────────────────────────────── */
|
||||
type PlayerOut = {
|
||||
user : { steamId: string; name: string | null; avatar: string | null }
|
||||
stats: any | null
|
||||
team : string
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, context: { params: { id: string } }) {
|
||||
const { id } = context.params
|
||||
const session = await getServerSession(authOptions(req))
|
||||
const userId = session?.user?.steamId
|
||||
const isAdmin = session?.user?.isAdmin
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { title, description, matchDate, players } = body
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { steamId: userId },
|
||||
include: { ledTeam: true },
|
||||
});
|
||||
/* ───────────────────────────── GET ───────────────────────────── */
|
||||
export async function GET (
|
||||
_req: Request,
|
||||
{ params: { id } }: { params: { id: string } },
|
||||
) {
|
||||
if (!id)
|
||||
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
||||
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
where : { id },
|
||||
include: {
|
||||
teamA : true,
|
||||
teamB : true,
|
||||
teamAUsers : { include: { team: true } },
|
||||
teamBUsers : { include: { team: true } },
|
||||
players : { include: { user: true, stats: true, team: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!match) {
|
||||
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||
}
|
||||
if (!match)
|
||||
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
||||
|
||||
const isTeamLeaderA = match.teamAId && user?.ledTeam?.id === match.teamAId;
|
||||
const isTeamLeaderB = match.teamBId && user?.ledTeam?.id === match.teamBId;
|
||||
/* ---------- Editierbarkeit bestimmen ---------- */
|
||||
const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
|
||||
const editable = match.matchType === 'community' && isFuture
|
||||
|
||||
if (!isAdmin && !isTeamLeaderA && !isTeamLeaderB) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
/* ---------- Spielerlisten zusammenstellen --------------------------------- */
|
||||
let playersA: PlayerOut[] = []
|
||||
let playersB: PlayerOut[] = []
|
||||
|
||||
// 🛡️ Validierung: Nur eigene Spieler
|
||||
if (!isAdmin) {
|
||||
const ownTeamId = isTeamLeaderA ? match.teamAId : match.teamBId
|
||||
|
||||
if (!ownTeamId) {
|
||||
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ownTeam = await prisma.team.findUnique({ where: { id: ownTeamId } })
|
||||
const allowed = new Set(ownTeam?.activePlayers || [])
|
||||
|
||||
const invalid = players.some((p: any) =>
|
||||
p.teamId === ownTeamId && !allowed.has(p.userId)
|
||||
)
|
||||
|
||||
if (invalid) {
|
||||
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// ❌ Alte Spieler löschen
|
||||
await prisma.matchPlayer.deleteMany({ where: { matchId: id } }) // ✅ Richtig, nur wenn das Feld korrekt heißt
|
||||
|
||||
// ✅ Neue Spieler speichern
|
||||
await prisma.matchPlayer.createMany({
|
||||
data: players.map((p: any) => ({
|
||||
matchId: id,
|
||||
userId: p.userId,
|
||||
teamId: p.teamId,
|
||||
})),
|
||||
})
|
||||
|
||||
// ✏️ Match aktualisieren
|
||||
const updated = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
players: {
|
||||
include: {
|
||||
user: true,
|
||||
stats: true,
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
if (editable) {
|
||||
/* ───── Spieler kommen direkt aus der Match-Relation ───── */
|
||||
/* ▸ teamAUsers / teamBUsers enthalten bereits User-Objekte */
|
||||
const mapUser = (u: any, fallbackTeam: string) => ({
|
||||
user : { // nur die Felder, die das Frontend braucht
|
||||
steamId: u.steamId,
|
||||
name : u.name ?? 'Unbekannt',
|
||||
avatar : u.avatar ?? null,
|
||||
},
|
||||
stats: null, // noch keine Stats vorhanden
|
||||
team : fallbackTeam,
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })
|
||||
playersA = match.teamAUsers.map(u => mapUser(u, match.teamA?.name ?? 'CT'))
|
||||
playersB = match.teamBUsers.map(u => mapUser(u, match.teamB?.name ?? 'T'))
|
||||
|
||||
/* ► Falls beim Anlegen noch keine Spieler zugewiesen wurden,
|
||||
(z. B. nach Migration) greifen wir auf activePlayers zurück */
|
||||
if (playersA.length === 0 || playersB.length === 0) {
|
||||
const [aIds, bIds] = [
|
||||
match.teamA?.activePlayers ?? [],
|
||||
match.teamB?.activePlayers ?? [],
|
||||
]
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where : { steamId: { in: [...aIds, ...bIds] } },
|
||||
select: { steamId: true, name: true, avatar: true },
|
||||
})
|
||||
const byId = Object.fromEntries(users.map(u => [u.steamId, u]))
|
||||
|
||||
playersA = aIds.map(id => mapUser(byId[id] ?? { steamId: id }, match.teamA?.name ?? 'CT'))
|
||||
playersB = bIds.map(id => mapUser(byId[id] ?? { steamId: id }, match.teamB?.name ?? 'T'))
|
||||
}
|
||||
} else {
|
||||
/* ───── Vergangene Matches: Stats-basierte Darstellung ───── */
|
||||
const setA = new Set(match.teamAUsers.map(u => u.steamId))
|
||||
const setB = new Set(match.teamBUsers.map(u => u.steamId))
|
||||
|
||||
// 🔄 Spieler wieder trennen
|
||||
const playersA = updated.players
|
||||
.filter(p => p.teamId === updated.teamAId)
|
||||
.map(p => ({
|
||||
user: p.user,
|
||||
stats: p.stats,
|
||||
team: p.team?.name ?? 'CT',
|
||||
}))
|
||||
playersA = match.players
|
||||
.filter(p => setA.has(p.steamId))
|
||||
.map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'CT' }))
|
||||
|
||||
const playersB = updated.players
|
||||
.filter(p => p.teamId === updated.teamBId)
|
||||
.map(p => ({
|
||||
user: p.user,
|
||||
stats: p.stats,
|
||||
team: p.team?.name ?? 'T',
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description,
|
||||
demoDate: updated.demoDate,
|
||||
matchType: updated.matchType,
|
||||
map: updated.map,
|
||||
scoreA: updated.scoreA,
|
||||
scoreB: updated.scoreB,
|
||||
teamA: playersA,
|
||||
teamB: playersB,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`PUT /matches/${id} failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
|
||||
playersB = match.players
|
||||
.filter(p => setB.has(p.steamId))
|
||||
.map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'T' }))
|
||||
}
|
||||
|
||||
/* ---------- Antwort ---------- */
|
||||
return NextResponse.json({
|
||||
id : match.id,
|
||||
title : match.title,
|
||||
description: match.description,
|
||||
demoDate : match.demoDate,
|
||||
matchType : match.matchType,
|
||||
roundCount : match.roundCount,
|
||||
map : match.map,
|
||||
scoreA : match.scoreA,
|
||||
scoreB : match.scoreB,
|
||||
editable, // <-- Frontend-Flag
|
||||
teamA: {
|
||||
id : match.teamA?.id ?? null,
|
||||
name : match.teamA?.name ?? 'CT',
|
||||
logo : match.teamA?.logo ?? null,
|
||||
leader : match.teamA?.leaderId ?? null,
|
||||
score : match.scoreA,
|
||||
players: playersA,
|
||||
},
|
||||
teamB: {
|
||||
id : match.teamB?.id ?? null,
|
||||
name : match.teamB?.name ?? 'T',
|
||||
logo : match.teamB?.logo ?? null,
|
||||
leader : match.teamB?.leaderId ?? null,
|
||||
score : match.scoreB,
|
||||
players: playersB,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, context: { params: { id: string } }) {
|
||||
const { id } = context.params
|
||||
/* ───────────────────────────── PUT ───────────────────────────── */
|
||||
export async function PUT (
|
||||
req: NextRequest,
|
||||
{ params: { id } }: { params: { id: string } },
|
||||
) {
|
||||
const session = await getServerSession(authOptions(req))
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
const me = session?.user
|
||||
if (!me?.steamId)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
|
||||
const match = await prisma.match.findUnique({ where: { id } })
|
||||
if (!match)
|
||||
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||
|
||||
/* ---------- erneute Editierbarkeits-Prüfung ---------- */
|
||||
const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
|
||||
const editable = match.matchType === 'community' && isFuture
|
||||
if (!editable)
|
||||
return NextResponse.json({ error: 'Match kann nicht bearbeitet werden' }, { status: 403 })
|
||||
|
||||
/* ---------- Rollen-Check (Admin oder Team-Leader) ----- */
|
||||
const userData = await prisma.user.findUnique({
|
||||
where : { steamId: me.steamId },
|
||||
include: { ledTeam: true },
|
||||
})
|
||||
const leaderOf = userData?.ledTeam?.id
|
||||
const isLeader = leaderOf && (leaderOf === match.teamAId || leaderOf === match.teamBId)
|
||||
|
||||
if (!me.isAdmin && !isLeader)
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
/* ---------- Payload einlesen & validieren ------------- */
|
||||
const { players } = await req.json() // title / description etc. bei Bedarf ergänzen
|
||||
|
||||
// wenn kein Admin: sicherstellen, dass nur Spieler des eigenen Teams gesetzt werden
|
||||
if (!me.isAdmin && leaderOf) {
|
||||
const ownTeam = await prisma.team.findUnique({ where: { id: leaderOf } })
|
||||
const allowed = new Set([
|
||||
...(ownTeam?.activePlayers ?? []),
|
||||
...(ownTeam?.inactivePlayers ?? []),
|
||||
])
|
||||
|
||||
const invalid = players.some((p: any) =>
|
||||
p.teamId === leaderOf && !allowed.has(p.steamId),
|
||||
)
|
||||
if (invalid)
|
||||
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
|
||||
}
|
||||
|
||||
/* ---------- Spieler-Mapping speichern ----------------- */
|
||||
try {
|
||||
// Lösche Match inklusive aller zugehörigen MatchPlayer-Einträge (wenn onDelete: Cascade nicht aktiv)
|
||||
await prisma.matchPlayer.deleteMany({ where: { matchId: id } })
|
||||
/* ► Listen pro Team aus dem Payload aufteilen */
|
||||
const teamAIds = players
|
||||
.filter((p: any) => p.teamId === match.teamAId)
|
||||
.map((p: any) => p.steamId)
|
||||
|
||||
// Lösche das Match
|
||||
await prisma.match.delete({ where: { id } })
|
||||
const teamBIds = players
|
||||
.filter((p: any) => p.teamId === match.teamBId)
|
||||
.map((p: any) => p.steamId)
|
||||
|
||||
await prisma.$transaction([
|
||||
/* 1) alle alten Zuordnungen löschen … */
|
||||
prisma.matchPlayer.deleteMany({ where: { matchId: id } }),
|
||||
|
||||
/* 2) … neue anlegen */
|
||||
prisma.matchPlayer.createMany({
|
||||
data: players.map((p: any) => ({
|
||||
matchId: id,
|
||||
steamId: p.steamId,
|
||||
teamId : p.teamId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
|
||||
/* 3) M-N-Relationen an Match-Eintrag aktualisieren */
|
||||
prisma.match.update({
|
||||
where: { id },
|
||||
data : {
|
||||
teamAUsers: { set: teamAIds.map((steamId: string) => ({ steamId })) },
|
||||
teamBUsers: { set: teamBIds.map((steamId: string) => ({ steamId })) },
|
||||
},
|
||||
}),
|
||||
])
|
||||
} catch (e) {
|
||||
console.error(`PUT /matches/${id} – Spielerupdate fehlgeschlagen:`, e)
|
||||
return NextResponse.json({ error: 'Failed to update players' }, { status: 500 })
|
||||
}
|
||||
|
||||
/* ---------- neue Daten abrufen & zurückgeben ---------- */
|
||||
return GET(req, { params: { id } }) // gleiche Antwort-Struktur wie oben
|
||||
}
|
||||
|
||||
/* ─────────────────────────── DELETE ─────────────────────────── */
|
||||
export async function DELETE (
|
||||
_req: NextRequest,
|
||||
{ params: { id } }: { params: { id: string } },
|
||||
) {
|
||||
const session = await getServerSession(authOptions(_req))
|
||||
if (!session?.user?.isAdmin)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.matchPlayer.deleteMany({ where: { matchId: id } }),
|
||||
prisma.match.delete({ where: { id } }),
|
||||
])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error(`DELETE /matches/${id} failed:`, err)
|
||||
|
||||
@ -1,37 +1,32 @@
|
||||
// /app/api/matches/create/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/app/lib/auth'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/app/lib/auth'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
|
||||
export async function POST (req: NextRequest) {
|
||||
/* ▸ Berechtigung ---------------------------------------------------------------- */
|
||||
/* ── Auth ▸ nur Admins ───────────────────────────── */
|
||||
const session = await getServerSession(authOptions(req))
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status : 403 })
|
||||
}
|
||||
if (!session?.user?.isAdmin)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
|
||||
/* ▸ Eingaben aus dem Body -------------------------------------------------------- */
|
||||
/* ── Body auslesen ──────────────────────────────── */
|
||||
const { teamAId, teamBId, title, description, matchDate, map } = await req.json()
|
||||
if (!teamAId || !teamBId || !matchDate)
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
|
||||
|
||||
if (!teamAId || !teamBId || !matchDate) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status : 400 })
|
||||
}
|
||||
/* ── Teams inkl. aktiver Spieler laden ───────────── */
|
||||
const [teamA, teamB] = await Promise.all([
|
||||
prisma.team.findUnique({ where: { id: teamAId }, select: { activePlayers: true } }),
|
||||
prisma.team.findUnique({ where: { id: teamBId }, select: { activePlayers: true } }),
|
||||
])
|
||||
if (!teamA || !teamB)
|
||||
return NextResponse.json({ error: 'Team not found' }, { status: 404 })
|
||||
|
||||
/* ── Match + Spieler in EINER Transaktion ────────── */
|
||||
try {
|
||||
/* ▸ Aktive Spieler der Teams laden ------------------------------------------- */
|
||||
const [teamA, teamB] = await Promise.all([
|
||||
prisma.team.findUnique({ where: { id: teamAId }, select: { activePlayers: true } }),
|
||||
prisma.team.findUnique({ where: { id: teamBId }, select: { activePlayers: true } })
|
||||
])
|
||||
|
||||
if (!teamA || !teamB) {
|
||||
return NextResponse.json({ error: 'Team not found' }, { status : 404 })
|
||||
}
|
||||
|
||||
/* ▸ Match & Match‑Players in einer Transaktion anlegen ----------------------- */
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
/* 1. Match */
|
||||
/* 1) Match mit verbundenen Team-User-Arrays anlegen */
|
||||
const newMatch = await tx.match.create({
|
||||
data: {
|
||||
teamAId,
|
||||
@ -39,28 +34,21 @@ export async function POST (req: NextRequest) {
|
||||
title : title?.trim() || `${teamAId}-${teamBId}`,
|
||||
description : description?.trim() || null,
|
||||
map : map?.trim() || null,
|
||||
demoDate : new Date(matchDate)
|
||||
}
|
||||
demoDate : new Date(matchDate),
|
||||
|
||||
/* aktive Spieler direkt verbinden */
|
||||
teamAUsers: { connect: teamA.activePlayers.map(id => ({ steamId: id })) },
|
||||
teamBUsers: { connect: teamB.activePlayers.map(id => ({ steamId: id })) },
|
||||
},
|
||||
})
|
||||
|
||||
/* 2. Spieler-Datensätze vorbereiten */
|
||||
/* 2) separate MatchPlayer-Zeilen */
|
||||
const playersData = [
|
||||
...teamA.activePlayers.map((steamId: string) => ({
|
||||
matchId: newMatch.id,
|
||||
steamId,
|
||||
teamId : teamAId
|
||||
})),
|
||||
...teamB.activePlayers.map((steamId: string) => ({
|
||||
matchId: newMatch.id,
|
||||
steamId,
|
||||
teamId : teamBId
|
||||
}))
|
||||
...teamA.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamAId })),
|
||||
...teamB.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamBId })),
|
||||
]
|
||||
|
||||
/* 3. Anlegen (nur wenn Spieler vorhanden) */
|
||||
if (playersData.length) {
|
||||
if (playersData.length)
|
||||
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
|
||||
}
|
||||
|
||||
return newMatch
|
||||
})
|
||||
@ -68,6 +56,6 @@ export async function POST (req: NextRequest) {
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
} catch (err) {
|
||||
console.error('POST /matches/create failed:', err)
|
||||
return NextResponse.json({ error: 'Failed to create match' }, { status : 500 })
|
||||
return NextResponse.json({ error: 'Failed to create match' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,46 +1,80 @@
|
||||
// /app/api/schedule/route.ts
|
||||
'use server'
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const schedules = await prisma.schedule.findMany({
|
||||
orderBy: {
|
||||
date: 'asc',
|
||||
},
|
||||
include: {
|
||||
teamA: true,
|
||||
teamB: true,
|
||||
createdBy: {
|
||||
select: {
|
||||
steamId: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
confirmedBy: {
|
||||
select: {
|
||||
steamId: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
linkedMatch: {
|
||||
select: {
|
||||
id: true,
|
||||
map: true,
|
||||
scoreA: true,
|
||||
scoreB: true,
|
||||
demoDate: true,
|
||||
/* 1) nur Community-Matches holen ------------------------------ */
|
||||
const matches = await prisma.match.findMany({
|
||||
where : { matchType: 'community' },
|
||||
orderBy : { demoDate: 'desc' }, // falls demoDate null ⇒ älter oben
|
||||
|
||||
include : {
|
||||
teamA : true,
|
||||
teamB : true,
|
||||
players: {
|
||||
include: {
|
||||
user : true,
|
||||
stats: true,
|
||||
team : true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ schedules })
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Abrufen der geplanten Matches:', error)
|
||||
return new NextResponse('Serverfehler beim Laden der geplanten Matches', {
|
||||
status: 500,
|
||||
/* 2) API-Response vereinheitlichen ---------------------------- */
|
||||
const formatted = matches.map(m => {
|
||||
/** ➜ einheitliches Datumsfeld für Frontend */
|
||||
const matchDate =
|
||||
m.demoDate ??
|
||||
// @ts-ignore – falls du optional noch ein „date“-Feld hast
|
||||
(m as any).date ??
|
||||
m.createdAt
|
||||
|
||||
return {
|
||||
id : m.id,
|
||||
title : m.title,
|
||||
map : m.map ?? null,
|
||||
matchType : 'community',
|
||||
matchDate : matchDate.toISOString(),
|
||||
|
||||
scoreA : m.scoreA,
|
||||
scoreB : m.scoreB,
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
|
||||
teamA: {
|
||||
id : m.teamA?.id ?? null,
|
||||
name : m.teamA?.name ?? 'CT',
|
||||
logo : m.teamA?.logo ?? null,
|
||||
score: m.scoreA,
|
||||
},
|
||||
teamB: {
|
||||
id : m.teamB?.id ?? null,
|
||||
name : m.teamB?.name ?? 'T',
|
||||
logo : m.teamB?.logo ?? null,
|
||||
score: m.scoreB,
|
||||
},
|
||||
|
||||
players: m.players.map(p => ({
|
||||
steamId : p.steamId,
|
||||
name : p.user?.name,
|
||||
avatar : p.user?.avatar,
|
||||
stats : p.stats,
|
||||
teamId : p.teamId,
|
||||
teamName: p.team?.name ?? null,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
/* 3) zurückgeben --------------------------------------------- */
|
||||
return NextResponse.json({ matches: formatted })
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Abrufen der Community-Matches:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'Serverfehler beim Laden der Community-Matches' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +1,80 @@
|
||||
// ✅ /api/team/[teamId]/route.ts
|
||||
// src/app/api/team/[teamId]/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import type { Player } from '@/app/types/team'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { teamId: string } }
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { teamId: string } },
|
||||
) {
|
||||
try {
|
||||
const param = await params
|
||||
/* ─── 1) Team holen ─────────────────────────────── */
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: param.teamId },
|
||||
where: { id: params.teamId },
|
||||
})
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const activePlayers = await prisma.user.findMany({
|
||||
where: { steamId: { in: team.activePlayers } },
|
||||
select: { steamId: true, name: true, avatar: true, location: true },
|
||||
/* ─── 2) Alle Steam-IDs sammeln und Users laden ─── */
|
||||
const allIds = Array.from(
|
||||
new Set([...team.activePlayers, ...team.inactivePlayers]),
|
||||
)
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where : { steamId: { in: allIds } },
|
||||
select: {
|
||||
steamId : true,
|
||||
name : true,
|
||||
avatar : true,
|
||||
location : true,
|
||||
premierRank: true,
|
||||
},
|
||||
})
|
||||
|
||||
const inactivePlayers = await prisma.user.findMany({
|
||||
where: { steamId: { in: team.inactivePlayers } },
|
||||
select: { steamId: true, name: true, avatar: true, location: true },
|
||||
})
|
||||
/* Map steamId → Player */
|
||||
const byId: Record<string, Player> = Object.fromEntries(
|
||||
users.map(u => [
|
||||
u.steamId,
|
||||
{
|
||||
steamId: u.steamId,
|
||||
name : u.name ?? 'Unbekannt',
|
||||
avatar : u.avatar ?? '/assets/img/avatars/default.png',
|
||||
location: u.location ?? '',
|
||||
premierRank: u.premierRank ?? 0,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
return NextResponse.json(team)
|
||||
/* ─── 3) Arrays umwandeln + sortieren ───────────── */
|
||||
const activePlayers = team.activePlayers
|
||||
.map(id => byId[id])
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const inactivePlayers = team.inactivePlayers
|
||||
.map(id => byId[id])
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
/* ─── 4) Antwort zusammenbauen ───────────────────── */
|
||||
const result = {
|
||||
id : team.id,
|
||||
name : team.name,
|
||||
logo : team.logo,
|
||||
leader : team.leaderId,
|
||||
createdAt : team.createdAt,
|
||||
activePlayers,
|
||||
inactivePlayers,
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Teams:', error)
|
||||
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
||||
console.error('GET /api/team/[teamId] failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Interner Serverfehler' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
47
src/app/api/team/add-players/route.ts
Normal file
47
src/app/api/team/add-players/route.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// /app/api/team/add-players/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { teamId, steamIds }: { teamId: string; steamIds: string[] } = await req.json()
|
||||
|
||||
if (!teamId || !Array.isArray(steamIds) || steamIds.length === 0) {
|
||||
return NextResponse.json({ message: 'Ungültige Parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
/* ▸ Spieler anhängen -------------------------------------------------------- */
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data : { inactivePlayers: { push: steamIds } },
|
||||
})
|
||||
|
||||
/* ▸ Frisches Team laden, um alle Player-IDs zu haben ----------------------- */
|
||||
const team = await prisma.team.findUnique({
|
||||
where : { id: teamId },
|
||||
select: { // nur das Nötigste
|
||||
id : true,
|
||||
activePlayers : true,
|
||||
inactivePlayers: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
/* ▸ Alle Teammitglieder (alt + neu) als Zielgruppe ------------------------- */
|
||||
const allPlayers = [
|
||||
...team.activePlayers,
|
||||
...team.inactivePlayers,
|
||||
]
|
||||
|
||||
/* ▸ SSE-Push --------------------------------------------------------------- */
|
||||
await sendServerSSEMessage({
|
||||
type : 'team-updated',
|
||||
teamId : team.id,
|
||||
targetUserIds : allPlayers,
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@ -1,60 +1,68 @@
|
||||
// /api/team/create/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma';
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client';
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { teamname, leader } = await req.json();
|
||||
/* ───── Request-Body ───── */
|
||||
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
|
||||
|
||||
if (!teamname || !leader) {
|
||||
return NextResponse.json({ message: 'Fehlende Eingaben.' }, { status: 400 });
|
||||
/* ► Teamname pflicht */
|
||||
if (!teamname?.trim()) {
|
||||
return NextResponse.json({ message: 'Teamname fehlt.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existingTeam = await prisma.team.findFirst({ where: { name: teamname } });
|
||||
if (existingTeam) {
|
||||
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { steamId: leader } });
|
||||
if (!existingUser) {
|
||||
return NextResponse.json({ message: 'Benutzer nicht gefunden.' }, { status: 404 });
|
||||
/* ► Name schon vergeben? */
|
||||
const dup = await prisma.team.findFirst({ where: { name: teamname } })
|
||||
if (dup) {
|
||||
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
|
||||
}
|
||||
|
||||
/* ───── Team anlegen ───── */
|
||||
const newTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: teamname,
|
||||
leaderId: leader,
|
||||
activePlayers: [leader],
|
||||
inactivePlayers: [],
|
||||
name : teamname,
|
||||
leaderId : leader ?? null, // ← nur setzen, wenn übergeben
|
||||
activePlayers : leader ? [leader] : [],
|
||||
inactivePlayers : [],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await prisma.user.update({
|
||||
where: { steamId: leader },
|
||||
data: { teamId: newTeam.id },
|
||||
});
|
||||
/* ───── Optional: Leader verknüpfen ───── */
|
||||
if (leader) {
|
||||
const user = await prisma.user.findUnique({ where: { steamId: leader } })
|
||||
if (!user) {
|
||||
// Rollback Team und Fehler ausgeben
|
||||
await prisma.team.delete({ where: { id: newTeam.id } })
|
||||
return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
steamId: leader,
|
||||
title: 'Team erstellt',
|
||||
message: `Du hast erfolgreich das Team "${teamname}" erstellt.`,
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { steamId: leader },
|
||||
data : { teamId: newTeam.id },
|
||||
})
|
||||
|
||||
// 📢 SSE Nachricht senden
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
steamId: leader,
|
||||
title : 'Team erstellt',
|
||||
message: `Du hast erfolgreich das Team „${teamname}“ erstellt.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ───── SSE an alle raus ───── */
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-created',
|
||||
title: 'Team erstellt',
|
||||
message: `Das Team "${teamname}" wurde erstellt.`,
|
||||
teamId: newTeam.id,
|
||||
});
|
||||
type : 'team-created',
|
||||
title : 'Team erstellt',
|
||||
message: `Das Team „${teamname}“ wurde erstellt.`,
|
||||
teamId : newTeam.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Team erstellt', team: newTeam });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Fehler beim Team erstellen:', error.message, error.stack);
|
||||
return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 });
|
||||
return NextResponse.json({ message: 'Team erstellt', team: newTeam })
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Team erstellen:', error)
|
||||
return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,67 +1,80 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { teamId, userIds: rawUserIds, invitedBy } = body
|
||||
const { teamId, userIds: rawUserIds, invitedBy } = await req.json()
|
||||
|
||||
if (!teamId || !rawUserIds || !invitedBy) {
|
||||
/* ------------------------------------------------------------ */
|
||||
/* Eingaben prüfen */
|
||||
/* ------------------------------------------------------------ */
|
||||
if (!teamId || !Array.isArray(rawUserIds) || rawUserIds.length === 0) {
|
||||
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userIds = rawUserIds.filter((id: string) => id !== invitedBy)
|
||||
/* Eingeladener darf nicht sich selbst einladen */
|
||||
const steamIds = rawUserIds.filter((id: string) => id !== invitedBy)
|
||||
|
||||
/* Team holen */
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
where : { id: teamId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
const teamName = team.name ?? 'Unbekanntes Team'
|
||||
|
||||
const teamName = team.name || 'Unbekanntes Team'
|
||||
|
||||
const results = await Promise.all(
|
||||
userIds.map(async (userId: string) => {
|
||||
const invitation = await prisma.teamInvite.create({
|
||||
/* ------------------------------------------------------------ */
|
||||
/* Einladungen + Benachrichtigungen erzeugen */
|
||||
/* ------------------------------------------------------------ */
|
||||
const invitationIds = await Promise.all(
|
||||
steamIds.map(async (steamId: string) => {
|
||||
/* TeamInvite anlegen – FELD-NAMEN ans Schema anpassen! */
|
||||
const invite = await prisma.teamInvite.create({
|
||||
data: {
|
||||
userId,
|
||||
teamId,
|
||||
steamId,
|
||||
type: 'team-invite',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
/* Notification anlegen */
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
title: 'Teameinladung',
|
||||
message: `Du wurdest in das Team "${teamName}" eingeladen.`,
|
||||
steamId,
|
||||
title : 'Teameinladung',
|
||||
message : `Du wurdest in das Team "${teamName}" eingeladen.`,
|
||||
actionType: 'team-invite',
|
||||
actionData: invitation.id,
|
||||
actionData: invite.id,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
/* SSE pushen */
|
||||
await sendServerSSEMessage({
|
||||
type: notification.actionType ?? 'notification',
|
||||
targetUserIds: [userId],
|
||||
message: notification.message,
|
||||
id: notification.id,
|
||||
actionType: notification.actionType ?? undefined,
|
||||
actionData: notification.actionData ?? undefined,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
type : notification.actionType ?? 'notification',
|
||||
targetUserIds: [steamId],
|
||||
message : notification.message,
|
||||
id : notification.id,
|
||||
actionType : notification.actionType ?? undefined,
|
||||
actionData : notification.actionData ?? undefined,
|
||||
createdAt : notification.createdAt.toISOString(),
|
||||
})
|
||||
|
||||
return invitation.id
|
||||
})
|
||||
return invite.id
|
||||
}),
|
||||
)
|
||||
|
||||
return NextResponse.json({ message: 'Einladungen versendet', invitationIds: results })
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Versenden der Einladungen:', error)
|
||||
return NextResponse.json({ message: 'Fehler beim Einladen' }, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{ message: 'Einladungen versendet', invitationIds },
|
||||
{ status: 200 },
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[TEAM-INVITE] Fehler:', err)
|
||||
return NextResponse.json(
|
||||
{ message: 'Fehler beim Einladen' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// src/app/api/team/kick/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
@ -6,12 +7,18 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 1) Payload-Validierung *
|
||||
* ------------------------------------------------------------------ */
|
||||
const { teamId, steamId } = await req.json()
|
||||
|
||||
if (!teamId || !steamId) {
|
||||
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 2) Team & User laden *
|
||||
* ------------------------------------------------------------------ */
|
||||
const team = await prisma.team.findUnique({ where: { id: teamId } })
|
||||
if (!team) {
|
||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||
@ -19,75 +26,87 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { steamId },
|
||||
select: { name: true }
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
const userName = user?.name ?? 'Ein Mitglied'
|
||||
const teamName = team.name ?? 'Unbekanntes Team'
|
||||
|
||||
const active = team.activePlayers.filter((id) => id !== steamId)
|
||||
const inactive = team.inactivePlayers.filter((id) => id !== steamId)
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 3) Spielerlisten aktualisieren *
|
||||
* ------------------------------------------------------------------ */
|
||||
const active = team.activePlayers.filter(id => id !== steamId)
|
||||
const inactive = team.inactivePlayers.filter(id => id !== steamId)
|
||||
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
activePlayers: { set: active },
|
||||
inactivePlayers: { set: inactive },
|
||||
data : {
|
||||
activePlayers : { set: active },
|
||||
inactivePlayers : { set: inactive },
|
||||
},
|
||||
})
|
||||
|
||||
/* der gekickte User gehört zu keinem Team mehr */
|
||||
await prisma.user.update({
|
||||
where: { steamId },
|
||||
data: { teamId: null },
|
||||
})
|
||||
|
||||
// 🟥 Gekickter User>
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: steamId,
|
||||
title: 'Teamverlassen',
|
||||
message: `Du wurdest aus dem Team "${teamName}" geworfen.`,
|
||||
actionType: 'team-kick',
|
||||
actionData: null,
|
||||
},
|
||||
})
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: notification.actionType ?? 'notification',
|
||||
targetUserIds: [steamId],
|
||||
message: notification.message,
|
||||
id: notification.id,
|
||||
actionType: notification.actionType ?? undefined,
|
||||
actionData: notification.actionData ?? undefined,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
data : { teamId: null },
|
||||
})
|
||||
|
||||
// 🟩 Verbleibende Mitglieder
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 4) Notifikation für den gekickten User *
|
||||
* ------------------------------------------------------------------ */
|
||||
const kickedNotification = await prisma.notification.create({
|
||||
data: {
|
||||
title : 'Team verlassen',
|
||||
message : `Du wurdest aus dem Team „${teamName}“ geworfen.`,
|
||||
actionType : 'team-kick',
|
||||
actionData : null,
|
||||
user : { connect: { steamId } }, // <-- Relation herstellen
|
||||
},
|
||||
})
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type : kickedNotification.actionType ?? 'notification',
|
||||
targetUserIds: [steamId],
|
||||
message : kickedNotification.message,
|
||||
id : kickedNotification.id,
|
||||
actionType : kickedNotification.actionType ?? undefined,
|
||||
actionData : kickedNotification.actionData ?? undefined,
|
||||
createdAt : kickedNotification.createdAt.toISOString(),
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 5) Notifikation für verbleibende Mitglieder *
|
||||
* ------------------------------------------------------------------ */
|
||||
const remainingUserIds = [...active, ...inactive]
|
||||
|
||||
await Promise.all(
|
||||
remainingUserIds.map(async (userId) => {
|
||||
const notification = await prisma.notification.create({
|
||||
remainingUserIds.map(async memberSteamId => {
|
||||
const n = await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
title: 'Teamupdate',
|
||||
message: `${userName} wurde aus dem Team "${teamName}" geworfen.`,
|
||||
actionType: 'team-kick-other',
|
||||
actionData: null,
|
||||
title : 'Team-Update',
|
||||
message : `${userName} wurde aus dem Team „${teamName}“ geworfen.`,
|
||||
actionType : 'team-kick-other',
|
||||
actionData : null,
|
||||
user : { connect: { steamId: memberSteamId } }, // <-- Relation
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: notification.actionType ?? 'notification',
|
||||
targetUserIds: [userId],
|
||||
message: notification.message,
|
||||
id: notification.id,
|
||||
actionType: notification.actionType ?? undefined,
|
||||
actionData: notification.actionData ?? undefined,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
type : n.actionType ?? 'notification',
|
||||
targetUserIds: [memberSteamId],
|
||||
message : n.message,
|
||||
id : n.id,
|
||||
actionType : n.actionType ?? undefined,
|
||||
actionData : n.actionData ?? undefined,
|
||||
createdAt : n.createdAt.toISOString(),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 6) Erfolg *
|
||||
* ------------------------------------------------------------------ */
|
||||
return NextResponse.json({ message: 'Mitglied entfernt' })
|
||||
} catch (error) {
|
||||
console.error('[KICK] Fehler:', error)
|
||||
|
||||
@ -58,7 +58,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: steamId,
|
||||
user: {
|
||||
connect: { steamId },
|
||||
},
|
||||
title: 'Teamupdate',
|
||||
message: `Du hast das Team "${team.name}" verlassen.`,
|
||||
actionType: 'team-left',
|
||||
@ -85,7 +87,9 @@ export async function POST(req: NextRequest) {
|
||||
allRemainingPlayers.map(async (userId) => {
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
user: {
|
||||
connect: { steamId: userId },
|
||||
},
|
||||
title: 'Teamupdate',
|
||||
message: `${user?.name ?? 'Ein Spieler'} hat das Team verlassen.`,
|
||||
actionType: 'team-member-left',
|
||||
@ -102,6 +106,12 @@ export async function POST(req: NextRequest) {
|
||||
actionData: notification.actionData ?? undefined,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
})
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-updated',
|
||||
teamId: team.id,
|
||||
targetUserIds: allRemainingPlayers,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@ -1,53 +1,80 @@
|
||||
// src/app/api/team/list/route.ts
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import type { Player } from '@/app/types/team'
|
||||
|
||||
/**
|
||||
* GET /api/team/list
|
||||
* Liefert alle Teams inklusive aller Mitglieder (User‑Objekte)
|
||||
* Struktur:
|
||||
* [
|
||||
* {
|
||||
* id, teamname, logo, leader, createdAt,
|
||||
* players: [ { steamId, name, avatar, location } ]
|
||||
* },
|
||||
* …
|
||||
* ]
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
/* 1. Alle Teams holen */
|
||||
const teams = await prisma.team.findMany()
|
||||
/* 1) Alle Teams mit reinen Steam-ID-Arrays holen ---------------- */
|
||||
const teams = await prisma.team.findMany({
|
||||
select: {
|
||||
id : true,
|
||||
name : true,
|
||||
logo : true,
|
||||
leaderId : true,
|
||||
createdAt : true,
|
||||
activePlayers : true, // string[]
|
||||
inactivePlayers: true, // string[]
|
||||
},
|
||||
})
|
||||
|
||||
/* 2. Für jedes Team die zugehörigen User‑Datensätze besorgen */
|
||||
const teamsWithPlayers = await Promise.all(
|
||||
teams.map(async (t) => {
|
||||
const steamIds = [...t.activePlayers, ...t.inactivePlayers]
|
||||
/* 2) Einmalig ALLE vorkommenden Steam-IDs sammeln --------------- */
|
||||
const uniqueIds = new Set<string>()
|
||||
teams.forEach(t => {
|
||||
t.activePlayers.forEach(id => uniqueIds.add(id))
|
||||
t.inactivePlayers.forEach(id => uniqueIds.add(id))
|
||||
})
|
||||
|
||||
// User abrufen, die in active+inactive vorkommen
|
||||
const players = await prisma.user.findMany({
|
||||
where: { steamId: { in: steamIds } },
|
||||
select: {
|
||||
steamId: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
location: true,
|
||||
},
|
||||
})
|
||||
/* 3) Die zugehörigen User-Objekte laden (ein Query) ------------- */
|
||||
const users = await prisma.user.findMany({
|
||||
where : { steamId: { in: [...uniqueIds] } },
|
||||
select: {
|
||||
steamId : true,
|
||||
name : true,
|
||||
avatar : true,
|
||||
location : true,
|
||||
premierRank: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...t,
|
||||
players,
|
||||
}
|
||||
})
|
||||
)
|
||||
/* 4) steamId → Player: Null-Werte abfangen ------------------------ */
|
||||
const byId: Record<string, Player> = {}
|
||||
|
||||
return NextResponse.json({ teams: teamsWithPlayers }, { status: 200 })
|
||||
/* Fallbacks definieren */
|
||||
const DEFAULT_AVATAR = '/assets/img/avatars/default.png' // oder was du nutzt
|
||||
const UNKNOWN_NAME = 'Unbekannt'
|
||||
|
||||
users.forEach(u => {
|
||||
byId[u.steamId] = {
|
||||
steamId : u.steamId,
|
||||
name : u.name ?? UNKNOWN_NAME,
|
||||
avatar : u.avatar ?? DEFAULT_AVATAR,
|
||||
location : u.location ?? '',
|
||||
premierRank: u.premierRank ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
/* 5) Teams zurückgeben – jetzt mit aufgelösten Spielern --------- */
|
||||
const result = teams.map(t => ({
|
||||
id : t.id,
|
||||
name : t.name,
|
||||
logo : t.logo,
|
||||
leader: t.leaderId, // Steam-ID des Leaders
|
||||
createdAt: t.createdAt,
|
||||
activePlayers : t.activePlayers
|
||||
.map(id => byId[id])
|
||||
.filter(Boolean) as Player[],
|
||||
inactivePlayers: t.inactivePlayers
|
||||
.map(id => byId[id])
|
||||
.filter(Boolean) as Player[],
|
||||
}))
|
||||
|
||||
return NextResponse.json({ teams: result })
|
||||
} catch (err) {
|
||||
console.error('GET /api/team/list failed:', err)
|
||||
return NextResponse.json(
|
||||
{ message: 'Interner Serverfehler' },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,26 +7,26 @@ import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
/* ---- Session prüfen ------------------------------------------ */
|
||||
/* ───────────────── Session prüfen ────────────────────── */
|
||||
const session = await getServerSession(authOptions(req))
|
||||
if (!session?.user?.steamId) {
|
||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
const requesterSteamId = session.user.steamId
|
||||
|
||||
/* ---- Body validieren ----------------------------------------- */
|
||||
/* ───────────────── Body validieren ────────────────────── */
|
||||
const { teamId } = await req.json()
|
||||
if (!teamId) {
|
||||
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||
}
|
||||
|
||||
/* ---- Team holen ---------------------------------------------- */
|
||||
/* ───────────────── Team holen ─────────────────────────── */
|
||||
const team = await prisma.team.findUnique({ where: { id: teamId } })
|
||||
if (!team) {
|
||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
/* ---- Bereits Mitglied? --------------------------------------- */
|
||||
/* ───────────────── Bereits Mitglied? ──────────────────── */
|
||||
if (
|
||||
requesterSteamId === team.leaderId ||
|
||||
team.activePlayers.includes(requesterSteamId) ||
|
||||
@ -35,43 +35,47 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ message: 'Du bist bereits Mitglied' }, { status: 400 })
|
||||
}
|
||||
|
||||
/* ---- Doppelte Anfrage vermeiden ------------------------------ */
|
||||
/* ───────────────── Doppelte Anfrage vermeiden ─────────── */
|
||||
const existingInvite = await prisma.teamInvite.findFirst({
|
||||
where: { steamId: requesterSteamId, teamId },
|
||||
where: {
|
||||
steamId: requesterSteamId,
|
||||
teamId,
|
||||
type : 'team-join-request',
|
||||
},
|
||||
})
|
||||
if (existingInvite) {
|
||||
return NextResponse.json({ message: 'Anfrage läuft bereits' }, { status: 200 })
|
||||
}
|
||||
|
||||
/* ---- Invitation anlegen -------------------------------------- */
|
||||
await prisma.teamInvite.create({
|
||||
/* ───────────────── Invitation anlegen ─────────────────── */
|
||||
const invitation = await prisma.teamInvite.create({
|
||||
data: {
|
||||
steamId: requesterSteamId, // User.steamId
|
||||
teamId,
|
||||
type: 'team-join-request',
|
||||
steamId: requesterSteamId,
|
||||
teamId ,
|
||||
type : 'team-join-request',
|
||||
},
|
||||
})
|
||||
|
||||
/* ---- Leader benachrichtigen ---------------------------------- */
|
||||
/* ───────────────── Leader benachrichtigen ─────────────── */
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
steamId: team.leaderId!,
|
||||
title: 'Beitrittsanfrage',
|
||||
message: `${session.user.name ?? 'Ein Spieler'} möchte deinem Team beitreten.`,
|
||||
steamId : team.leaderId!, // garantiert vorhanden
|
||||
title : 'Beitrittsanfrage',
|
||||
message : `${session.user.name ?? 'Ein Spieler'} möchte deinem Team beitreten.`,
|
||||
actionType: 'team-join-request',
|
||||
actionData: teamId,
|
||||
actionData: invitation.id, // ← WICHTIG: invitationId
|
||||
},
|
||||
})
|
||||
|
||||
/* ---- SSE Event (optional) ------------------------------ */
|
||||
|
||||
/* ───────────────── SSE Event auslösen ─────────────────── */
|
||||
await sendServerSSEMessage({
|
||||
type: notification.actionType ?? 'notification',
|
||||
type : notification.actionType ?? 'notification',
|
||||
targetUserIds: [team.leaderId],
|
||||
message: notification.message,
|
||||
id: notification.id,
|
||||
actionType: notification.actionType ?? undefined,
|
||||
actionData: notification.actionData ?? undefined,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
message : notification.message,
|
||||
id : notification.id,
|
||||
actionType : notification.actionType ?? undefined,
|
||||
actionData : notification.actionData ?? undefined, // invitation.id
|
||||
createdAt : notification.createdAt.toISOString(),
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 })
|
||||
|
||||
@ -61,7 +61,7 @@ export async function GET() {
|
||||
return NextResponse.json({
|
||||
team: {
|
||||
id: team.id,
|
||||
teamname: team.name,
|
||||
name: team.name,
|
||||
logo: team.logo,
|
||||
leader: team.leaderId,
|
||||
activePlayers,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
// /app/api/team/transfer-leader/route.ts
|
||||
|
||||
// src/app/api/team/transfer-leader/route.ts
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
@ -8,48 +7,112 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { teamId, newLeaderSteamId } = await req.json()
|
||||
|
||||
/* ────────────── Parameter prüfen ────────────── */
|
||||
if (!teamId || !newLeaderSteamId) {
|
||||
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
})
|
||||
|
||||
/* ────────────── Team holen ───────────────────── */
|
||||
const team = await prisma.team.findUnique({ where: { id: teamId } })
|
||||
if (!team) {
|
||||
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
|
||||
}
|
||||
|
||||
const allPlayerIds = Array.from(new Set([
|
||||
...(team.activePlayers || []),
|
||||
...(team.inactivePlayers || []),
|
||||
]))
|
||||
|
||||
/* ────────────── Mitgliedschaft prüfen ────────── */
|
||||
const allPlayerIds = Array.from(
|
||||
new Set([
|
||||
...(team.activePlayers ?? []),
|
||||
...(team.inactivePlayers ?? []),
|
||||
]),
|
||||
)
|
||||
if (!allPlayerIds.includes(newLeaderSteamId)) {
|
||||
return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 })
|
||||
return NextResponse.json({
|
||||
message: 'Neuer Leader ist kein Teammitglied.',
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
/* ────────────── Leader setzen ────────────────── */
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: { leader: newLeaderSteamId },
|
||||
data : { leaderId: newLeaderSteamId },
|
||||
})
|
||||
|
||||
/* ────────────── Namen des neuen Leaders ───────── */
|
||||
const newLeader = await prisma.user.findUnique({
|
||||
where: { steamId: newLeaderSteamId },
|
||||
where : { steamId: newLeaderSteamId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
/* ────────── 1) Notification an neuen Leader ───── */
|
||||
const leaderNote = await prisma.notification.create({
|
||||
data: {
|
||||
steamId : newLeaderSteamId,
|
||||
title : 'Beförderung',
|
||||
message : `Du bist jetzt Teamleader von "${team.name}".`,
|
||||
actionType: 'team-leader-self',
|
||||
actionData: teamId,
|
||||
},
|
||||
})
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-leader-changed',
|
||||
title: 'Neuer Teamleader',
|
||||
message: `${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader.`,
|
||||
teamId,
|
||||
type : leaderNote.actionType ?? 'notification',
|
||||
targetUserIds: [newLeaderSteamId],
|
||||
message : leaderNote.message,
|
||||
id : leaderNote.id,
|
||||
actionType : leaderNote.actionType ?? undefined,
|
||||
actionData : leaderNote.actionData ?? undefined,
|
||||
createdAt : leaderNote.createdAt.toISOString(),
|
||||
})
|
||||
|
||||
/* ────────── 2) Info an alle anderen ───────────── */
|
||||
const others: string[] = [
|
||||
...allPlayerIds,
|
||||
team.leaderId ?? undefined, // alter Leader (kann null sein)
|
||||
]
|
||||
/* Type-Guard: nur echte Strings behalten */
|
||||
.filter((id): id is string => typeof id === 'string' && id !== newLeaderSteamId)
|
||||
|
||||
if (others.length) {
|
||||
const text =
|
||||
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
|
||||
|
||||
const notes = await Promise.all(
|
||||
others.map(steamId =>
|
||||
prisma.notification.create({
|
||||
data: {
|
||||
steamId,
|
||||
title : 'Neuer Teamleader',
|
||||
message: text,
|
||||
actionType: 'team-leader-changed',
|
||||
actionData: newLeaderSteamId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type : 'team-leader-changed',
|
||||
targetUserIds: others,
|
||||
message : text,
|
||||
id : notes[0].id, // eine Referenz-ID reicht
|
||||
actionType : 'team-leader-changed',
|
||||
actionData : newLeaderSteamId,
|
||||
createdAt : notes[0].createdAt.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/* ── 3) Globales “team-updated” an ALLE ──────────────── */
|
||||
await sendServerSSEMessage({
|
||||
type : 'team-updated',
|
||||
targetUserIds: allPlayerIds,
|
||||
teamId,
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Leaderwechsel:', error)
|
||||
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
|
||||
return NextResponse.json({
|
||||
message: 'Serverfehler beim Leaderwechsel.',
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,6 +95,12 @@ export async function POST(
|
||||
actionData: notification.actionData ?? undefined,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
})
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-updated',
|
||||
teamId,
|
||||
targetUserIds: allSteamIds,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
76
src/app/api/user/list/route.ts
Normal file
76
src/app/api/user/list/route.ts
Normal file
@ -0,0 +1,76 @@
|
||||
// ───────────────────────────────────────────────────────────
|
||||
// src/app/api/user/list/route.ts
|
||||
// ───────────────────────────────────────────────────────────
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// POST { steamIds: string[] }
|
||||
// -----------------------------------------------------------
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { steamIds } = await req.json()
|
||||
|
||||
if (!Array.isArray(steamIds) || steamIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: 'steamIds muss ein Array mit mindestens einer ID sein.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { steamId: { in: steamIds } },
|
||||
select: {
|
||||
steamId: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
location: true,
|
||||
premierRank: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ users }, { status: 200 })
|
||||
} catch (err) {
|
||||
console.error('POST /api/user/list failed:', err)
|
||||
return NextResponse.json(
|
||||
{ message: 'Interner Serverfehler' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// GET /api/user/list?ids=ID1,ID2,ID3
|
||||
// -----------------------------------------------------------
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const idsParam = req.nextUrl.searchParams.get('ids') ?? ''
|
||||
const steamIds = idsParam.split(',').filter(Boolean)
|
||||
|
||||
if (steamIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Bitte mindestens eine ID in ids=… angeben.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { steamId: { in: steamIds } },
|
||||
select: {
|
||||
steamId: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
location: true,
|
||||
premierRank: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ users }, { status: 200 })
|
||||
} catch (err) {
|
||||
console.error('GET /api/user/list failed:', err)
|
||||
return NextResponse.json(
|
||||
{ message: 'Interner Serverfehler' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -52,7 +52,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
|
||||
}
|
||||
setShowModal(false)
|
||||
|
||||
setRefetchKey(Date.now().toString()) // 🔥 Neuer Key zum Reload
|
||||
setRefetchKey(Date.now().toString())
|
||||
}, 1500)
|
||||
|
||||
} catch (err: any) {
|
||||
@ -94,6 +94,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
|
||||
setStatus('idle')
|
||||
setMessage('')
|
||||
}}
|
||||
placeholder="Gebe einen Teamnamen ein..."
|
||||
className={`py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600
|
||||
${
|
||||
status === 'error'
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { Player } from '../types/team'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type DroppableZoneProps = {
|
||||
id: string
|
||||
@ -10,19 +11,40 @@ type DroppableZoneProps = {
|
||||
activeDragItem: Player | null
|
||||
}
|
||||
|
||||
export function DroppableZone({ id, label, children, activeDragItem }: DroppableZoneProps) {
|
||||
export function DroppableZone({
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
}: DroppableZoneProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({ id })
|
||||
|
||||
const baseClasses = `
|
||||
p-4 rounded-lg border-2 min-h-[200px] transition-all
|
||||
${isOver ? 'border-blue-400 border-dashed bg-gray-200 dark:bg-neutral-800' : 'border-gray-300 dark:border-neutral-700'}
|
||||
`
|
||||
/* ───────────── sichtbare Zone ───────────── */
|
||||
const zoneClasses = clsx(
|
||||
// immer volle Zeilenbreite
|
||||
'w-full rounded-lg p-4 transition-colors',
|
||||
// Mindesthöhe einer MiniCard (damit sie bei leeren Teams nicht einklappt)
|
||||
'min-h-[200px]',
|
||||
isOver
|
||||
? 'border-2 border-dashed border-blue-400 bg-blue-400/10'
|
||||
: 'border border-gray-300 dark:border-neutral-700'
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={baseClasses}>
|
||||
<h3 className="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">{label}</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-4">
|
||||
{children}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-md font-semibold text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</h3>
|
||||
|
||||
{/* Hier sitzt der Droppable-Ref */}
|
||||
<div ref={setNodeRef} className={zoneClasses}>
|
||||
<div
|
||||
className="
|
||||
grid gap-4 justify-start
|
||||
[grid-template-columns:repeat(5,minmax(0,160px))]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,177 +1,245 @@
|
||||
/* ------------------------------------------------------------------
|
||||
/app/components/EditMatchPlayersModal.tsx
|
||||
– zeigt ALLE Spieler des gewählten Teams & nutzt DroppableZone-IDs
|
||||
"active" / "inactive" analog zur TeamMemberView.
|
||||
------------------------------------------------------------------- */
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Modal from '@/app/components/Modal'
|
||||
import MiniCard from '@/app/components/MiniCard'
|
||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { Player } from '@/app/types/team'
|
||||
import { Team } from '@/app/types/team'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import {
|
||||
DndContext, closestCenter, DragOverlay,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext, verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
matchId: string
|
||||
teamA: Team
|
||||
teamB: Team
|
||||
initialPlayersA: string[]
|
||||
initialPlayersB: string[]
|
||||
import Modal from '@/app/components/Modal'
|
||||
import SortableMiniCard from '@/app/components/SortableMiniCard'
|
||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||
import { DroppableZone } from '@/app/components/DroppableZone'
|
||||
|
||||
import type { Player, Team } from '@/app/types/team'
|
||||
|
||||
/* ───────────────────────── Typen ────────────────────────── */
|
||||
export type EditSide = 'A' | 'B'
|
||||
|
||||
interface Props {
|
||||
show : boolean
|
||||
onClose : () => void
|
||||
matchId : string
|
||||
teamA : Team
|
||||
teamB : Team
|
||||
side : EditSide // welches Team wird editiert?
|
||||
initialA: string[] // bereits eingesetzte Spieler-IDs
|
||||
initialB: string[]
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
export default function EditMatchPlayersModal({
|
||||
show,
|
||||
onClose,
|
||||
matchId,
|
||||
teamA,
|
||||
teamB,
|
||||
initialPlayersA,
|
||||
initialPlayersB,
|
||||
onSaved,
|
||||
}: Props) {
|
||||
/* ───────────────────── Komponente ──────────────────────── */
|
||||
export default function EditMatchPlayersModal (props: Props) {
|
||||
const {
|
||||
show, onClose, matchId,
|
||||
teamA, teamB, side,
|
||||
initialA, initialB,
|
||||
onSaved,
|
||||
} = props
|
||||
|
||||
/* ---- Rollen-Check --------------------------------------- */
|
||||
const { data: session } = useSession()
|
||||
const [playersA, setPlayersA] = useState<Player[]>([])
|
||||
const [playersB, setPlayersB] = useState<Player[]>([])
|
||||
const [selectedA, setSelectedA] = useState<string[]>([])
|
||||
const [selectedB, setSelectedB] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const meSteam = session?.user?.steamId
|
||||
const isAdmin = session?.user?.isAdmin
|
||||
const isLeader = side === 'A'
|
||||
? meSteam === teamA.leader
|
||||
: meSteam === teamB.leader
|
||||
const canEdit = isAdmin || isLeader
|
||||
|
||||
const steamId = session?.user?.steamId
|
||||
const isLeaderA = steamId && teamA?.leader && steamId === teamA.leader
|
||||
const isLeaderB = steamId && teamB?.leader && steamId === teamB.leader
|
||||
const isAdmin = session?.user?.isAdmin
|
||||
/* ---- States --------------------------------------------- */
|
||||
const [players, setPlayers] = useState<Player[]>([])
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [dragItem, setDragItem] = useState<Player | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const canEdit = isAdmin || isLeaderA || isLeaderB
|
||||
|
||||
if (!teamA || !teamB) return <LoadingSpinner />
|
||||
/* ---- Team-Info ------------------------------------------ */
|
||||
const team = side === 'A' ? teamA : teamB
|
||||
const other = side === 'A' ? teamB : teamA
|
||||
const otherInit = side === 'A' ? initialB : initialA
|
||||
const myInit = side === 'A' ? initialA : initialB
|
||||
|
||||
/* ---- Komplett-Spielerliste laden ------------------------ */
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
fetchTeamPlayers()
|
||||
setSelectedA(initialPlayersA)
|
||||
setSelectedB(initialPlayersB)
|
||||
setSaved(false)
|
||||
}
|
||||
}, [show])
|
||||
if (!show) return
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/team/${team.id}`)
|
||||
const data = await res.json()
|
||||
|
||||
const fetchTeamPlayers = async () => {
|
||||
try {
|
||||
const [resA, resB] = await Promise.all([
|
||||
fetch(`/api/team/${teamA.id}`).then(res => res.json()),
|
||||
fetch(`/api/team/${teamB.id}`).then(res => res.json()),
|
||||
])
|
||||
setPlayersA(resA.activePlayers || [])
|
||||
setPlayersB(resB.activePlayers || [])
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Spieler:', err)
|
||||
}
|
||||
/* ❶ aktive + inaktive Spieler zusammenführen */
|
||||
const all = [
|
||||
...(data.activePlayers as Player[] ?? []),
|
||||
...(data.inactivePlayers as Player[] ?? []),
|
||||
].filter((p, i, arr) => arr.findIndex(x => x.steamId === p.steamId) === i) // dedupe
|
||||
|
||||
setPlayers(all.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
setSelected(myInit) // übernommene Line-up
|
||||
setSaved(false)
|
||||
} catch (e) {
|
||||
console.error('[EditMatchPlayersModal] load error:', e)
|
||||
}
|
||||
})()
|
||||
}, [show, team.id, myInit])
|
||||
|
||||
/* ---- Drag’n’Drop-Handler -------------------------------- */
|
||||
const onDragStart = ({ active }: any) => {
|
||||
setDragItem(players.find(p => p.steamId === active.id) ?? null)
|
||||
}
|
||||
|
||||
const toggleSelect = (team: 'A' | 'B', steamId: string) => {
|
||||
if (team === 'A') {
|
||||
setSelectedA(prev =>
|
||||
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
|
||||
)
|
||||
} else {
|
||||
setSelectedB(prev =>
|
||||
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
|
||||
)
|
||||
}
|
||||
const onDragEnd = ({ active, over }: any) => {
|
||||
setDragItem(null)
|
||||
if (!over) return
|
||||
|
||||
const id = active.id as string
|
||||
const dropZone = over.id as string // "active" | "inactive"
|
||||
const already = selected.includes(id)
|
||||
const toActive = dropZone === 'active'
|
||||
|
||||
if ( (toActive && already) || (!toActive && !already) ) return
|
||||
|
||||
setSelected(sel =>
|
||||
toActive
|
||||
? [...sel, id].slice(0, 5) // max 5 einsatzfähig
|
||||
: sel.filter(x => x !== id),
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Speichern ------------------------------------------ */
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
setSaving(true)
|
||||
try {
|
||||
const players = [
|
||||
...selectedA.map(userId => ({ userId, teamId: teamA.id })),
|
||||
...selectedB.map(userId => ({ userId, teamId: teamB.id })),
|
||||
]
|
||||
const body = {
|
||||
players: [
|
||||
/* akt. Auswahl für die bearbeitete Seite */
|
||||
...selected.map(steamId => ({ steamId, teamId: team.id })),
|
||||
|
||||
/* unveränderte Gegenseite unbedingt mitschicken! */
|
||||
...otherInit.map(steamId => ({ steamId, teamId: other.id })),
|
||||
],
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/matches/${matchId}`, {
|
||||
method: 'PUT',
|
||||
method : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ players }),
|
||||
body : JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error('Fehler beim Speichern')
|
||||
if (!res.ok) throw new Error()
|
||||
|
||||
setSaved(true)
|
||||
onSaved?.()
|
||||
} catch (err) {
|
||||
console.error('Speichern fehlgeschlagen:', err)
|
||||
} catch (e) {
|
||||
console.error('[EditMatchPlayersModal] save error:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Listen trennen ------------------------------------- */
|
||||
const active = players.filter(p => selected.includes(p.steamId))
|
||||
const inactive = players.filter(p => !selected.includes(p.steamId))
|
||||
|
||||
/* ---- UI -------------------------------------------------- */
|
||||
if (!show) return null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id="edit-match-players-modal"
|
||||
title="Spieler bearbeiten"
|
||||
show={show}
|
||||
id="edit-match-players"
|
||||
title={`Spieler bearbeiten – ${team.name ?? 'Team'}`}
|
||||
show
|
||||
onClose={onClose}
|
||||
onSave={handleSave}
|
||||
closeButtonTitle={saved ? '✓ gespeichert' : 'Speichern'}
|
||||
closeButtonTitle={
|
||||
saved ? '✓ gespeichert' : saving ? 'Speichern …' : 'Speichern'
|
||||
}
|
||||
closeButtonColor={saved ? 'green' : 'blue'}
|
||||
disableSave={!canEdit || saving}
|
||||
maxWidth='sm:max-w-2xl'
|
||||
>
|
||||
{!canEdit ? (
|
||||
{!canEdit && (
|
||||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
Du bist kein Teamleiter dieses Matches.
|
||||
Du darfst dieses Team nicht bearbeiten.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{saved && (
|
||||
<div className="mb-4 text-green-700 bg-green-100 border border-green-200 rounded px-4 py-2 text-sm">
|
||||
Änderungen gespeichert
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">{teamA.teamname}</h3>
|
||||
{playersA.length === 0 ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{playersA.map((p) => (
|
||||
<MiniCard
|
||||
key={p.steamId}
|
||||
title={p.name}
|
||||
avatar={p.avatar}
|
||||
steamId={p.steamId}
|
||||
location={p.location}
|
||||
selected={selectedA.includes(p.steamId)}
|
||||
onSelect={() => toggleSelect('A', p.steamId)}
|
||||
currentUserSteamId={steamId!}
|
||||
teamLeaderSteamId={teamA.leader}
|
||||
hideActions
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">{teamB.teamname}</h3>
|
||||
{playersB.length === 0 ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{playersB.map((p) => (
|
||||
<MiniCard
|
||||
{canEdit && (
|
||||
<>
|
||||
{players.length === 0 && <LoadingSpinner />}
|
||||
|
||||
{players.length > 0 && (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{/* --- Zone: Aktuell eingestellte Spieler ------------- */}
|
||||
<DroppableZone
|
||||
id="active"
|
||||
label={`Eingesetzte Spieler (${active.length} / 5)`}
|
||||
activeDragItem={dragItem}
|
||||
>
|
||||
<SortableContext
|
||||
items={active.map(p => p.steamId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{active.map(p => (
|
||||
<SortableMiniCard
|
||||
key={p.steamId}
|
||||
title={p.name}
|
||||
avatar={p.avatar}
|
||||
steamId={p.steamId}
|
||||
location={p.location}
|
||||
selected={selectedB.includes(p.steamId)}
|
||||
onSelect={() => toggleSelect('B', p.steamId)}
|
||||
currentUserSteamId={steamId!}
|
||||
teamLeaderSteamId={teamB.leader}
|
||||
hideActions
|
||||
player={p}
|
||||
currentUserSteamId={meSteam ?? ''}
|
||||
teamLeaderSteamId={team.leader}
|
||||
isAdmin={!!session?.user?.isAdmin}
|
||||
hideOverlay
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DroppableZone>
|
||||
|
||||
{/* --- Zone: Verfügbar (restliche) ------------------- */}
|
||||
<DroppableZone
|
||||
id="inactive"
|
||||
label="Verfügbare Spieler"
|
||||
activeDragItem={dragItem}
|
||||
>
|
||||
<SortableContext
|
||||
items={inactive.map(p => p.steamId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{inactive.map(p => (
|
||||
<SortableMiniCard
|
||||
key={p.steamId}
|
||||
player={p}
|
||||
currentUserSteamId={meSteam ?? ''}
|
||||
teamLeaderSteamId={team.leader}
|
||||
isAdmin={!!session?.user?.isAdmin}
|
||||
hideOverlay
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DroppableZone>
|
||||
|
||||
{/* Drag-Overlay */}
|
||||
<DragOverlay>
|
||||
{dragItem && (
|
||||
<SortableMiniCard
|
||||
player={dragItem}
|
||||
currentUserSteamId={meSteam ?? ''}
|
||||
teamLeaderSteamId={team.leader}
|
||||
isAdmin={!!session?.user?.isAdmin}
|
||||
hideOverlay
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@ -14,9 +14,10 @@ type Props = {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
team: Team
|
||||
directAdd?: boolean
|
||||
}
|
||||
|
||||
export default function InvitePlayersModal({ show, onClose, onSuccess, team }: Props) {
|
||||
export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) {
|
||||
const { data: session } = useSession()
|
||||
const steamId = session?.user?.steamId
|
||||
|
||||
@ -63,14 +64,16 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
|
||||
if (selectedIds.length === 0 || !steamId) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/team/invite', {
|
||||
const url = directAdd ? '/api/team/add-players'
|
||||
: '/api/team/invite'
|
||||
const body = directAdd
|
||||
? { teamId: team.id, steamIds: selectedIds }
|
||||
: { teamId: team.id, userIds: selectedIds, invitedBy: steamId }
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
teamId: team.id,
|
||||
userIds: selectedIds,
|
||||
invitedBy: steamId,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
|
||||
@ -124,15 +127,21 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
|
||||
return (
|
||||
<Modal
|
||||
id="invite-members-modal"
|
||||
title="Mitglieder einladen"
|
||||
title={directAdd ? 'Mitglieder hinzufügen' : 'Mitglieder einladen'}
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
onSave={handleInvite}
|
||||
closeButtonColor={isSuccess ? "teal" : "blue"}
|
||||
closeButtonTitle={isSuccess ? "Einladungen versendet" : "Einladungen senden"}
|
||||
closeButtonTitle={
|
||||
isSuccess
|
||||
? directAdd ? 'Mitglieder hinzugefügt' : 'Einladungen versendet'
|
||||
: directAdd ? 'Hinzufügen' : 'Einladungen senden'
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
|
||||
Wähle Benutzer aus, die du in dein Team einladen möchtest:
|
||||
<p className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
|
||||
{directAdd
|
||||
? 'Wähle Benutzer aus, die du direkt zum Team hinzufügen möchtest:'
|
||||
: 'Wähle Benutzer aus, die du in dein Team einladen möchtest:'}
|
||||
</p>
|
||||
{/* Ausgewählte Benutzer anzeigen */}
|
||||
{selectedIds.length > 0 && (
|
||||
@ -184,7 +193,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
|
||||
/>
|
||||
{isSuccess && (
|
||||
<div className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg">
|
||||
{sentCount} Einladung{sentCount !== 1 ? 'en' : ''} erfolgreich versendet!
|
||||
{directAdd
|
||||
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
|
||||
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
|
||||
@ -192,9 +203,11 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
|
||||
<LoadingSpinner />
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="col-span-full text-center text-gray-500 dark:text-neutral-400">
|
||||
{allUsers.length === 0
|
||||
? 'Niemand zum Einladen verfügbar :('
|
||||
: 'Keine Benutzer gefunden.'}
|
||||
{allUsers.length === 0
|
||||
? directAdd
|
||||
? 'Keine Benutzer verfügbar :('
|
||||
: 'Niemand zum Einladen verfügbar :('
|
||||
: 'Keine Benutzer gefunden.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -41,12 +41,12 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
|
||||
const success = await leaveTeam(steamId, team.leader === steamId ? newLeaderId : undefined)
|
||||
if (success) {
|
||||
onSuccess()
|
||||
onClose()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Verlassen:', err)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,7 +64,10 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
|
||||
Du bist der Teamleader. Bitte wähle ein anderes Mitglied aus, das die Rolle des Leaders übernehmen soll:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
|
||||
{(team.players ?? [])
|
||||
{[
|
||||
...(team.activePlayers ?? []),
|
||||
...(team.inactivePlayers ?? []),
|
||||
]
|
||||
.filter((player) => player.steamId !== steamId)
|
||||
.map((player: Player) => (
|
||||
<MiniCard
|
||||
|
||||
@ -1,136 +1,150 @@
|
||||
// /app/components/MatchDetails.tsx
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
/app/components/MatchDetails.tsx
|
||||
Zeigt pro Team einen eigenen „Spieler bearbeiten“-Button und öffnet
|
||||
das Modal nur für das angeklickte Team.
|
||||
─────────────────────────────────────────────────────────────────*/
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Match, MatchPlayer } from '../types/match'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import Table from './Table'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PremierRankBadge from './PremierRankBadge'
|
||||
import CompRankBadge from './CompRankBadge'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
interface MatchDetailsProps {
|
||||
match: Match
|
||||
}
|
||||
import Table from './Table'
|
||||
import PremierRankBadge from './PremierRankBadge'
|
||||
import CompRankBadge from './CompRankBadge'
|
||||
import EditMatchPlayersModal from './EditMatchPlayersModal'
|
||||
import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
|
||||
|
||||
function calcKDR(kills?: number, deaths?: number): string {
|
||||
if (typeof kills !== 'number' || typeof deaths !== 'number') return '-';
|
||||
if (deaths === 0) return '∞';
|
||||
return (kills / deaths).toFixed(2);
|
||||
}
|
||||
import type { Match, MatchPlayer } from '../types/match'
|
||||
|
||||
function calcADR(totalDamage?: number, roundCount?: number): string {
|
||||
if (typeof totalDamage !== 'number' || typeof roundCount !== 'number' || roundCount === 0) {
|
||||
return '-';
|
||||
}
|
||||
return (totalDamage / roundCount).toFixed(1);
|
||||
}
|
||||
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
|
||||
const kdr = (k?: number, d?: number) =>
|
||||
typeof k === 'number' && typeof d === 'number'
|
||||
? d === 0 ? '∞' : (k / d).toFixed(2)
|
||||
: '-'
|
||||
|
||||
export function MatchDetails({ match }: MatchDetailsProps) {
|
||||
const router = useRouter();
|
||||
const adr = (dmg?: number, rounds?: number) =>
|
||||
typeof dmg === 'number' && typeof rounds === 'number' && rounds > 0
|
||||
? (dmg / rounds).toFixed(1)
|
||||
: '-'
|
||||
|
||||
const matchDate = match.demoDate
|
||||
? format(new Date(match.demoDate), 'PPpp', { locale: de })
|
||||
: 'Unbekannt'
|
||||
/* ─────────────────── Komponente ─────────────────────────────── */
|
||||
export function MatchDetails ({ match }: { match: Match }) {
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
const renderPlayerTable = (players: MatchPlayer[]) => {
|
||||
|
||||
const sortedPlayers = [...players].sort((a, b) => {
|
||||
const dmgA = a.stats?.totalDamage ?? 0;
|
||||
const dmgB = b.stats?.totalDamage ?? 0;
|
||||
return dmgB - dmgA;
|
||||
});
|
||||
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
||||
const me = session?.user
|
||||
const userId = me?.steamId
|
||||
const isAdmin = me?.isAdmin
|
||||
const isLeaderA = !!userId && userId === match.teamA?.leader
|
||||
const isLeaderB = !!userId && userId === match.teamB?.leader
|
||||
const canEditA = isAdmin || isLeaderA
|
||||
const canEditB = isAdmin || isLeaderB
|
||||
|
||||
console.log(match);
|
||||
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
|
||||
const dateString = match.matchDate ?? match.demoDate
|
||||
const isFutureMatch = !!dateString && new Date(dateString).getTime() > Date.now()
|
||||
|
||||
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
|
||||
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
||||
|
||||
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
|
||||
const ColGroup = () => (
|
||||
<colgroup>
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '8%' }} />
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<col key={i} style={{ width: '5.666%' }} />
|
||||
))}
|
||||
</colgroup>
|
||||
)
|
||||
|
||||
/* ─── Spieler-Tabelle ─────────────────────────────────────── */
|
||||
const renderTable = (players: MatchPlayer[]) => {
|
||||
const sorted = [...players].sort(
|
||||
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
|
||||
)
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<ColGroup />
|
||||
<Table.Head>
|
||||
<Table.Row>
|
||||
<Table.Cell as='th'>Spieler</Table.Cell>
|
||||
<Table.Cell as='th'>Rank</Table.Cell>
|
||||
<Table.Cell as='th'>K</Table.Cell>
|
||||
<Table.Cell as='th'>A</Table.Cell>
|
||||
<Table.Cell as='th'>D</Table.Cell>
|
||||
<Table.Cell as='th'>1K</Table.Cell>
|
||||
<Table.Cell as='th'>2K</Table.Cell>
|
||||
<Table.Cell as='th'>3K</Table.Cell>
|
||||
<Table.Cell as='th'>4K</Table.Cell>
|
||||
<Table.Cell as='th'>5K</Table.Cell>
|
||||
<Table.Cell as='th'>K/D</Table.Cell>
|
||||
<Table.Cell as='th'>ADR</Table.Cell>
|
||||
<Table.Cell as='th'>HS%</Table.Cell>
|
||||
<Table.Cell as='th'>Damage</Table.Cell>
|
||||
{['Spieler','Rank','K','A','D','1K','2K','3K','4K','5K',
|
||||
'K/D','ADR','HS%','Damage'].map(h => (
|
||||
<Table.Cell key={h} as="th">{h}</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Head>
|
||||
|
||||
<Table.Body>
|
||||
{sortedPlayers.map((p: MatchPlayer, i) => (
|
||||
{sorted.map(p => (
|
||||
<Table.Row
|
||||
key={i}
|
||||
key={p.user.steamId}
|
||||
hoverable
|
||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||
>
|
||||
<Table.Cell className="py-1 flex items-center gap-2">
|
||||
{(
|
||||
<img
|
||||
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
||||
alt={p.user.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
||||
alt={p.user.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
{p.user.name ?? 'Unbekannt'}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
{match.matchType === 'premier' ? (
|
||||
<PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||
) : (
|
||||
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||
)}
|
||||
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
|
||||
<span
|
||||
className={`text-sm ${
|
||||
p.stats?.rankChange > 0
|
||||
? 'text-green-500'
|
||||
: p.stats?.rankChange < 0
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{p.stats?.rankChange > 0 ? '+' : ''}
|
||||
{p.stats?.rankChange}
|
||||
</span>
|
||||
)}
|
||||
{match.matchType === 'premier'
|
||||
? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||
: <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
|
||||
{match.matchType === 'premier' &&
|
||||
typeof p.stats?.rankChange === 'number' && (
|
||||
<span className={`text-sm ${
|
||||
p.stats.rankChange > 0 ? 'text-green-500'
|
||||
: p.stats.rankChange < 0 ? 'text-red-500' : ''}`}>
|
||||
{p.stats.rankChange > 0 ? '+' : ''}
|
||||
{p.stats.rankChange}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k5 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{calcKDR(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
|
||||
<Table.Cell>{calcADR(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
|
||||
|
||||
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{p.stats?.k5 ?? '-'}</Table.Cell>
|
||||
<Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
|
||||
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
|
||||
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>
|
||||
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Ausgabe-Datum ───────────────────────────────────────── */
|
||||
const readableDate = dateString
|
||||
? format(new Date(dateString), 'PPpp', { locale: de })
|
||||
: 'Unbekannt'
|
||||
|
||||
/* ─── Render ─────────────────────────────────────────────── */
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
Match auf {match.map} ({match.matchType})
|
||||
</h1>
|
||||
|
||||
<p className="text-sm text-gray-500">Datum: {matchDate}</p>
|
||||
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
||||
|
||||
<div className="text-md">
|
||||
<strong>Teams:</strong>{' '}
|
||||
@ -141,21 +155,65 @@ export function MatchDetails({ match }: MatchDetailsProps) {
|
||||
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 mt-4 space-y-6">
|
||||
{/* ───────── Team-Blöcke ───────── */}
|
||||
<div className="border-t pt-4 mt-4 space-y-10">
|
||||
{/* Team A */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{match.teamA?.name ?? 'Team A'}
|
||||
</h2>
|
||||
{renderPlayerTable(match.teamA.players)}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{match.teamA?.name ?? 'Team A'}
|
||||
</h2>
|
||||
|
||||
{canEditA && isFutureMatch && (
|
||||
<button
|
||||
onClick={() => setEditSide('A')}
|
||||
className="px-3 py-1.5 text-sm rounded-lg
|
||||
bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Spieler bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderTable(match.teamA.players)}
|
||||
</div>
|
||||
|
||||
{/* Team B */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{match.teamB?.name ?? 'Team B'}
|
||||
</h2>
|
||||
{renderPlayerTable(match.teamB.players)}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{match.teamB?.name ?? 'Team B'}
|
||||
</h2>
|
||||
|
||||
{canEditB && isFutureMatch && (
|
||||
<button
|
||||
onClick={() => setEditSide('B')}
|
||||
className="px-3 py-1.5 text-sm rounded-lg
|
||||
bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Spieler bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderTable(match.teamB.players)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ───────── Modal ───────── */}
|
||||
{editSide && (
|
||||
<EditMatchPlayersModal
|
||||
show
|
||||
onClose={() => setEditSide(null)}
|
||||
matchId={match.id}
|
||||
teamA={match.teamA}
|
||||
teamB={match.teamB}
|
||||
side={editSide}
|
||||
initialA={match.teamA.players.map(p => p.user.steamId)}
|
||||
initialB={match.teamB.players.map(p => p.user.steamId)}
|
||||
onSaved={() => window.location.reload()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ type MiniCardProps = {
|
||||
isLeader?: boolean
|
||||
draggable?: boolean
|
||||
currentUserSteamId: string
|
||||
teamLeaderSteamId: string
|
||||
teamLeaderSteamId?: string | null
|
||||
location?: string
|
||||
rank?: number
|
||||
dragListeners?: any
|
||||
@ -22,6 +22,8 @@ type MiniCardProps = {
|
||||
onPromote?: (steamId: string) => void
|
||||
hideActions?: boolean
|
||||
hideOverlay?: boolean
|
||||
isSelectable?: boolean
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export default function MiniCard({
|
||||
@ -42,13 +44,15 @@ export default function MiniCard({
|
||||
onPromote,
|
||||
hideActions = false,
|
||||
hideOverlay = false,
|
||||
isSelectable = true,
|
||||
isAdmin = false,
|
||||
}: MiniCardProps) {
|
||||
const isSelectable = typeof onSelect === 'function'
|
||||
const canKick = currentUserSteamId === teamLeaderSteamId && steamId !== teamLeaderSteamId
|
||||
//const isSelectable = typeof onSelect === 'function'
|
||||
const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
|
||||
|
||||
const cardClasses = `
|
||||
relative flex flex-col items-center p-4 border rounded-lg transition
|
||||
max-h-[200px] w-full overflow-hidden
|
||||
max-h-[200px] max-w-[160px] overflow-hidden
|
||||
bg-white dark:bg-neutral-800 border shadow-2xs rounded-xl
|
||||
${selected ? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400' : 'border-gray-200 dark:border-neutral-700'}
|
||||
${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''}
|
||||
@ -62,10 +66,14 @@ export default function MiniCard({
|
||||
}
|
||||
|
||||
const handleKickClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onKick?.(steamId)
|
||||
}
|
||||
|
||||
const handlePromoteClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onPromote?.(steamId)
|
||||
}
|
||||
|
||||
@ -75,7 +83,7 @@ export default function MiniCard({
|
||||
|
||||
return (
|
||||
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
|
||||
{canKick && !hideActions && !hideOverlay && (
|
||||
{canEdit && !hideActions && !hideOverlay && (
|
||||
<div className={`absolute inset-0 bg-white dark:bg-black bg-opacity-50 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${
|
||||
hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}>
|
||||
|
||||
@ -9,7 +9,7 @@ export default function MiniCardDummy({ title, onClick, children }: MiniCardDumm
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative flex flex-col items-center p-4 border border-dashed rounded-lg transition
|
||||
relative flex flex-col h-full max-h-[200px] items-center p-4 border border-dashed rounded-lg transition
|
||||
hover:border-blue-400 dark:hover:border-blue-400 hover:cursor-pointer
|
||||
border-gray-300 dark:border-neutral-700
|
||||
`}
|
||||
|
||||
@ -2,6 +2,14 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Width =
|
||||
| 'sm:max-w-sm'
|
||||
| 'sm:max-w-md'
|
||||
| 'sm:max-w-lg'
|
||||
| 'sm:max-w-xl'
|
||||
| 'sm:max-w-2xl'
|
||||
| string
|
||||
|
||||
type ModalProps = {
|
||||
id: string
|
||||
title: string
|
||||
@ -10,8 +18,10 @@ type ModalProps = {
|
||||
onClose?: () => void
|
||||
onSave?: () => void
|
||||
hideCloseButton?: boolean
|
||||
closeButtonColor?: string
|
||||
closeButtonColor?: 'blue' | 'red' | 'green' | 'teal'
|
||||
closeButtonTitle?: string
|
||||
disableSave?: boolean
|
||||
maxWidth?: Width
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
@ -22,132 +32,136 @@ export default function Modal({
|
||||
onClose,
|
||||
onSave,
|
||||
hideCloseButton = false,
|
||||
closeButtonColor = "blue",
|
||||
closeButtonTitle = "Speichern"
|
||||
closeButtonColor = 'blue',
|
||||
closeButtonTitle = 'Speichern',
|
||||
disableSave,
|
||||
maxWidth = 'sm:max-w-lg',
|
||||
}: ModalProps) {
|
||||
|
||||
/* ───────── Overlay-Lifecycle ───────── */
|
||||
useEffect(() => {
|
||||
const modalEl = document.getElementById(id);
|
||||
const hs = (window as any).HSOverlay;
|
||||
const modalEl = document.getElementById(id)
|
||||
const hs = (window as any).HSOverlay
|
||||
if (!modalEl || !hs) return
|
||||
|
||||
const handleClose = () => {
|
||||
if (typeof onClose === 'function') {
|
||||
onClose();
|
||||
/* ► Collection kann undefined oder ein Objekt sein.
|
||||
► Wir sichern uns ab und behandeln nur echte Arrays. */
|
||||
const getCollection = (): any[] =>
|
||||
Array.isArray(hs.collection) ? hs.collection : []
|
||||
|
||||
const destroyIfExists = () => {
|
||||
const inst = getCollection().find((i) => i.element === modalEl)
|
||||
inst?.destroy?.()
|
||||
if (inst) {
|
||||
hs.collection = getCollection().filter((i) => i !== inst)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
modalEl?.addEventListener('hsOverlay:close', handleClose);
|
||||
const handleClose = () => onClose?.()
|
||||
modalEl.addEventListener('hsOverlay:close', handleClose)
|
||||
|
||||
const tryOpen = () => {
|
||||
try {
|
||||
if (typeof hs?.autoInit === 'function') {
|
||||
hs.autoInit();
|
||||
}
|
||||
|
||||
if (modalEl && typeof hs?.open === 'function') {
|
||||
hs.open(modalEl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Modal] Fehler beim Öffnen des Modals:', err);
|
||||
try {
|
||||
if (show) {
|
||||
destroyIfExists()
|
||||
hs.autoInit?.()
|
||||
hs.open?.(modalEl)
|
||||
} else {
|
||||
hs.close?.(modalEl)
|
||||
destroyIfExists()
|
||||
}
|
||||
};
|
||||
|
||||
const tryClose = () => {
|
||||
try {
|
||||
if (modalEl && typeof hs?.close === 'function') {
|
||||
const isInCollection = hs?.collection?.find?.((item: any) => item.element === modalEl);
|
||||
if (isInCollection) {
|
||||
hs.close(modalEl);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Modal] Fehler beim Schließen des Modals:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (show) {
|
||||
tryOpen();
|
||||
} else {
|
||||
tryClose();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Modal] HSOverlay Fehler:', err)
|
||||
}
|
||||
|
||||
return () => {
|
||||
modalEl?.removeEventListener('hsOverlay:close', handleClose);
|
||||
};
|
||||
}, [show, id]);
|
||||
modalEl.removeEventListener('hsOverlay:close', handleClose)
|
||||
destroyIfExists()
|
||||
}
|
||||
}, [show, id, onClose])
|
||||
|
||||
/* ───────── Render ───────── */
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
data-hs-overlay="true"
|
||||
className="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none"
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-labelledby={`${id}-label`}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose?.()
|
||||
}}
|
||||
className="hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<div className="fixed inset-0 z-[-1] bg-black bg-opacity-50 dark:bg-neutral-900/70 hs-overlay-backdrop">
|
||||
<div className="hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500 mt-0 opacity-0 ease-out transition-all sm:max-w-lg sm:w-full m-3 sm:mx-auto min-h-[calc(100%-56px)] flex items-center">
|
||||
<div className="w-full flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl pointer-events-auto dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70">
|
||||
<div className="flex justify-between items-center py-3 px-4 border-b border-gray-200 dark:border-neutral-700">
|
||||
<h3 id={`${id}-label`} className="font-bold text-gray-800 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="size-8 inline-flex justify-center items-center gap-x-2 rounded-full border border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 focus:outline-hidden focus:bg-gray-200 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-400 dark:focus:bg-neutral-600"
|
||||
aria-label="Close"
|
||||
data-hs-overlay={`#${id}`}
|
||||
>
|
||||
<span className="sr-only">Schließen</span>
|
||||
<svg
|
||||
className="shrink-0 size-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 -z-10 bg-black/50 dark:bg-neutral-900/70" />
|
||||
|
||||
<div className="p-4 overflow-visible">
|
||||
{children}
|
||||
</div>
|
||||
{/* Dialog */}
|
||||
<div className={`hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500
|
||||
mt-0 opacity-0 transition-all ease-out
|
||||
${maxWidth} sm:w-full m-3 sm:mx-auto
|
||||
min-h-[calc(100%-56px)] flex items-center`}
|
||||
>
|
||||
<div className="w-full flex flex-col bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 shadow-2xs dark:shadow-neutral-700/70 rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center py-3 px-4 border-b border-gray-200 dark:border-neutral-700">
|
||||
<h3 id={`${id}-label`} className="font-bold text-gray-800 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
data-hs-overlay={`#${id}`}
|
||||
className="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700"
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
data-hs-overlay={`#${id}`}
|
||||
onClick={onClose}
|
||||
className="size-8 inline-flex justify-center items-center rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-gray-800 dark:text-neutral-400"
|
||||
>
|
||||
<svg
|
||||
className="size-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
)}
|
||||
{onSave && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className={`py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 text-white hover:bg-${closeButtonColor}-700 focus:outline-hidden focus:bg-${closeButtonColor}-700 disabled:opacity-50 disabled:pointer-events-none`}
|
||||
>
|
||||
{closeButtonTitle}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 overflow-visible">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
data-hs-overlay={`#${id}`}
|
||||
onClick={onClose}
|
||||
className="py-2 px-3 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onSave && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={disableSave}
|
||||
className={`py-2 px-3 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 hover:bg-${closeButtonColor}-700 focus:bg-${closeButtonColor}-700 text-white`}
|
||||
>
|
||||
{closeButtonTitle}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ export default function Navbar({ children }: { children?: React.ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hs-navbar-to-overlay" className="hs-overlay hs-overlay-open:translate-x-0 [--auto-close:sm] -translate-x-full fixed top-0 start-0 transition-all duration-300 transform h-full w-full sm:w-96 z-60 bg-white border-e sm:static sm:block sm:h-auto sm:w-full sm:border-e-transparent sm:transition-none sm:transform-none sm:translate-x-0 sm:z-40 dark:bg-neutral-800 sm:dark:bg-neutral-900 dark:border-e-neutral-700 sm:dark:border-e-transparent hidden" role="dialog" tabindex="-1" aria-label="Sidebar" data-hs-overlay-close-on-resize>
|
||||
<div id="hs-navbar-to-overlay" className="hs-overlay hs-overlay-open:translate-x-0 [--auto-close:sm] -translate-x-full fixed top-0 start-0 transition-all duration-300 transform h-full w-full sm:w-96 z-60 bg-white border-e sm:static sm:block sm:h-auto sm:w-full sm:border-e-transparent sm:transition-none sm:transform-none sm:translate-x-0 sm:z-40 dark:bg-neutral-800 sm:dark:bg-neutral-900 dark:border-e-neutral-700 sm:dark:border-e-transparent hidden" role="dialog" tabIndex={-1} aria-label="Sidebar" data-hs-overlay-close-on-resize>
|
||||
<div className="overflow-hidden overflow-y-auto h-full [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500">
|
||||
<div className="flex flex-col gap-y-3 sm:gap-y-0 sm:flex-row sm:items-center sm:justify-end p-2 sm:p-0">
|
||||
<div className="py-3 sm:hidden flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
|
||||
|
||||
@ -62,6 +62,7 @@ export default function NoTeamView() {
|
||||
currentUserSteamId={session?.user?.steamId || ''}
|
||||
invitationId={teamToInvitationId[team.id]}
|
||||
onUpdateInvitation={updateInvitationMap}
|
||||
adminMode={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,9 @@ import { useSession } from 'next-auth/react'
|
||||
import { useTeamManager } from '../hooks/useTeamManager'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
/* Typen */
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
type Notification = {
|
||||
id: string
|
||||
text: string
|
||||
@ -16,8 +19,11 @@ type Notification = {
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
/* Komponente */
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
export default function NotificationCenter() {
|
||||
/* --- Hooks & States ------------------------------------ */
|
||||
const { data: session } = useSession()
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -28,20 +34,18 @@ export default function NotificationCenter() {
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [animateBell, setAnimateBell] = useState(false)
|
||||
|
||||
/* --- Aktionen beim Klick auf eine Notification ---------- */
|
||||
const onNotificationClick = (notification: Notification) => {
|
||||
if (!notification.actionData) return;
|
||||
|
||||
if (!notification.actionData) return
|
||||
try {
|
||||
const data = JSON.parse(notification.actionData);
|
||||
console.error('Weiterleitung: ', notification.actionData);
|
||||
if (data.redirectUrl) {
|
||||
router.push(data.redirectUrl);
|
||||
}
|
||||
const data = JSON.parse(notification.actionData)
|
||||
if (data.redirectUrl) router.push(data.redirectUrl)
|
||||
} catch (err) {
|
||||
console.error('Ungültige actionData:', err);
|
||||
console.error('[NotificationCenter] Ungültige actionData:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Initiale Daten laden + SSE verbinden --------------- */
|
||||
useEffect(() => {
|
||||
const steamId = session?.user?.steamId
|
||||
if (!steamId) return
|
||||
@ -52,12 +56,12 @@ export default function NotificationCenter() {
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
const data = await res.json()
|
||||
const loaded = data.notifications.map((n: any) => ({
|
||||
id: n.id,
|
||||
text: n.message,
|
||||
read: n.read,
|
||||
id : n.id,
|
||||
text : n.message,
|
||||
read : n.read,
|
||||
actionType: n.actionType,
|
||||
actionData: n.actionData,
|
||||
createdAt: n.createdAt,
|
||||
createdAt : n.createdAt,
|
||||
}))
|
||||
setNotifications(loaded)
|
||||
} catch (err) {
|
||||
@ -66,44 +70,33 @@ export default function NotificationCenter() {
|
||||
}
|
||||
|
||||
loadNotifications()
|
||||
connect(steamId)
|
||||
connect(steamId) // SSE starten
|
||||
}, [session?.user?.steamId, connect])
|
||||
|
||||
/* --- Live-Updates über SSE empfangen -------------------- */
|
||||
useEffect(() => {
|
||||
if (!source) return
|
||||
|
||||
/* Handler für JEDES eintreffende Paket ------------------ */
|
||||
const handleEvent = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'heartbeat') return
|
||||
|
||||
const isNotificationType = [
|
||||
'notification',
|
||||
'invitation',
|
||||
'team-invite',
|
||||
'team-joined',
|
||||
'team-member-joined',
|
||||
'team-kick',
|
||||
'team-kick-other',
|
||||
'team-left',
|
||||
'team-member-left',
|
||||
'team-leader-changed',
|
||||
'team-join-request',
|
||||
'expired-sharecode'
|
||||
].includes(data.type)
|
||||
|
||||
if (!isNotificationType) return
|
||||
if (data.type === 'heartbeat') return // Ping ignorieren
|
||||
|
||||
/* Neues Notification-Objekt erzeugen */
|
||||
const newNotification: Notification = {
|
||||
id: data.id,
|
||||
text: data.message || 'Neue Benachrichtigung',
|
||||
read: false,
|
||||
id : data.id ?? crypto.randomUUID(),
|
||||
text : data.message ?? 'Neue Benachrichtigung',
|
||||
read : false,
|
||||
actionType: data.actionType,
|
||||
actionData: data.actionData,
|
||||
createdAt: data.createdAt,
|
||||
createdAt : data.createdAt ?? new Date().toISOString(),
|
||||
}
|
||||
|
||||
/* State updaten (immer oben einsortieren) */
|
||||
setNotifications(prev => [newNotification, ...prev])
|
||||
|
||||
/* Glocke & Vorschau animieren ---------------------- */
|
||||
setPreviewText(newNotification.text)
|
||||
setShowPreview(true)
|
||||
setAnimateBell(true)
|
||||
@ -114,16 +107,46 @@ export default function NotificationCenter() {
|
||||
setAnimateBell(false)
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
console.error('[SSE] Ungültige Nachricht:', event)
|
||||
console.error('[SSE] Ungültige Nachricht:', event.data, err)
|
||||
}
|
||||
}
|
||||
|
||||
source.addEventListener('notification', handleEvent)
|
||||
return () => source.removeEventListener('notification', handleEvent)
|
||||
/* Liste aller Event-Namen, die der Server schicken kann */
|
||||
const eventNames = [
|
||||
'notification',
|
||||
'invitation',
|
||||
'team-invite',
|
||||
'team-joined',
|
||||
'team-member-joined',
|
||||
'team-kick',
|
||||
'team-kick-other',
|
||||
'team-left',
|
||||
'team-member-left',
|
||||
'team-leader-changed',
|
||||
'team-leader-self',
|
||||
'team-join-request',
|
||||
'expired-sharecode',
|
||||
]
|
||||
|
||||
/* Named Events abonnieren ------------------------------ */
|
||||
eventNames.forEach(evt => source.addEventListener(evt, handleEvent))
|
||||
|
||||
/* Fallback: Server sendet evtl. Events ohne „event:“----- */
|
||||
source.onmessage = handleEvent
|
||||
|
||||
/* Aufräumen bei Unmount -------------------------------- */
|
||||
return () => {
|
||||
eventNames.forEach(evt => source.removeEventListener(evt, handleEvent))
|
||||
source.onmessage = null
|
||||
}
|
||||
}, [source])
|
||||
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
/* Render */
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
{/* Glocke -------------------------------------------------- */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
@ -132,14 +155,14 @@ export default function NotificationCenter() {
|
||||
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
|
||||
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
|
||||
>
|
||||
{/* Vorschautext */}
|
||||
{/* Vorschau-Text --------------------------------------- */}
|
||||
{previewText && (
|
||||
<span className="truncate text-sm text-gray-800 dark:text-white">
|
||||
{previewText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Notification Bell (absolut rechts innerhalb des Buttons) */}
|
||||
|
||||
{/* Icon & Badge --------------------------------------- */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center">
|
||||
<svg
|
||||
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
|
||||
@ -155,7 +178,8 @@ export default function NotificationCenter() {
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
{/* Badge (ungelesen) -------------------------------- */}
|
||||
{notifications.some(n => !n.read) && (
|
||||
<span className="flex absolute top-0 end-0 -mt-1 -me-1">
|
||||
<span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span>
|
||||
@ -166,8 +190,8 @@ export default function NotificationCenter() {
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
|
||||
{/* Dropdown --------------------------------------------- */}
|
||||
{open && (
|
||||
<NotificationDropdown
|
||||
notifications={notifications}
|
||||
@ -177,7 +201,9 @@ export default function NotificationCenter() {
|
||||
}}
|
||||
onSingleRead={async (id) => {
|
||||
await markOneAsRead(id)
|
||||
setNotifications(prev => prev.map(n => (n.id === id ? { ...n, read: true } : n)))
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, read: true } : n)),
|
||||
)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
onAction={async (action, id) => {
|
||||
@ -186,8 +212,8 @@ export default function NotificationCenter() {
|
||||
prev.map(n =>
|
||||
n.actionData === id
|
||||
? { ...n, read: true, actionType: undefined, actionData: undefined }
|
||||
: n
|
||||
)
|
||||
: n,
|
||||
),
|
||||
)
|
||||
if (action === 'accept') router.refresh()
|
||||
}}
|
||||
@ -195,5 +221,5 @@ export default function NotificationCenter() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -69,6 +69,7 @@ export default function Pagination({
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
onClick={() => onPageChange(page)}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
className={`min-h-9.5 min-w-9.5 flex justify-center items-center py-2 px-3 text-sm rounded-lg border
|
||||
|
||||
@ -8,7 +8,8 @@ import { Player } from '../types/team'
|
||||
type Props = {
|
||||
player: Player
|
||||
currentUserSteamId: string
|
||||
teamLeaderSteamId: string
|
||||
teamLeaderSteamId: string | null | undefined
|
||||
isAdmin?: boolean
|
||||
onKick?: (player: Player) => void
|
||||
onPromote?: (steamId: string) => void
|
||||
hideOverlay?: boolean
|
||||
@ -16,14 +17,14 @@ type Props = {
|
||||
matchParentBg?: boolean
|
||||
}
|
||||
|
||||
|
||||
export default function SortableMiniCard({
|
||||
player,
|
||||
onKick,
|
||||
onPromote,
|
||||
currentUserSteamId,
|
||||
teamLeaderSteamId,
|
||||
hideOverlay = false
|
||||
isAdmin = false,
|
||||
onKick,
|
||||
onPromote,
|
||||
hideOverlay = false,
|
||||
}: Props) {
|
||||
const {
|
||||
attributes,
|
||||
@ -41,7 +42,8 @@ export default function SortableMiniCard({
|
||||
transition,
|
||||
}
|
||||
|
||||
const isDraggable = currentUserSteamId === teamLeaderSteamId
|
||||
/* Drag-Berechtigung: Leader **oder** Admin */
|
||||
const isDraggable = isAdmin || currentUserSteamId === teamLeaderSteamId
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -61,7 +63,8 @@ export default function SortableMiniCard({
|
||||
onKick={() => onKick?.(player)}
|
||||
onPromote={onPromote}
|
||||
currentUserSteamId={currentUserSteamId}
|
||||
teamLeaderSteamId={teamLeaderSteamId}
|
||||
teamLeaderSteamId={teamLeaderSteamId ?? ''}
|
||||
isAdmin={isAdmin}
|
||||
dragListeners={isDraggable ? listeners : undefined}
|
||||
hoverEffect={isDraggable}
|
||||
hideOverlay={hideOverlay}
|
||||
@ -69,4 +72,3 @@ export default function SortableMiniCard({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -24,8 +24,10 @@ export function Tabs({ children }: { children: ReactNode }) {
|
||||
typeof tab.props.href === 'string'
|
||||
)
|
||||
.map((tab, index) => {
|
||||
const slug = tab.props.href.split('/').pop()
|
||||
const isActive = pathname.endsWith(slug ?? '')
|
||||
const base = tab.props.href.replace(/\/$/, '')
|
||||
const current = pathname.replace(/\/$/, '')
|
||||
|
||||
const isActive = current === base || current.startsWith(base + '/');
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@ -1,99 +1,174 @@
|
||||
// components/TeamCard.tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Button from './Button'
|
||||
import { Team, Player } from '../types/team'
|
||||
import { useLiveTeam } from '../hooks/useLiveTeam'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Button from './Button'
|
||||
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||||
import { useLiveTeam } from '../hooks/useLiveTeam'
|
||||
import type { Team, Player } from '../types/team'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
type Props = {
|
||||
team: Team
|
||||
currentUserSteamId: string
|
||||
invitationId?: string
|
||||
onUpdateInvitation: (teamId: string, newValue: string | null) => void
|
||||
adminMode?: boolean
|
||||
}
|
||||
|
||||
export default function TeamCard({ team, currentUserSteamId, invitationId, onUpdateInvitation }: Props) {
|
||||
export default function TeamCard({
|
||||
team,
|
||||
currentUserSteamId,
|
||||
invitationId,
|
||||
onUpdateInvitation,
|
||||
adminMode = false,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
const [joining, setJoining] = useState(false)
|
||||
|
||||
/* ---------- Live-Daten ---------- */
|
||||
const data = useLiveTeam(team)
|
||||
|
||||
if (!data || !data.players) {
|
||||
return <p className="text-sm text-gray-400">Lade Team …</p>
|
||||
}
|
||||
|
||||
if (!data) return <LoadingSpinner />
|
||||
|
||||
const players: Player[] = [
|
||||
...(data.activePlayers ?? []),
|
||||
...(data.inactivePlayers ?? []),
|
||||
]
|
||||
|
||||
/* ---------- Join / Reject ---------- */
|
||||
const isRequested = Boolean(invitationId)
|
||||
const isDisabled = joining || currentUserSteamId === data.leader
|
||||
|
||||
const handleClick = async () => {
|
||||
if (joining) return
|
||||
setJoining(true)
|
||||
|
||||
try {
|
||||
if (invitationId) {
|
||||
if (isRequested) {
|
||||
await fetch('/api/user/invitations/reject', {
|
||||
method: 'POST',
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invitationId }),
|
||||
body : JSON.stringify({ invitationId }),
|
||||
})
|
||||
onUpdateInvitation(data.id, null)
|
||||
} else {
|
||||
const res = await fetch('/api/team/request-join', {
|
||||
method: 'POST',
|
||||
await fetch('/api/team/request-join', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ teamId: data.id }),
|
||||
body : JSON.stringify({ teamId: data.id }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
onUpdateInvitation(data.id, 'dummy-id') // ← bei Bedarf mit realer ID aktualisieren
|
||||
onUpdateInvitation(data.id, 'pending')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler bei Join/Reject:', err)
|
||||
console.error('[TeamCard] Join/Reject-Fehler:', err)
|
||||
} finally {
|
||||
setJoining(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isRequested = !!invitationId
|
||||
const isDisabled = joining || currentUserSteamId === data.leader
|
||||
/* ---------- Ziel-URL berechnen ---------- */
|
||||
const targetHref = adminMode
|
||||
? `/admin/teams/${data.id}`
|
||||
: `/team/${data.id}`
|
||||
|
||||
/* ---------- Render ---------- */
|
||||
return (
|
||||
<div className="p-4 border rounded-lg bg-white dark:bg-neutral-800 dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(targetHref)}
|
||||
onKeyDown={e => (e.key === 'Enter') && router.push(targetHref)}
|
||||
className="
|
||||
p-4 border rounded-lg bg-white dark:bg-neutral-800
|
||||
dark:border-neutral-700 shadow-sm hover:shadow-md
|
||||
transition cursor-pointer focus:outline-none
|
||||
"
|
||||
>
|
||||
{/* Kopfzeile */}
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={data.logo ? `/assets/img/logos/${data.logo}` : '/assets/img/logos/placeholder.png'}
|
||||
alt={data.teamname ?? 'Teamlogo'}
|
||||
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
|
||||
src={
|
||||
data.logo
|
||||
? `/assets/img/logos/${data.logo}`
|
||||
: '/assets/img/logos/cs2.webp'
|
||||
}
|
||||
alt={data.name ?? 'Teamlogo'}
|
||||
className="w-12 h-12 rounded-full object-cover border
|
||||
border-gray-200 dark:border-neutral-600"
|
||||
/>
|
||||
<span className="font-medium truncate text-gray-500 dark:text-neutral-400">
|
||||
{data.teamname ?? 'Team'}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
|
||||
{data.name ?? 'Team'}
|
||||
</span>
|
||||
|
||||
<TeamPremierRankBadge players={players} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
title={isRequested ? 'Angefragt (zurückziehen)' : 'Beitreten'}
|
||||
size="sm"
|
||||
color={isRequested ? 'gray' : 'blue'}
|
||||
disabled={isDisabled}
|
||||
onClick={(e: any) => {
|
||||
e.stopPropagation()
|
||||
handleClick()
|
||||
}}
|
||||
>
|
||||
{joining ? '...' : isRequested ? 'Angefragt' : 'Beitreten'}
|
||||
</Button>
|
||||
{adminMode ? (
|
||||
<Button
|
||||
title="Verwalten"
|
||||
size="sm"
|
||||
color="blue"
|
||||
onClick={e => {
|
||||
e.stopPropagation() // ▼ Navigation hier unterbinden
|
||||
router.push(`/admin/teams/${data.id}`)
|
||||
}}
|
||||
>
|
||||
Verwalten
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
title={isRequested ? 'Angefragt (zurückziehen)' : 'Beitritt anfragen'}
|
||||
size="sm"
|
||||
color={isRequested ? 'gray' : 'blue'}
|
||||
disabled={isDisabled}
|
||||
onClick={e => {
|
||||
e.stopPropagation() // ▼ verhindert Klick-Weitergabe
|
||||
handleClick()
|
||||
}}
|
||||
>
|
||||
{joining ? (
|
||||
<>
|
||||
<span
|
||||
className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1"
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
/>
|
||||
Lädt
|
||||
</>
|
||||
) : isRequested ? (
|
||||
'Angefragt'
|
||||
) : (
|
||||
'Beitritt anfragen'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avatare */}
|
||||
<div className="flex -space-x-3">
|
||||
{data.players.slice(0, 5).map((p) => (
|
||||
{players.slice(0, 5).map(p => (
|
||||
<img
|
||||
key={p.steamId}
|
||||
src={p.avatar}
|
||||
alt={p.name}
|
||||
title={p.name}
|
||||
className="w-8 h-8 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
|
||||
className="w-8 h-8 rounded-full border-2 border-white
|
||||
dark:border-neutral-800 object-cover"
|
||||
/>
|
||||
))}
|
||||
{data.players.length > 5 && (
|
||||
<span className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 text-xs">
|
||||
+{data.players.length - 5}
|
||||
|
||||
{players.length > 5 && (
|
||||
<span
|
||||
key="more"
|
||||
className="w-8 h-8 flex items-center justify-center
|
||||
rounded-full bg-gray-200 text-xs"
|
||||
>
|
||||
+{players.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -79,11 +79,8 @@ function TeamCardComponent(props: Props, ref: any) {
|
||||
<TeamMemberView
|
||||
{...teamManager}
|
||||
currentUserSteamId={steamId}
|
||||
adminMode={false}
|
||||
/>
|
||||
|
||||
<div className="flex gap-x-3">
|
||||
<CreateTeamButton setRefetchKey={setRefetchKey} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { DroppableZone } from './DroppableZone'
|
||||
import MiniCard from './MiniCard'
|
||||
import MiniCardDummy from './MiniCardDummy'
|
||||
import SortableMiniCard from './SortableMiniCard'
|
||||
import LeaveTeamModal from './LeaveTeamModal'
|
||||
@ -17,6 +18,7 @@ import { useTeamManager } from '../hooks/useTeamManager'
|
||||
import Button from './Button'
|
||||
import Image from 'next/image'
|
||||
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||||
import Link from 'next/link'
|
||||
|
||||
type Props = {
|
||||
team: Team | null
|
||||
@ -33,6 +35,7 @@ type Props = {
|
||||
setIsDragging: (v: boolean) => void
|
||||
setactivePlayers: (players: Player[]) => void
|
||||
setInactivePlayers: (players: Player[]) => void
|
||||
adminMode?: boolean
|
||||
}
|
||||
|
||||
export default function TeamMemberView({
|
||||
@ -49,6 +52,7 @@ export default function TeamMemberView({
|
||||
setIsDragging,
|
||||
setactivePlayers,
|
||||
setInactivePlayers,
|
||||
adminMode = false,
|
||||
}: Props) {
|
||||
const { data: session } = useSession()
|
||||
const { source, connect } = useSSE()
|
||||
@ -57,11 +61,14 @@ export default function TeamMemberView({
|
||||
|
||||
const currentUserSteamId = session?.user?.steamId || ''
|
||||
const isLeader = currentUserSteamId === team?.leader
|
||||
const canManage = adminMode || isLeader
|
||||
const canInvite = isLeader && !adminMode
|
||||
const canAddDirect = adminMode
|
||||
const { leaveTeam, reloadTeam, renameTeam, deleteTeam } = useTeamManager({}, null)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const [editedName, setEditedName] = useState(team?.teamname || '')
|
||||
const [editedName, setEditedName] = useState(team?.name || '')
|
||||
const [isEditingLogo, setIsEditingLogo] = useState(false)
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null)
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null)
|
||||
@ -79,44 +86,58 @@ export default function TeamMemberView({
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!source || !team?.id) return
|
||||
if (!source || !teamState?.id) return
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data)
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
const relevant = [
|
||||
'team-updated',
|
||||
'team-leader-changed',
|
||||
'team-leader-self',
|
||||
'team-member-joined',
|
||||
'team-member-left',
|
||||
'team-kick',
|
||||
'team-kick-other',
|
||||
'team-renamed',
|
||||
'team-logo-updated',
|
||||
]
|
||||
if (data.teamId !== teamState.id || !relevant.includes(data.type)) return
|
||||
|
||||
const relevantTypes = [
|
||||
'team-updated',
|
||||
'team-kick',
|
||||
'team-kick-other',
|
||||
'team-member-joined',
|
||||
'team-member-left',
|
||||
'team-leader-changed',
|
||||
'team-renamed',
|
||||
'team-logo-updated',
|
||||
]
|
||||
|
||||
if (relevantTypes.includes(data.type) && typeof data.teamId === 'string') {
|
||||
/* EIN Aufruf genügt – holt Team + Spieler + setzt States */
|
||||
fetch(`/api/team/${encodeURIComponent(data.teamId)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setactivePlayers(
|
||||
(data.activePlayers ?? [])
|
||||
.filter((p: Player) => p?.name)
|
||||
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
setInactivePlayers(
|
||||
(data.inactivePlayers ?? [])
|
||||
.filter((p: Player) => p?.name)
|
||||
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
|
||||
);
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(fresh => {
|
||||
setTeamState(fresh)
|
||||
setactivePlayers((fresh.activePlayers ?? [])
|
||||
.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
|
||||
setInactivePlayers((fresh.inactivePlayers ?? [])
|
||||
.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('SSE parse error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
source.addEventListener('message', handleMessage)
|
||||
return () => source.removeEventListener('message', handleMessage)
|
||||
}, [source, team?.id])
|
||||
const eventNames = [
|
||||
'team-updated',
|
||||
'team-leader-changed',
|
||||
'team-leader-self',
|
||||
'team-member-joined',
|
||||
'team-member-left',
|
||||
'team-kick',
|
||||
'team-kick-other',
|
||||
'team-renamed',
|
||||
'team-logo-updated',
|
||||
]
|
||||
eventNames.forEach(evt => source.addEventListener(evt, handleMessage))
|
||||
source.onmessage = handleMessage
|
||||
|
||||
return () => {
|
||||
eventNames.forEach(evt => source.removeEventListener(evt, handleMessage))
|
||||
source.onmessage = null
|
||||
}
|
||||
}, [source, teamState?.id, reloadTeam])
|
||||
|
||||
|
||||
const handleDragStart = (event: any) => {
|
||||
@ -225,40 +246,62 @@ export default function TeamMemberView({
|
||||
}
|
||||
}
|
||||
|
||||
if (!teamState || !currentUserSteamId) return null
|
||||
if (!teamState) return null
|
||||
if (!adminMode && !currentUserSteamId) return null
|
||||
|
||||
const manageSteam = adminMode ? teamState.leader : currentUserSteamId
|
||||
|
||||
const renderMemberList = (players: Player[]) => (
|
||||
<AnimatePresence>
|
||||
{players.map(player => (
|
||||
<motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
|
||||
<SortableMiniCard
|
||||
player={player}
|
||||
onKick={setKickCandidate}
|
||||
onPromote={() => setPromoteCandidate(player)}
|
||||
currentUserSteamId={currentUserSteamId}
|
||||
teamLeaderSteamId={teamState.leader}
|
||||
isDraggingGlobal={isDragging}
|
||||
hideOverlay={isDragging}
|
||||
matchParentBg={true}
|
||||
/>
|
||||
<motion.div
|
||||
key={player.steamId}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className='max-w-[160px]'
|
||||
>
|
||||
{/* ✨ Link zur Profil-Seite */}
|
||||
<Link
|
||||
href={`/profile/${player.steamId}`} // ⇦ dein Profil-Slug
|
||||
passHref // nur nötig, falls du <a> verwendest
|
||||
onClick={e => {
|
||||
/* Wenn gerade gezogen wird → Navigation verhindern */
|
||||
if (isDragging) e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{/* Wichtig: SortableMiniCard selbst bleibt Drag-Handle,
|
||||
deshalb keinen weiteren Wrapper mehr einziehen. */}
|
||||
<SortableMiniCard
|
||||
player={player}
|
||||
onKick={setKickCandidate}
|
||||
onPromote={() => setPromoteCandidate(player)}
|
||||
currentUserSteamId={manageSteam}
|
||||
teamLeaderSteamId={teamState.leader}
|
||||
isAdmin={!!session?.user?.isAdmin}
|
||||
isDraggingGlobal={isDragging}
|
||||
hideOverlay={isDragging}
|
||||
matchParentBg
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
||||
|
||||
return (
|
||||
<div className={`p-4 my-6 sm:my-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700 ${isDragging ? 'cursor-grabbing' : ''}`}>
|
||||
<div className={`p-4 mt-6 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700 ${isDragging ? 'cursor-grabbing' : ''}`}>
|
||||
<div className="flex justify-between items-center mb-6 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Teamlogo mit Fallback */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600 cursor-pointer"
|
||||
onClick={() => isLeader && document.getElementById('logoUpload')?.click()}
|
||||
onClick={() => canManage && document.getElementById('logoUpload')?.click()}
|
||||
>
|
||||
<Image
|
||||
src={teamState.logo ? `/assets/img/logos/${teamState.logo}` : `/assets/img/logos/placeholder.png`}
|
||||
src={teamState.logo ? `/assets/img/logos/${teamState.logo}` : `/assets/img/logos/cs2.webp`}
|
||||
alt="Teamlogo"
|
||||
fill
|
||||
sizes="64px"
|
||||
@ -268,7 +311,7 @@ export default function TeamMemberView({
|
||||
/>
|
||||
|
||||
{/* Overlay beim Hover */}
|
||||
{isLeader && (
|
||||
{canManage && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -283,7 +326,7 @@ export default function TeamMemberView({
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
{isLeader && (
|
||||
{canManage && (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@ -359,7 +402,7 @@ export default function TeamMemberView({
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditingName(false)
|
||||
setEditedName(teamState.teamname ?? '')
|
||||
setEditedName(teamState.name ?? '')
|
||||
}}
|
||||
className="h-[34px] px-3 flex items-center justify-center"
|
||||
>
|
||||
@ -381,11 +424,11 @@ export default function TeamMemberView({
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{teamState.teamname ?? 'Team'}
|
||||
{teamState.name ?? 'Team'}
|
||||
</h2>
|
||||
<TeamPremierRankBadge players={activePlayers} />
|
||||
</div>
|
||||
{isLeader && (
|
||||
{canManage && (
|
||||
<Button
|
||||
title="Bearbeiten"
|
||||
color="blue"
|
||||
@ -393,7 +436,7 @@ export default function TeamMemberView({
|
||||
variant="soft"
|
||||
onClick={() => {
|
||||
setIsEditingName(true)
|
||||
setEditedName(teamState.teamname || '')
|
||||
setEditedName(teamState.name || '')
|
||||
}}
|
||||
className="h-[34px] px-3 flex items-center justify-center"
|
||||
>
|
||||
@ -414,8 +457,9 @@ export default function TeamMemberView({
|
||||
|
||||
{/* Aktionen */}
|
||||
<div className="flex gap-2">
|
||||
{isLeader && (
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="text-sm px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
@ -423,6 +467,7 @@ export default function TeamMemberView({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (isLeader) {
|
||||
setShowLeaveModal(true)
|
||||
@ -452,10 +497,10 @@ export default function TeamMemberView({
|
||||
<DroppableZone id="inactive" label="Inaktives Team" activeDragItem={activeDragItem}>
|
||||
<SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||||
{renderMemberList(inactivePlayers)}
|
||||
{isLeader && (
|
||||
{canManage && (
|
||||
<motion.div key="mini-card-dummy" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.2 }}>
|
||||
<MiniCardDummy
|
||||
title="Einladen"
|
||||
title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
|
||||
onClick={() => {
|
||||
setShowInviteModal(false)
|
||||
setTimeout(() => setShowInviteModal(true), 0)
|
||||
@ -479,21 +524,46 @@ export default function TeamMemberView({
|
||||
player={activeDragItem}
|
||||
currentUserSteamId={currentUserSteamId}
|
||||
teamLeaderSteamId={teamState.leader}
|
||||
isAdmin={!!session?.user?.isAdmin}
|
||||
hideOverlay
|
||||
matchParentBg
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{isLeader && (
|
||||
<>
|
||||
<LeaveTeamModal show={showLeaveModal} onClose={() => setShowLeaveModal(false)} onSuccess={() => setShowLeaveModal(false)} team={teamState} />
|
||||
<InvitePlayersModal show={showInviteModal} onClose={() => setShowInviteModal(false)} onSuccess={() => {}} team={teamState} />
|
||||
</>
|
||||
|
||||
{/* Modal(s) */}
|
||||
{canInvite && (
|
||||
<InvitePlayersModal
|
||||
show={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
onSuccess={() => {}}
|
||||
team={teamState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canAddDirect && (
|
||||
<InvitePlayersModal
|
||||
show={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
onSuccess={() => {}}
|
||||
team={teamState}
|
||||
directAdd
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLeader && promoteCandidate && (
|
||||
{/* Leader-spezifische Modale (z. B. Team verlassen) */}
|
||||
{isLeader && (
|
||||
<LeaveTeamModal
|
||||
show={showLeaveModal}
|
||||
onClose={() => setShowLeaveModal(false)}
|
||||
onSuccess={() => setShowLeaveModal(false)}
|
||||
team={teamState}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{canManage && promoteCandidate && (
|
||||
<Modal
|
||||
id={`modal-promote-player-${promoteCandidate.steamId}`}
|
||||
title="Leader übertragen"
|
||||
@ -506,13 +576,30 @@ export default function TeamMemberView({
|
||||
closeButtonTitle="Übertragen"
|
||||
closeButtonColor="blue"
|
||||
>
|
||||
{/* ► PlayerCard des Kandidaten */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<MiniCard
|
||||
steamId={promoteCandidate.steamId}
|
||||
title={promoteCandidate.name}
|
||||
avatar={promoteCandidate.avatar}
|
||||
location={promoteCandidate.location}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
draggable={false}
|
||||
currentUserSteamId={currentUserSteamId}
|
||||
teamLeaderSteamId={teamState.leader}
|
||||
hideActions
|
||||
isSelectable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Teamleader machen?
|
||||
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{isLeader && kickCandidate && (
|
||||
{canManage && kickCandidate && (
|
||||
<Modal
|
||||
id={`modal-kick-player-${kickCandidate.steamId}`}
|
||||
title="Mitglied entfernen"
|
||||
@ -522,13 +609,30 @@ export default function TeamMemberView({
|
||||
closeButtonTitle="Entfernen"
|
||||
closeButtonColor="red"
|
||||
>
|
||||
{/* ► PlayerCard des Kandidaten */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<MiniCard
|
||||
steamId={kickCandidate.steamId}
|
||||
title={kickCandidate.name}
|
||||
avatar={kickCandidate.avatar}
|
||||
location={kickCandidate.location}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
draggable={false}
|
||||
currentUserSteamId={currentUserSteamId}
|
||||
teamLeaderSteamId={teamState.leader}
|
||||
hideActions
|
||||
isSelectable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
Möchtest du <strong>{kickCandidate.name}</strong> wirklich aus dem Team entfernen?
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{isLeader && (
|
||||
{canManage && (
|
||||
<Modal
|
||||
id="modal-delete-team"
|
||||
title="Team löschen"
|
||||
|
||||
15
src/app/components/UserClips.tsx
Normal file
15
src/app/components/UserClips.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
// components/UserClips.tsx
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getClips, type Clip } from '../lib/allstar'
|
||||
|
||||
export default function UserClips({ steamId }: { steamId: string }) {
|
||||
const [clips, setClips] = useState<Clip[]>([])
|
||||
useEffect(() => { getClips(steamId).then(r => setClips(r.clips)) }, [steamId])
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{clips.map(c => (
|
||||
<video key={c.id} src={c.url} controls className="rounded-lg w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,14 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Chart from '@/app/components/Chart'
|
||||
import { MatchStats } from '@/app/types/match'
|
||||
import Card from './Card'
|
||||
import UserClips from './UserClips'
|
||||
|
||||
type MatchStatsProps = {
|
||||
stats: { matches: MatchStats[] }
|
||||
}
|
||||
|
||||
export default function UserProfile({ stats }: MatchStatsProps) {
|
||||
const { data: session } = useSession()
|
||||
const steamId = session?.user?.steamId ?? '' // ← für UserClips
|
||||
|
||||
const { matches } = stats
|
||||
|
||||
const totalKills = matches.reduce((sum, m) => sum + m.kills, 0)
|
||||
@ -77,6 +82,14 @@ export default function UserProfile({ stats }: MatchStatsProps) {
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ► Allstar-Clips des aktuellen Users -------------------------- */}
|
||||
{steamId && (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold mb-2">Highlights</h3>
|
||||
<UserClips steamId={steamId} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Breite Diagramme */}
|
||||
|
||||
@ -4,12 +4,12 @@ import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Modal from '@/app/components/Modal'
|
||||
import Select from '@/app/components/Select'
|
||||
import Input from './Input'
|
||||
import Button from './Button'
|
||||
import DatePickerWithTime from './DatePickerWithTime'
|
||||
import Input from '../Input'
|
||||
import Button from '../Button'
|
||||
import DatePickerWithTime from '../DatePickerWithTime'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import Switch from './Switch'
|
||||
import Switch from '../Switch'
|
||||
|
||||
function getRoundedDate() {
|
||||
const now = new Date()
|
||||
@ -37,7 +37,6 @@ export default function MatchesAdminManager() {
|
||||
const [description, setDescription] = useState('')
|
||||
const [matchDate, setMatchDate] = useState(getRoundedDate())
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/teams').then(res => res.json()).then(setTeams)
|
||||
@ -49,23 +48,19 @@ export default function MatchesAdminManager() {
|
||||
const teamA = teams.find(t => t.id === teamAId)
|
||||
const teamB = teams.find(t => t.id === teamBId)
|
||||
if (teamA && teamB) {
|
||||
setTitle(`${teamA.teamname} vs ${teamB.teamname}`)
|
||||
setTitle(`${teamA.name} vs ${teamB.name}`)
|
||||
}
|
||||
}
|
||||
}, [teamAId, teamBId, teams, titleManuallySet])
|
||||
|
||||
const fetchMatches = async () => {
|
||||
const res = await fetch('/api/matches')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setMatches(data)
|
||||
}
|
||||
if (res.ok) setMatches(await res.json())
|
||||
}
|
||||
|
||||
const filteredMatches = onlyOwnTeam && session?.user?.team
|
||||
? matches.filter((m: any) =>
|
||||
m.teamA.id === session.user.team || m.teamB.id === session.user.team)
|
||||
: matches
|
||||
const filteredMatches = matches.filter(
|
||||
(m: any) => m.matchType === 'community'
|
||||
)
|
||||
|
||||
const resetFields = () => {
|
||||
setTitle('')
|
||||
@ -117,13 +112,13 @@ export default function MatchesAdminManager() {
|
||||
<div className="flex flex-col items-center w-1/4">
|
||||
<Image
|
||||
src={getTeamLogo(match.teamA?.logo)}
|
||||
alt={match.teamA?.teamname || 'Team A'}
|
||||
alt={match.teamA?.name || 'Team A'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full border object-cover bg-white"
|
||||
/>
|
||||
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
{match.teamA?.teamname || 'Team A'}
|
||||
{match.teamA?.name || 'Team A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 w-1/2">
|
||||
@ -136,13 +131,13 @@ export default function MatchesAdminManager() {
|
||||
<div className="flex flex-col items-center w-1/4">
|
||||
<Image
|
||||
src={getTeamLogo(match.teamB?.logo)}
|
||||
alt={match.teamB?.teamname || 'Team B'}
|
||||
alt={match.teamB?.name || 'Team B'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full border object-cover bg-white"
|
||||
/>
|
||||
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
{match.teamB?.teamname || 'Team B'}
|
||||
{match.teamB?.name || 'Team B'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -171,20 +166,20 @@ export default function MatchesAdminManager() {
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block mb-1">Team A</label>
|
||||
<label className="block text-sm font-medium mb-2 dark:text-white">Team A</label>
|
||||
<Select
|
||||
value={teamAId}
|
||||
onChange={setTeamAId}
|
||||
options={teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.teamname }))}
|
||||
options={teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.name }))}
|
||||
placeholder="Wählen"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1">Team B</label>
|
||||
<label className="block text-sm font-medium mb-2 dark:text-white">Team B</label>
|
||||
<Select
|
||||
value={teamBId}
|
||||
onChange={setTeamBId}
|
||||
options={teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.teamname }))}
|
||||
options={teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.name }))}
|
||||
placeholder="Wählen"
|
||||
/>
|
||||
</div>
|
||||
135
src/app/components/admin/teams/AdminTeamsView.tsx
Normal file
135
src/app/components/admin/teams/AdminTeamsView.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Button from '@/app/components/Button'
|
||||
import Modal from '@/app/components/Modal'
|
||||
import Input from '@/app/components/Input'
|
||||
import TeamCard from '@/app/components/TeamCard'
|
||||
import type { Team } from '@/app/types/team'
|
||||
|
||||
export default function AdminTeamsView() {
|
||||
/* ────────────────────────── Session ─────────────────────────── */
|
||||
const { data: session } = useSession()
|
||||
|
||||
/* ─────────────────────────── State ───────────────────────────── */
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
/* ───────────────────── fetchTeams (memoisiert) ─────────────── */
|
||||
const fetchTeams = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/team/list')
|
||||
const json = await res.json()
|
||||
setTeams(json.teams ?? [])
|
||||
} catch (err) {
|
||||
console.error('[AdminTeamsView] /api/team/list:', err)
|
||||
setTeams([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ─────────────────── einmaliger Aufruf sichern ──────────────── */
|
||||
const fetchedOnce = useRef(false)
|
||||
useEffect(() => {
|
||||
if (fetchedOnce.current) return // im Strict-Mode zweiter Aufruf → abbrechen
|
||||
fetchedOnce.current = true
|
||||
fetchTeams()
|
||||
}, [fetchTeams])
|
||||
|
||||
/* ───────────────────────── Team anlegen ─────────────────────── */
|
||||
// Ausschnitt aus AdminTeamsView.tsx
|
||||
const createTeam = async () => {
|
||||
if (!newName.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/team/create', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({ teamname: newName.trim() }), // ⇦ nur Name
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
alert((await res.json()).message ?? 'Fehler beim Erstellen')
|
||||
return
|
||||
}
|
||||
|
||||
await fetchTeams()
|
||||
setShowCreate(false)
|
||||
setNewName('')
|
||||
} catch (err) {
|
||||
console.error('[AdminTeamsView] Team erstellen:', err)
|
||||
alert('Team konnte nicht erstellt werden.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Render ─────────────────────────── */
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Lade Teams …
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Kopfzeile */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
Teams verwalten
|
||||
</h2>
|
||||
|
||||
<Button color="blue" onClick={() => setShowCreate(true)}>
|
||||
Neues Team erstellen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Team-Grid */}
|
||||
{teams.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Es wurden noch keine Teams erstellt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{teams.map(t => (
|
||||
<TeamCard
|
||||
key={t.id}
|
||||
team={t}
|
||||
currentUserSteamId=""
|
||||
invitationId={undefined}
|
||||
onUpdateInvitation={() => {}}
|
||||
adminMode
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal zum Erstellen */}
|
||||
<Modal
|
||||
id="create-team-modal"
|
||||
title="Neues Team erstellen"
|
||||
show={showCreate}
|
||||
onClose={() => { setShowCreate(false); setNewName('') }}
|
||||
onSave={createTeam}
|
||||
closeButtonColor="blue"
|
||||
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
|
||||
>
|
||||
<Input
|
||||
label="Teamname"
|
||||
value={newName}
|
||||
placeholder="z. B. Ironie eSports"
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -62,7 +62,7 @@ export default function AppearanceSettings() {
|
||||
checked={isChecked}
|
||||
onChange={() => setTheme(id)}
|
||||
/>
|
||||
<img className="rounded-t-[14px] -mt-px" src={img ? `/assets/img/themes/${img}` : '/assets/img/logos/placeholder.png'} alt={label} loading="lazy" />
|
||||
<img className="rounded-t-[14px] -mt-px" src={img ? `/assets/img/themes/${img}` : '/assets/img/logos/cs2.webp'} alt={label} loading="lazy" />
|
||||
<span
|
||||
className={`py-3 px-2 text-sm font-semibold rounded-b-xl
|
||||
${isChecked
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// LatestKnownCodeSettings.tsx
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
@ -80,15 +81,31 @@ export default function LatestKnownCodeSettings({
|
||||
required
|
||||
/>
|
||||
{!showError && (
|
||||
<div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none">
|
||||
<svg className="shrink-0 size-4 text-teal-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<div className="absolute top-1/2 end-3 -translate-y-1/2 pointer-events-none">
|
||||
<svg
|
||||
className="size-4 text-teal-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{showError && (
|
||||
<p className="text-sm text-red-600 mt-2">
|
||||
Abgelaufener Austauschcode
|
||||
Abgelaufener Austauschcode! Deinen neuen Austauschcode findest du
|
||||
<Link
|
||||
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
|
||||
target="_blank"
|
||||
className="text-red-600 underline hover:text-blue-800"
|
||||
>
|
||||
hier
|
||||
</Link>.
|
||||
</p>
|
||||
)}
|
||||
{isSaved && !showError && (
|
||||
|
||||
@ -1,65 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Team } from '../types/team'
|
||||
import type { Team, Player } from '../types/team'
|
||||
|
||||
const relevantEvents = [
|
||||
'ws-team-renamed',
|
||||
'ws-team-member-joined',
|
||||
'ws-team-member-left',
|
||||
'ws-team-kick',
|
||||
'ws-team-kick-other',
|
||||
'ws-team-leader-changed',
|
||||
'ws-team-logo-updated'
|
||||
]
|
||||
const events = [
|
||||
'ws-team-renamed',
|
||||
'ws-team-member-joined',
|
||||
'ws-team-member-left',
|
||||
'ws-team-kick',
|
||||
'ws-team-kick-other',
|
||||
'ws-team-leader-changed',
|
||||
'ws-team-logo-updated',
|
||||
]
|
||||
|
||||
export function useLiveTeam(initialTeam: Team) {
|
||||
const [data, setData] = useState<Team>(initialTeam)
|
||||
/** Der lokale Zustand hat dieselbe Form wie `Team` */
|
||||
const [team, setTeam] = useState<Team>(initialTeam)
|
||||
|
||||
useEffect(() => {
|
||||
const update = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/team/get?id=${initialTeam.id}`)
|
||||
if (!res.ok) return
|
||||
const refresh = async () => {
|
||||
const res = await fetch(`/api/team/get?id=${initialTeam.id}`)
|
||||
if (!res.ok) return
|
||||
const { team: t } = await res.json()
|
||||
if (!t) return
|
||||
|
||||
const json = await res.json()
|
||||
const updatedTeam = json?.team
|
||||
if (!updatedTeam) return
|
||||
|
||||
const players = [
|
||||
...(updatedTeam.activePlayers ?? []),
|
||||
...(updatedTeam.inactivePlayers ?? []),
|
||||
]
|
||||
|
||||
setData({
|
||||
id: updatedTeam.id,
|
||||
teamname: updatedTeam.teamname,
|
||||
logo: updatedTeam.logo,
|
||||
leader: updatedTeam.leader,
|
||||
players,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Nachladen des Teams:', err)
|
||||
}
|
||||
setTeam({
|
||||
id : t.id,
|
||||
name : t.teamname,
|
||||
logo : t.logo,
|
||||
leader: t.leader,
|
||||
activePlayers : t.activePlayers ?? [],
|
||||
inactivePlayers: t.inactivePlayers ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
if (customEvent.detail?.teamId === initialTeam.id) {
|
||||
update()
|
||||
}
|
||||
const ev = e as CustomEvent
|
||||
if (ev.detail?.teamId === initialTeam.id) refresh()
|
||||
}
|
||||
|
||||
for (const evt of relevantEvents) {
|
||||
window.addEventListener(evt, handler)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const evt of relevantEvents) {
|
||||
window.removeEventListener(evt, handler)
|
||||
}
|
||||
}
|
||||
events.forEach(evt => window.addEventListener(evt, handler))
|
||||
return () => events.forEach(evt => window.removeEventListener(evt, handler))
|
||||
}, [initialTeam.id])
|
||||
|
||||
return data
|
||||
return team
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export type Invitation = {
|
||||
}
|
||||
|
||||
export function useTeamManager(
|
||||
props: { refetchKey?: string },
|
||||
props: { refetchKey?: string; teamId?: string },
|
||||
ref: React.Ref<any>
|
||||
) {
|
||||
const [team, setTeam] = useState<Team | null>(null)
|
||||
@ -29,7 +29,11 @@ export function useTeamManager(
|
||||
const fetchTeam = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/team')
|
||||
const url = props.teamId
|
||||
? `/api/team/${encodeURIComponent(props.teamId)}`
|
||||
: '/api/team'
|
||||
|
||||
const res = await fetch(url)
|
||||
|
||||
if (res.status === 404) {
|
||||
setTeam(null)
|
||||
@ -39,25 +43,62 @@ export function useTeamManager(
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Abrufen des Teams')
|
||||
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!data.team) {
|
||||
const teamData = data.team ?? data
|
||||
|
||||
if (!teamData || !teamData.id) {
|
||||
setTeam(null)
|
||||
setactivePlayers([])
|
||||
setInactivePlayers([])
|
||||
return
|
||||
}
|
||||
|
||||
const newActive = data.team.activePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
|
||||
const newInactive = data.team.inactivePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
|
||||
// ── 1. evtl. nur Steam-IDs? ───────────────────────────────
|
||||
let newActive = teamData.activePlayers ?? []
|
||||
let newInactive = teamData.inactivePlayers ?? []
|
||||
|
||||
const playersAreStrings =
|
||||
typeof newActive[0] === 'string' || typeof newInactive[0] === 'string'
|
||||
|
||||
if (playersAreStrings && (newActive.length || newInactive.length)) {
|
||||
/* Alle IDs sammeln und User-Infos nachladen */
|
||||
const steamIds = [...newActive, ...newInactive]
|
||||
const resUsers = await fetch('/api/user/list', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({ steamIds })
|
||||
})
|
||||
|
||||
|
||||
const json = await resUsers.json()
|
||||
const users: Player[] = Array.isArray(json) ? json
|
||||
: Array.isArray(json.users) ? json.users
|
||||
: []
|
||||
|
||||
const map = Object.fromEntries(users.map(u => [u.steamId, u]))
|
||||
|
||||
newActive = newActive
|
||||
.map((id: string) => map[id])
|
||||
.filter(Boolean)
|
||||
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
|
||||
newInactive = newInactive
|
||||
.map((id: string) => map[id])
|
||||
.filter(Boolean)
|
||||
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
|
||||
} else {
|
||||
newActive = newActive .sort((a: Player, b: Player) => a.name.localeCompare(b.name))
|
||||
newInactive = newInactive.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
setTeam({
|
||||
id: data.team.id,
|
||||
teamname: data.team.teamname,
|
||||
leader: data.team.leader,
|
||||
logo: data.team.logo,
|
||||
players: [...newActive, ...newInactive],
|
||||
id: teamData.id,
|
||||
name: teamData.name,
|
||||
leader: teamData.leader,
|
||||
logo: teamData.logo,
|
||||
activePlayers : newActive,
|
||||
inactivePlayers: newInactive,
|
||||
})
|
||||
setactivePlayers(newActive)
|
||||
setInactivePlayers(newInactive)
|
||||
@ -87,10 +128,13 @@ export function useTeamManager(
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (props.teamId) // 👉 Admin-Detail: nur Team holen
|
||||
await fetchTeam()
|
||||
else // 👉 eigener User: Team + Einladungen
|
||||
await Promise.all([fetchTeam(), fetchInvitations()])
|
||||
}
|
||||
load()
|
||||
}, [props.refetchKey])
|
||||
}, [props.refetchKey, props.teamId]) // teamId als Dep nicht vergessen
|
||||
|
||||
useWebSocketListener('ws-invitation', fetchInvitations)
|
||||
useWebSocketListener('ws-team-invite', fetchInvitations)
|
||||
|
||||
23
src/app/lib/allstar.ts
Normal file
23
src/app/lib/allstar.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// lib/allstar.ts
|
||||
import ky from 'ky'
|
||||
|
||||
|
||||
export interface Clip {
|
||||
id: string
|
||||
url: string // direct mp4/hls URL
|
||||
title?: string
|
||||
createdAt: string // ISO-Datum
|
||||
// … beliebige weitere Felder der API …
|
||||
}
|
||||
|
||||
const api = ky.create({
|
||||
prefixUrl : 'https://partner-api.allstar.gg/v1',
|
||||
headers : { Authorization: `Bearer ${process.env.ALLSTAR_TOKEN}` },
|
||||
timeout : 10_000,
|
||||
retry : 1,
|
||||
})
|
||||
|
||||
export async function getClips(steamId: string) {
|
||||
return api.get('clips', { searchParams: { steamId, limit: 25 } })
|
||||
.json<{ clips: Clip[] }>()
|
||||
}
|
||||
4
src/app/messages/de.json
Normal file
4
src/app/messages/de.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"hello": "Hallo Welt",
|
||||
"profile.title": "Dein Profil"
|
||||
}
|
||||
4
src/app/messages/en.json
Normal file
4
src/app/messages/en.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"hello": "Hello world",
|
||||
"profile.title": "My Profile"
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import MatchDetails from '@/app/components/MatchDetails'
|
||||
|
||||
export default function Page({ params }: { params: Promise<{ matchId: string }> }) {
|
||||
const { matchId } = use(params)
|
||||
|
||||
return <MatchDetails matchId={matchId} />
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Switch from '@/app/components/Switch'
|
||||
@ -10,8 +11,12 @@ type Match = {
|
||||
id: string
|
||||
title: string
|
||||
matchDate: string
|
||||
teamA: { id: string; teamname: string; logo?: string | null }
|
||||
teamB: { id: string; teamname: string; logo?: string | null }
|
||||
teamA: { id: string; name: string; logo?: string | null }
|
||||
teamB: { id: string; name: string; logo?: string | null }
|
||||
}
|
||||
|
||||
function getTeamLogo(logo?: string | null) {
|
||||
return logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
|
||||
}
|
||||
|
||||
export default function MatchesPage() {
|
||||
@ -21,16 +26,11 @@ export default function MatchesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/schedule')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
setMatches(data)
|
||||
} else if (Array.isArray(data.schedules)) {
|
||||
setMatches(data.schedules)
|
||||
} else {
|
||||
console.error("❌ Unerwartetes API-Format", data)
|
||||
setMatches([])
|
||||
}
|
||||
.then(r => r.json())
|
||||
.then(data => setMatches(Array.isArray(data.matches) ? data.matches : []))
|
||||
.catch(err => {
|
||||
console.error('[MatchesPage] /api/schedule fehlt oder Antwort fehlerhaft:', err)
|
||||
setMatches([])
|
||||
})
|
||||
}, [])
|
||||
|
||||
@ -69,29 +69,47 @@ export default function MatchesPage() {
|
||||
{filteredMatches.map(match => (
|
||||
<li key={match.id}>
|
||||
<Link
|
||||
href={`/matches/${match.id}`}
|
||||
href={`/match-details/${match.id}`}
|
||||
className="block border rounded p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition"
|
||||
>
|
||||
<div className="flex items-center justify-between text-center">
|
||||
{/* Team A */}
|
||||
<div className="flex flex-col items-center w-1/4">
|
||||
<div className="size-16 rounded-full border bg-gray-100 dark:bg-neutral-700" />
|
||||
<Image
|
||||
src={getTeamLogo(match.teamA?.logo)}
|
||||
alt={match.teamA?.name || 'Team A'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full border object-cover bg-white"
|
||||
/>
|
||||
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
{match.teamA?.teamname ?? 'Team A'}
|
||||
{match.teamA?.name ?? 'Team A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Datum / Zeit */}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 w-1/2">
|
||||
<div>{new Date(match.matchDate).toLocaleDateString('de-DE')}</div>
|
||||
<div>{new Date(match.matchDate).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr</div>
|
||||
<div>
|
||||
{new Date(match.matchDate).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}{' '}
|
||||
Uhr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team B */}
|
||||
<div className="flex flex-col items-center w-1/4">
|
||||
<div className="size-16 rounded-full border bg-gray-100 dark:bg-neutral-700" />
|
||||
<Image
|
||||
src={getTeamLogo(match.teamB?.logo)}
|
||||
alt={match.teamB?.name || 'Team B'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full border object-cover bg-white"
|
||||
/>
|
||||
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
{match.teamB?.teamname ?? 'Team B'}
|
||||
{match.teamB?.name ?? 'Team B'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
// /types/team.ts
|
||||
export type Player = {
|
||||
steamId: string
|
||||
name: string
|
||||
avatar: string
|
||||
location?: string
|
||||
premierRank?: number
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
steamId : string
|
||||
name : string
|
||||
avatar : string
|
||||
location? : string
|
||||
premierRank?: number
|
||||
isAdmin? : boolean
|
||||
}
|
||||
|
||||
export type Team = {
|
||||
id: string
|
||||
teamname: string | null
|
||||
logo: string | null
|
||||
leader: string
|
||||
players?: Player[]
|
||||
}
|
||||
id : string
|
||||
name?: string | null
|
||||
logo?: string | null
|
||||
leader?: string | null
|
||||
activePlayers? : Player[]
|
||||
inactivePlayers?: Player[]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
4
src/generated/prisma/index.d.ts
vendored
4
src/generated/prisma/index.d.ts
vendored
@ -16185,7 +16185,6 @@ export namespace Prisma {
|
||||
|
||||
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
steamId?: string
|
||||
teamId?: string
|
||||
AND?: UserWhereInput | UserWhereInput[]
|
||||
OR?: UserWhereInput[]
|
||||
NOT?: UserWhereInput | UserWhereInput[]
|
||||
@ -16193,6 +16192,7 @@ export namespace Prisma {
|
||||
avatar?: StringNullableFilter<"User"> | string | null
|
||||
location?: StringNullableFilter<"User"> | string | null
|
||||
isAdmin?: BoolFilter<"User"> | boolean
|
||||
teamId?: StringNullableFilter<"User"> | string | null
|
||||
premierRank?: IntNullableFilter<"User"> | number | null
|
||||
authCode?: StringNullableFilter<"User"> | string | null
|
||||
lastKnownShareCode?: StringNullableFilter<"User"> | string | null
|
||||
@ -16210,7 +16210,7 @@ export namespace Prisma {
|
||||
demoFiles?: DemoFileListRelationFilter
|
||||
createdSchedules?: ScheduleListRelationFilter
|
||||
confirmedSchedules?: ScheduleListRelationFilter
|
||||
}, "steamId" | "teamId">
|
||||
}, "steamId">
|
||||
|
||||
export type UserOrderByWithAggregationInput = {
|
||||
steamId?: SortOrder
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-79a53e7403334d4969ea3ed05480c7d287e1fc81fe58f157629c22acbdb9958c",
|
||||
"name": "prisma-client-42c2f4122e5c92abceced92f5ccc724f4e2068f151760c3339b2243eb9d75d8e",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp8412
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp8412
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user