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