This commit is contained in:
Linrador 2025-09-17 14:07:46 +02:00
parent e693af798b
commit 237be94ebe
42 changed files with 3408 additions and 1153 deletions

2
.env
View File

@ -14,7 +14,7 @@ AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3 ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9 PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
PTERODACTYL_CLIENT_API=ptlc_6NXqjxieIekaULga2jmuTPyPwdziigT82PRbrg3G4S7 PTERODACTYL_CLIENT_API=ptlc_6NXqjxieIekaULga2jmuTPyPwdziigT82PRbrg3G4S7
PTERO_PANEL_URL=https://panel.ironieopen.de PTERODACTYL_PANEL_URL=https://panel.ironieopen.de
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022 PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
PTERO_SERVER_SFTP_USER=army.37a11489 PTERO_SERVER_SFTP_USER=army.37a11489
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM

72
package-lock.json generated
View File

@ -15,7 +15,7 @@
"@fortawesome/fontawesome-free": "^7.0.0", "@fortawesome/fontawesome-free": "^7.0.0",
"@preline/dropdown": "^3.0.1", "@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0", "@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.15.0", "@prisma/client": "^6.16.1",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2", "csgo-sharecode": "^3.1.2",
@ -61,7 +61,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.15.0", "prisma": "^6.16.1",
"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",
@ -1599,9 +1599,9 @@
"license": "Licensed under MIT and Preline UI Fair Use License" "license": "Licensed under MIT and Preline UI Fair Use License"
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.1.tgz",
"integrity": "sha512-wR2LXUbOH4cL/WToatI/Y2c7uzni76oNFND7+23ypLllBmIS8e3ZHhO+nud9iXSXKFt1SoM3fTZvHawg63emZw==", "integrity": "sha512-QaBCOY29lLAxEFFJgBPyW3WInCW52fJeQTmWx/h6YsP5u0bwuqP51aP0uhqFvhK9DaZPwvai/M4tSDYLVE9vRg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -1621,9 +1621,9 @@
} }
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.1.tgz",
"integrity": "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA==", "integrity": "sha512-sz3uxRPNL62QrJ0EYiujCFkIGZ3hg+9hgC1Ae1HjoYuj0BxCqHua4JNijYvYCrh9LlofZDZcRBX3tHBfLvAngA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -1634,53 +1634,53 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.1.tgz",
"integrity": "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g==", "integrity": "sha512-RWv/VisW5vJE4cDRTuAHeVedtGoItXTnhuLHsSlJ9202QKz60uiXWywBlVcqXVq8bFeIZoCoWH+R1duZJPwqLw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.1.tgz",
"integrity": "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA==", "integrity": "sha512-EOnEM5HlosPudBqbI+jipmaW/vQEaF0bKBo4gVkGabasINHR6RpC6h44fKZEqx4GD8CvH+einD2+b49DQrwrAg==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.15.0", "@prisma/debug": "6.16.1",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/fetch-engine": "6.15.0", "@prisma/fetch-engine": "6.16.1",
"@prisma/get-platform": "6.15.0" "@prisma/get-platform": "6.16.1"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
"integrity": "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg==", "integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.1.tgz",
"integrity": "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw==", "integrity": "sha512-fl/PKQ8da5YTayw86WD3O9OmKJEM43gD3vANy2hS5S1CnfW2oPXk+Q03+gUWqcKK306QqhjjIHRFuTZ31WaosQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.15.0", "@prisma/debug": "6.16.1",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/get-platform": "6.15.0" "@prisma/get-platform": "6.16.1"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.1.tgz",
"integrity": "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg==", "integrity": "sha512-kUfg4vagBG7dnaGRcGd1c0ytQFcDj2SUABiuveIpL3bthFdTLI6PJeLEia6Q8Dgh+WhPdo0N2q0Fzjk63XTyaA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.15.0" "@prisma/debug": "6.16.1"
} }
}, },
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
@ -6781,15 +6781,15 @@
"peer": true "peer": true
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.15.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.1.tgz",
"integrity": "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ==", "integrity": "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/config": "6.15.0", "@prisma/config": "6.16.1",
"@prisma/engines": "6.15.0" "@prisma/engines": "6.16.1"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

View File

@ -19,7 +19,7 @@
"@fortawesome/fontawesome-free": "^7.0.0", "@fortawesome/fontawesome-free": "^7.0.0",
"@preline/dropdown": "^3.0.1", "@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0", "@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.15.0", "@prisma/client": "^6.16.1",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2", "csgo-sharecode": "^3.1.2",
@ -65,7 +65,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.15.0", "prisma": "^6.16.1",
"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",

View File

@ -1,20 +1,20 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../src/generated/prisma" output = "../src/generated/prisma"
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// //
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// 🧑 Benutzer, Teams & Verwaltung // 🧑 Benutzer, Teams & Verwaltung
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// //
model User { model User {
steamId String @id steamId String @id
name String? name String?
avatar String? avatar String?
@ -50,15 +50,17 @@ model User {
lastActiveAt DateTime? // optional: wann zuletzt aktiv lastActiveAt DateTime? // optional: wann zuletzt aktiv
readyAcceptances MatchReady[] @relation("MatchReadyUser") readyAcceptances MatchReady[] @relation("MatchReadyUser")
}
enum UserStatus { pterodactylClientApiKey String?
}
enum UserStatus {
online online
away away
offline offline
} }
model Team { model Team {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
logo String? logo String?
@ -80,9 +82,9 @@ model Team {
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB") schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVoteSteps MapVoteStep[] @relation("VoteStepTeam") mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
} }
model TeamInvite { model TeamInvite {
id String @id @default(uuid()) id String @id @default(uuid())
steamId String steamId String
teamId String teamId String
@ -91,9 +93,9 @@ model TeamInvite {
user User @relation("UserInvitations", fields: [steamId], references: [steamId]) user User @relation("UserInvitations", fields: [steamId], references: [steamId])
team Team @relation(fields: [teamId], references: [id]) team Team @relation(fields: [teamId], references: [id])
} }
model Notification { model Notification {
id String @id @default(uuid()) id String @id @default(uuid())
steamId String steamId String
title String? title String?
@ -105,19 +107,19 @@ model Notification {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [steamId], references: [steamId]) user User @relation(fields: [steamId], references: [steamId])
} }
// //
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// 🎮 Matches & Spieler // 🎮 Matches & Spieler
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// //
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// 🎮 Matches // 🎮 Matches
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
model Match { model Match {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
matchType String @default("community") matchType String @default("community")
@ -157,9 +159,9 @@ model Match {
schedule Schedule? schedule Schedule?
readyAcceptances MatchReady[] @relation("MatchReadyMatch") readyAcceptances MatchReady[] @relation("MatchReadyMatch")
} }
model MatchPlayer { model MatchPlayer {
id String @id @default(uuid()) id String @id @default(uuid())
steamId String steamId String
matchId String matchId String
@ -174,9 +176,9 @@ model MatchPlayer {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([matchId, steamId]) @@unique([matchId, steamId])
} }
model PlayerStats { model PlayerStats {
id String @id @default(uuid()) id String @id @default(uuid())
matchId String matchId String
steamId String steamId String
@ -217,9 +219,9 @@ model PlayerStats {
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId]) matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
@@unique([matchId, steamId]) @@unique([matchId, steamId])
} }
model RankHistory { model RankHistory {
id String @id @default(uuid()) id String @id @default(uuid())
steamId String steamId String
matchId String? matchId String?
@ -233,9 +235,9 @@ model RankHistory {
user User @relation("UserRankHistory", fields: [steamId], references: [steamId]) user User @relation("UserRankHistory", fields: [steamId], references: [steamId])
match Match? @relation("MatchRankHistory", fields: [matchId], references: [id]) match Match? @relation("MatchRankHistory", fields: [matchId], references: [id])
} }
model Schedule { model Schedule {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
description String? description String?
@ -260,23 +262,23 @@ model Schedule {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
enum ScheduleStatus { enum ScheduleStatus {
PENDING PENDING
CONFIRMED CONFIRMED
DECLINED DECLINED
CANCELLED CANCELLED
COMPLETED COMPLETED
} }
// //
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// 📦 Demo-Dateien & CS2 Requests // 📦 Demo-Dateien & CS2 Requests
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// //
model DemoFile { model DemoFile {
id String @id @default(uuid()) id String @id @default(uuid())
matchId String @unique matchId String @unique
steamId String steamId String
@ -288,9 +290,9 @@ model DemoFile {
match Match @relation(fields: [matchId], references: [id]) match Match @relation(fields: [matchId], references: [id])
user User @relation(fields: [steamId], references: [steamId]) user User @relation(fields: [steamId], references: [steamId])
} }
model ServerRequest { model ServerRequest {
id String @id @default(uuid()) id String @id @default(uuid())
steamId String steamId String
matchId String matchId String
@ -304,19 +306,19 @@ model ServerRequest {
user User @relation("MatchRequests", fields: [steamId], references: [steamId]) user User @relation("MatchRequests", fields: [steamId], references: [steamId])
@@unique([steamId, matchId]) @@unique([steamId, matchId])
} }
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// 🗺️ Map-Vote // 🗺️ Map-Vote
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
enum MapVoteAction { enum MapVoteAction {
BAN BAN
PICK PICK
DECIDER DECIDER
} }
model MapVote { model MapVote {
id String @id @default(uuid()) id String @id @default(uuid())
matchId String @unique matchId String @unique
match Match @relation(fields: [matchId], references: [id]) match Match @relation(fields: [matchId], references: [id])
@ -336,9 +338,9 @@ model MapVote {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model MapVoteStep { model MapVoteStep {
id String @id @default(uuid()) id String @id @default(uuid())
voteId String voteId String
order Int order Int
@ -357,9 +359,9 @@ model MapVoteStep {
@@unique([voteId, order]) @@unique([voteId, order])
@@index([teamId]) @@index([teamId])
@@index([chosenBy]) @@index([chosenBy])
} }
model MatchReady { model MatchReady {
matchId String matchId String
steamId String steamId String
acceptedAt DateTime @default(now()) acceptedAt DateTime @default(now())
@ -369,4 +371,18 @@ model MatchReady {
@@id([matchId, steamId]) @@id([matchId, steamId])
@@index([steamId]) @@index([steamId])
} }
// ──────────────────────────────────────────────
// 🛠️ Server-Konfiguration & Pterodactyl
// ──────────────────────────────────────────────
model ServerConfig {
id String @id
serverIp String
serverPassword String? // ⬅️ neu
pterodactylServerId String
pterodactylServerApiKey String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -8,6 +8,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<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="Teams" href="/admin/teams" /> <Tab name="Teams" href="/admin/teams" />
<Tab name="Serververwaltung" href="/admin/server" />
</Tabs> </Tabs>
<div className="mt-6"> <div className="mt-6">
{children} {children}

View File

@ -0,0 +1,88 @@
// /src/app/admin/server/page.tsx
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
import Card from '@/app/components/Card'
import ServerView from '@/app/components/admin/server/ServerView'
export const dynamic = 'force-dynamic'
async function ensureConfig() {
const cfg = await prisma.serverConfig.upsert({
where: { id: 'default' },
update: {},
create: {
id: 'default',
serverIp: '',
serverPassword: '', // ⬅️ neu
pterodactylServerId: '',
pterodactylServerApiKey: '',
},
})
return cfg
}
export default async function AdminServerPage(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const me = session?.user as any | undefined
if (!me?.steamId || !me?.isAdmin) {
redirect('/')
}
const [cfg, meUser] = await Promise.all([
ensureConfig(),
prisma.user.findUnique({
where: { steamId: me.steamId },
select: { steamId: true, pterodactylClientApiKey: true, name: true },
}),
])
async function save(formData: FormData) {
'use server'
const serverIp = String(formData.get('serverIp') ?? '').trim()
const serverId = String(formData.get('serverId') ?? '').trim()
const serverApiKey = String(formData.get('serverApiKey') ?? '').trim()
const serverPassword = String(formData.get('serverPassword') ?? '').trim() // ⬅️ neu
const clientApiKey = String(formData.get('clientApiKey') ?? '').trim()
if (!serverIp) throw new Error('Server-IP darf nicht leer sein.')
if (!serverId) throw new Error('Pterodactyl Server-ID darf nicht leer sein.')
await prisma.$transaction(async (tx) => {
await tx.serverConfig.update({
where: { id: 'default' },
data: {
serverIp,
pterodactylServerId: serverId,
...(serverApiKey ? { pterodactylServerApiKey: serverApiKey } : {}),
...(serverPassword ? { serverPassword } : {}), // ⬅️ nur setzen, wenn übergeben
},
})
if (clientApiKey) {
await tx.user.update({
where: { steamId: me?.steamId },
data: { pterodactylClientApiKey: clientApiKey },
})
}
})
revalidatePath('/admin/server')
}
return (
<Card title="Serververwaltung" description="Hier kannst du die Servereinstellungen ändern" maxWidth="auto">
<ServerView
cfg={cfg}
meUser={meUser}
meSteamId={me.steamId}
onSave={save}
/>
</Card>
)
}

View File

@ -0,0 +1,60 @@
// /src/app/api/cs2/server/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function buildConnectHref(serverIp: string, password?: string | null, port = 27015) {
const pass = (password ?? '').trim()
return pass
? `steam://connect/${serverIp}:${port}/${pass}`
: `steam://connect/${serverIp}:${port}`
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const me = session?.user as { steamId?: string } | undefined
if (!me?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const { searchParams } = new URL(req.url)
const matchId = searchParams.get('matchId') || undefined
// 1) Config aus DB
const cfg = await prisma.serverConfig.findUnique({ where: { id: 'default' } })
if (!cfg?.serverIp) {
return NextResponse.json({ error: 'server not configured' }, { status: 503 })
}
const port = Number(process.env.NEXT_PUBLIC_GAME_PORT ?? '27015') || 27015
// 2) Optional: nur Teilnehmer des Matches bekommen den Link
if (matchId) {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA: { select: { leaderId: true } },
teamB: { select: { leaderId: true } },
teamAUsers: { select: { steamId: true } },
teamBUsers: { select: { steamId: true } },
players: { select: { steamId: true } },
},
})
if (!match) return NextResponse.json({ error: 'match not found' }, { status: 404 })
const ids = new Set<string>()
match.teamAUsers.forEach(u => ids.add(u.steamId))
match.teamBUsers.forEach(u => ids.add(u.steamId))
match.players.forEach(p => ids.add(p.steamId))
if (match.teamA?.leaderId) ids.add(match.teamA.leaderId)
if (match.teamB?.leaderId) ids.add(match.teamB.leaderId)
if (!ids.has(me.steamId!)) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
}
}
const connectHref = buildConnectHref(cfg.serverIp, cfg.serverPassword ?? undefined, port)
return NextResponse.json({ connectHref }, { headers: { 'Cache-Control': 'no-store' } })
}

View File

@ -0,0 +1,98 @@
// /src/app/api/cs2/server/send-command/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function buildPanelUrl(base: string, serverId: string) {
const u = new URL(base.includes('://') ? base : `https://${base}`)
// /api/client/servers/:id/command
const cleaned = (u.pathname || '').replace(/\/+$/,'')
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
return u.toString()
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const me = session?.user as { steamId?: string; isAdmin?: boolean } | undefined
if (!me?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
if (!me?.isAdmin) return NextResponse.json({ error: 'forbidden' }, { status: 403 })
let body: { command?: string; serverId?: string } = {}
try { body = await req.json() } catch {}
const command = (body.command ?? '').trim()
if (!command) {
return NextResponse.json({ error: 'command required' }, { status: 400 })
}
// Panel-Base-URL aus ENV
const panelBase =
process.env.PTERODACTYL_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
''
if (!panelBase) {
return NextResponse.json({ error: 'PTERODACTYL_PANEL_URL not set' }, { status: 500 })
}
// ServerId (global aus Config, optional via Body überschreibbar)
const cfg = await prisma.serverConfig.findUnique({
where: { id: 'default' },
select: { pterodactylServerId: true },
})
const serverId = (body.serverId ?? cfg?.pterodactylServerId ?? '').trim()
if (!serverId) {
return NextResponse.json({ error: 'serverId not configured' }, { status: 503 })
}
// Userbasierter Client-API-Key
const user = await prisma.user.findUnique({
where: { steamId: me.steamId! },
select: { pterodactylClientApiKey: true },
})
const clientKey = (user?.pterodactylClientApiKey ?? '').trim()
if (!clientKey) {
return NextResponse.json({ error: 'missing client api key for user' }, { status: 403 })
}
const url = buildPanelUrl(panelBase, serverId)
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${clientKey}`,
Accept: 'Application/vnd.pterodactyl.v1+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ command }),
// Panel ist „extern“ niemals cachen
cache: 'no-store',
})
// Pterodactyl antwortet häufig mit 204 No Content
if (res.status === 204) {
return NextResponse.json({ ok: true, status: 204 }, { headers: { 'Cache-Control': 'no-store' } })
}
const text = await res.text().catch(() => '')
if (!res.ok) {
let errPayload: any = undefined
try { errPayload = JSON.parse(text) } catch {}
return NextResponse.json(
{ error: 'pterodactyl_error', status: res.status, body: errPayload ?? text },
{ status: 502 }
)
}
let json: any = {}
try { json = JSON.parse(text) } catch { json = { body: text } }
return NextResponse.json({ ok: true, status: res.status, response: json }, { headers: { 'Cache-Control': 'no-store' } })
} catch (e: any) {
return NextResponse.json({ error: e?.message ?? 'request_failed' }, { status: 500 })
}
}

View File

@ -1,151 +0,0 @@
// /app/api/matches/[id]/export-to-sftp/route.ts
import { NextResponse } from 'next/server'
import { randomBytes } from 'crypto'
export const runtime = 'nodejs' // KEIN edge
export const dynamic = 'force-dynamic'
type MapVoteStep = {
action: 'ban' | 'pick' | 'decider'
map?: string | null
teamId?: string | null
}
type MapVoteState = {
bestOf?: number
steps: MapVoteStep[]
mapVisuals?: Record<string, { label?: string; bg?: string }>
teams?: {
teamA?: { id?: string | null, name?: string | null, players?: Array<{ steamId: string, name?: string | null }> }
teamB?: { id?: string | null, name?: string | null, players?: Array<{ steamId: string, name?: string | null }> }
}
locked?: boolean
}
type PlayerLike = { user?: { steamId: string, name?: string | null }, steamId?: string, name?: string | null }
type MatchLike = {
id: string | number
bestOf?: number
teamA?: { name?: string | null, players?: PlayerLike[] | any[] }
teamB?: { name?: string | null, players?: PlayerLike[] | any[] }
}
function sanitizeFilePart(s?: string | null) {
return (s ?? 'team').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
function playersMapFromList(list: PlayerLike[] | any[] | undefined) {
const out: Record<string, string> = {}
for (const p of list ?? []) {
const sid = (p?.user?.steamId ?? (p as any)?.steamId) as string | undefined
if (!sid) continue
const name = (p?.user?.name ?? p?.name ?? 'Player') as string
out[sid] = name
}
return out
}
function toDeMapName(key: string) {
if (key.startsWith('de_')) return key
return `de_${key}`
}
function buildMatchJson(match: MatchLike, state: MapVoteState) {
const bestOf = match.bestOf ?? state.bestOf ?? 3
// Nur Picks + Decider in Reihenfolge (ohne Lücken)
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
// maplist
const maplist = chosen.slice(0, bestOf).map(s => toDeMapName(s.map!))
// einfache Sides-Logik (Beispiel): first two maps start CT for Team1/Team2, last knife
const map_sides = maplist.map((_, i) => {
if (i === maplist.length - 1) return 'knife'
return i % 2 === 0 ? 'team1_ct' : 'team2_ct'
})
const team1Name = match.teamA?.name ?? 'Team_1'
const team2Name = match.teamB?.name ?? 'Team_2'
const team1Players = playersMapFromList(match.teamA?.players)
const team2Players = playersMapFromList(match.teamB?.players)
const payload = {
matchid: Number(match.id) || 0,
team1: { name: team1Name, players: team1Players },
team2: { name: team2Name, players: team2Players },
num_maps: bestOf,
maplist,
map_sides,
spectators: { players: {} as Record<string, string> }, // optional
clinch_series: true,
players_per_team: 5,
cvars: {
hostname: `Iron:e Open 4 | ${team1Name} vs ${team2Name}`,
mp_friendlyfire: '1',
},
}
return payload
}
export async function POST(req: Request, { params }: { params: { matchId: string } }) {
try {
const SFTPClient = (await import('ssh2-sftp-client')).default // dyn. import
const { match, state } = (await req.json()) as { match: MatchLike, state: MapVoteState }
if (!match || !state?.locked) {
return NextResponse.json({ error: 'Ungültige Daten oder Voting nicht abgeschlossen.' }, { status: 400 })
}
const bestOf = match.bestOf ?? state.bestOf ?? 3
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
if (chosen.length < bestOf) {
return NextResponse.json({ error: 'Es sind noch nicht alle Maps gewählt.' }, { status: 400 })
}
// JSON bauen
const json = buildMatchJson(match, state)
const jsonStr = JSON.stringify(json, null, 2)
// Dateiname (wie gewünscht)
const team1 = sanitizeFilePart(match.teamA?.name)
const team2 = sanitizeFilePart(match.teamB?.name)
const rid = randomBytes(3).toString('hex') // 6 hex chars
const filename = `team_${team1}_vs_team_${team2}_${rid}.json`
// SFTP Credentials
const url = process.env.PTERO_SERVER_SFTP_URL || '' // z.B. "sftp://your.host:22" oder "your.host:22" oder nur Host
const user = process.env.PTERO_SERVER_SFTP_USER
const pass = process.env.PTERO_SERVER_SFTP_PASSWORD
if (!url || !user || !pass) {
return NextResponse.json({ error: 'SFTP-Umgebungsvariablen fehlen.' }, { status: 500 })
}
// host/port extrahieren
let host = url
let port = 22
try {
const u = new URL(url.includes('://') ? url : `sftp://${url}`)
host = u.hostname
port = Number(u.port) || 22
} catch {
// Fallback: "host:port" oder nur host
const [h, p] = url.split(':')
host = h ?? url
port = p ? Number(p) : 22
}
// Upload
const sftp = new SFTPClient()
await sftp.connect({ host, port, username: user, password: pass })
const remotePath = `/game/csgo/${filename}`
await sftp.put(Buffer.from(jsonStr, 'utf8'), remotePath)
await sftp.end()
return NextResponse.json({ ok: true, remotePath, filename })
} catch (err: any) {
console.error('[export-to-sftp] error:', err)
return NextResponse.json({ error: err?.message ?? 'Upload fehlgeschlagen' }, { status: 500 })
}
}

View File

@ -22,6 +22,81 @@ const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms)); const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
function makeRandomMatchId() {
try {
// 910-stellige ID (>= 100_000_000) Obergrenze exklusiv
return typeof randomInt === 'function'
? randomInt(100_000_000, 2_147_483_647) // bis INT32_MAX
: (Math.floor(Math.random() * (2_147_483_647 - 100_000_000)) + 100_000_000)
} catch {
return Math.floor(Math.random() * 1_000_000_000) + 100_000_000
}
}
function buildPteroClientUrl(base: string, serverId: string) {
const u = new URL(base.includes('://') ? base : `https://${base}`)
const cleaned = (u.pathname || '').replace(/\/+$/, '')
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
return u.toString()
}
async function sendServerCommand(command: string) {
try {
const panelBase =
process.env.PTERODACTYL_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
''
if (!panelBase) {
console.warn('[mapvote] PTERODACTYL_PANEL_URL fehlt Command wird nicht gesendet.')
return
}
// ⬇️ Client-API-Key NUR aus .env ziehen
const clientApiKey = process.env.PTERODACTYL_CLIENT_API || ''
if (!clientApiKey) {
console.warn('[mapvote] PTERODACTYL_CLIENT_API fehlt Command wird nicht gesendet.')
return
}
// Server-ID weiterhin aus DB (ServerConfig)
const cfg = await prisma.serverConfig.findUnique({
where: { id: 'default' },
select: { pterodactylServerId: true },
})
if (!cfg?.pterodactylServerId) {
console.warn('[mapvote] pterodactylServerId fehlt in ServerConfig Command wird nicht gesendet.')
return
}
const url = buildPteroClientUrl(panelBase, cfg.pterodactylServerId)
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${clientApiKey}`, // ✅ Client-API-Key
Accept: 'Application/vnd.pterodactyl.v1+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ command }),
cache: 'no-store',
})
if (res.status === 204) {
console.log('[mapvote] Command OK (204):', command)
return
}
if (!res.ok) {
const t = await res.text().catch(() => '')
console.error('[mapvote] Command fehlgeschlagen:', res.status, t)
return
}
console.log('[mapvote] Command OK:', command)
} catch (e) {
console.error('[mapvote] Command-Fehler:', e)
}
}
// Admin-Edit-Flag setzen/zurücksetzen // Admin-Edit-Flag setzen/zurücksetzen
async function setAdminEdit(voteId: string, by: string | null) { async function setAdminEdit(voteId: string, by: string | null) {
return prisma.mapVote.update({ return prisma.mapVote.update({
@ -316,6 +391,8 @@ function toDeMapName(key: string) {
if (key.startsWith('de_')) return key if (key.startsWith('de_')) return key
return `de_${key}` return `de_${key}`
} }
// ⬇️ buildMatchJson anpassen: matchid statt "" → zufälliger Integer
function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) { function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
const bestOf = match.bestOf ?? state.bestOf ?? 3 const bestOf = match.bestOf ?? state.bestOf ?? 3
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
@ -333,8 +410,11 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
const team1Players = playersMapFromList(match.teamA?.players) const team1Players = playersMapFromList(match.teamA?.players)
const team2Players = playersMapFromList(match.teamB?.players) const team2Players = playersMapFromList(match.teamB?.players)
// 👇 hier neu: zufällige Integer-ID
const rndId = makeRandomMatchId()
return { return {
matchid: "", matchid: rndId, // vorher: ""
team1: { name: team1Name, players: team1Players }, team1: { name: team1Name, players: team1Players },
team2: { name: team2Name, players: team2Players }, team2: { name: team2Name, players: team2Players },
num_maps: bestOf, num_maps: bestOf,
@ -352,7 +432,7 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
async function exportMatchToSftpDirect(match: any, vote: any) { async function exportMatchToSftpDirect(match: any, vote: any) {
try { try {
const SFTPClient = (await import('ssh2-sftp-client')).default // dyn. import const SFTPClient = (await import('ssh2-sftp-client')).default
const mLike: MatchLike = { const mLike: MatchLike = {
id: match.id, id: match.id,
bestOf: match.bestOf ?? vote.bestOf ?? 3, bestOf: match.bestOf ?? vote.bestOf ?? 3,
@ -367,15 +447,13 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
locked: vote.locked, locked: vote.locked,
} }
if (!sLike.locked) return // nur exportieren, wenn locked if (!sLike.locked) return
const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3 const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
if (chosen.length < bestOf) return // vollständig sicherstellen if (chosen.length < bestOf) return
const json = buildMatchJson(mLike, sLike) const json = buildMatchJson(mLike, sLike)
const jsonStr = JSON.stringify(json, null, 2) const jsonStr = JSON.stringify(json, null, 2)
const filename = `${match.id}.json` const filename = `${match.id}.json`
const url = process.env.PTERO_SERVER_SFTP_URL || '' const url = process.env.PTERO_SERVER_SFTP_URL || ''
@ -405,11 +483,17 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
await sftp.end() await sftp.end()
console.log(`[mapvote] Export OK → ${remotePath}`) console.log(`[mapvote] Export OK → ${remotePath}`)
// 👇 NACH ERFOLGREICHEM UPLOAD: Match in CS2-Plugin laden
// Laut Vorgabe nur die JSON-Datei als Argument übergeben:
await sendServerCommand(`matchzy_loadmatch ${filename}`)
// (Falls dein Plugin den absoluten Pfad erwartet, nimm stattdessen: `matchzy_loadmatch ${remotePath}`)
} catch (err) { } catch (err) {
console.error('[mapvote] Export fehlgeschlagen:', err) console.error('[mapvote] Export fehlgeschlagen:', err)
} }
} }
/* ---------- kleine Helfer für match-ready Payload ---------- */ /* ---------- kleine Helfer für match-ready Payload ---------- */
function deriveChosenSteps(vote: any) { function deriveChosenSteps(vote: any) {

View File

@ -6,12 +6,27 @@ import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import MatchReadyOverlay from './MatchReadyOverlay' import MatchReadyOverlay from './MatchReadyOverlay'
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore' import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore'
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore' // ⬅️ neu
/** // ---- kleiner In-Memory Cache für connectHref pro matchId
* Erwartet SSE-Events: const CONNECT_CACHE = new Map<string | undefined, string>()
* - 'match-ready' { matchId, firstMap:{label,bg}, participants:string[] }
* - 'map-vote-updated' { matchId, locked, bestOf, steps, mapVisuals, teams:{teamA,teamB} } async function getConnectHref(matchId?: string): Promise<string | null> {
*/ if (CONNECT_CACHE.has(matchId)) return CONNECT_CACHE.get(matchId) || null
try {
const qs = matchId ? `?matchId=${encodeURIComponent(matchId)}` : ''
const r = await fetch(`/api/cs2/server${qs}`, { cache: 'no-store' })
if (!r.ok) {
return null
}
const j = await r.json()
const href: string | undefined = j?.connectHref
if (href) CONNECT_CACHE.set(matchId, href)
return href ?? null
} catch {
return null
}
}
export default function ReadyOverlayHost() { export default function ReadyOverlayHost() {
const router = useRouter() const router = useRouter()
@ -21,7 +36,9 @@ export default function ReadyOverlayHost() {
const { open, data, showWithDelay, hide } = useReadyOverlayStore() const { open, data, showWithDelay, hide } = useReadyOverlayStore()
// --- LocalStorage-Flag: pro Match nur einmal anzeigen --- const setRoster = useMatchRosterStore(s => s.setRoster) // ⬅️ neu
const clearRoster = useMatchRosterStore(s => s.clearRoster) // ⬅️ neu
const isAccepted = (matchId: string) => const isAccepted = (matchId: string) =>
typeof window !== 'undefined' && typeof window !== 'undefined' &&
window.localStorage.getItem(`match:${matchId}:readyAccepted`) === '1' window.localStorage.getItem(`match:${matchId}:readyAccepted`) === '1'
@ -32,7 +49,7 @@ export default function ReadyOverlayHost() {
} }
} }
// --- Ableitung firstMap + participants aus 'map-vote-updated' --- // Aus 'map-vote-updated' minimal Summary ableiten (Map 1 + Teilnehmer)
function deriveReadySummary(payload: any) { function deriveReadySummary(payload: any) {
const matchId: string | undefined = payload?.matchId const matchId: string | undefined = payload?.matchId
if (!matchId) return null if (!matchId) return null
@ -65,49 +82,71 @@ export default function ReadyOverlayHost() {
return { matchId, firstMap: { key, label, bg }, participants } return { matchId, firstMap: { key, label, bg }, participants }
} }
// --- SSE: 'match-ready' / 'map-vote-updated' öffnen das Overlay --- // Events: 'match-ready' & 'map-vote-updated'
useEffect(() => { useEffect(() => {
if (!lastEvent || !mySteamId) return if (!lastEvent || !mySteamId) return
if (lastEvent.type === 'match-ready') { if (lastEvent.type === 'match-ready') {
const m = lastEvent.payload?.matchId (async () => {
const m: string | undefined = lastEvent.payload?.matchId
const participants: string[] = lastEvent.payload?.participants ?? [] const participants: string[] = lastEvent.payload?.participants ?? []
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
// ⬇️ Roster persistent speichern (für Reconnect-Banner)
setRoster(participants)
const label = lastEvent.payload?.firstMap?.label ?? '?' const label = lastEvent.payload?.firstMap?.label ?? '?'
const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp' const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
const connectHref =
(await getConnectHref(m)) ||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
null
showWithDelay( showWithDelay(
{ {
matchId: m, matchId: m,
mapLabel: label, mapLabel: label,
mapBg: bg, mapBg: bg,
nextHref: `/match-details/${m}/radar`, nextHref: `/match-details/${m}/radar`,
connectHref: connectHref ?? undefined,
}, },
3000 3000
) )
})()
return return
} }
if (lastEvent.type === 'map-vote-updated') { if (lastEvent.type === 'map-vote-updated') {
(async () => {
const summary = deriveReadySummary(lastEvent.payload) const summary = deriveReadySummary(lastEvent.payload)
if (!summary) return if (!summary) return
const { matchId: m, firstMap, participants } = summary const { matchId: m, firstMap, participants } = summary
if (!participants.includes(mySteamId) || isAccepted(m)) return if (!participants.includes(mySteamId) || isAccepted(m)) return
// ⬇️ Roster persistent speichern
setRoster(participants)
const connectHref =
(await getConnectHref(m)) ||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
null
showWithDelay( showWithDelay(
{ {
matchId: m, matchId: m,
mapLabel: firstMap.label, mapLabel: firstMap.label,
mapBg: firstMap.bg, mapBg: firstMap.bg,
nextHref: `/match-details/${m}/radar`, nextHref: `/match-details/${m}/radar`,
connectHref: connectHref ?? undefined,
}, },
3000 3000
) )
})()
} }
}, [lastEvent, mySteamId, showWithDelay]) }, [lastEvent, mySteamId, showWithDelay, setRoster])
// --- Reset-Event: Overlay schließen & Flag löschen --- // Reset schließt Overlay & löscht Flag + Roster
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
if (lastEvent.type === 'map-vote-reset') { if (lastEvent.type === 'map-vote-reset') {
@ -115,27 +154,23 @@ export default function ReadyOverlayHost() {
if (m && typeof window !== 'undefined') { if (m && typeof window !== 'undefined') {
window.localStorage.removeItem(`match:${m}:readyAccepted`) window.localStorage.removeItem(`match:${m}:readyAccepted`)
} }
clearRoster() // ⬅️ neu
if (open) hide() if (open) hide()
} }
}, [lastEvent, open, hide]) }, [lastEvent, open, hide, clearRoster])
if (!open || !data) return null if (!open || !data) return null
return ( return (
<MatchReadyOverlay <MatchReadyOverlay
open={open} open={open}
matchId={data.matchId} // ✅ neu: fürs Ready-Polling im Overlay matchId={data.matchId}
mapLabel={data.mapLabel} mapLabel={data.mapLabel}
mapBg={data.mapBg} mapBg={data.mapBg}
onAccept={async () => { onAccept={async () => {
// Backend optional informieren (Overlay bleibt offen!)
// await fetch(`/api/matches/${data.matchId}/ready-accept`, { method: 'POST' }).catch(() => {})
markAccepted(data.matchId) markAccepted(data.matchId)
// ❌ NICHT mehr schließen oder navigieren
// hide()
// router.push(data.nextHref ?? `/match-details/${data.matchId}/radar`)
}} }}
connectHref={data.connectHref}
/> />
) )
} }

View File

@ -1,8 +1,10 @@
'use client' 'use client'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { usePresenceStore } from '@/app/lib/usePresenceStore' import { usePresenceStore } from '@/app/lib/usePresenceStore'
import { useTelemetryStore } from '@/app/lib/useTelemetryStore' import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore'
import { useSession } from 'next-auth/react'
type Status = 'idle'|'connecting'|'open'|'closed'|'error' type Status = 'idle'|'connecting'|'open'|'closed'|'error'
@ -29,17 +31,46 @@ export default function TelemetrySocket() {
[] []
) )
const { data: session } = useSession()
const mySteamId = (session?.user as any)?.steamId ?? null
const setSnapshot = usePresenceStore(s => s.setSnapshot) const setSnapshot = usePresenceStore(s => s.setSnapshot)
const setJoin = usePresenceStore(s => s.setJoin) const setJoin = usePresenceStore(s => s.setJoin)
const setLeave = usePresenceStore(s => s.setLeave) const setLeave = usePresenceStore(s => s.setLeave)
const setMapKey = useTelemetryStore(s => s.setMapKey) const setMapKey = useTelemetryStore(s => s.setMapKey)
const phase = useTelemetryStore(s => s.phase)
const setPhase = useTelemetryStore(s => s.setPhase)
// interne Refs für saubere Handler const rosterSet = useMatchRosterStore(s => s.roster)
// 👇 Tracke, ob ICH gerade auf dem Server bin
const [myConnected, setMyConnected] = useState(false)
// internes Dismiss-Flag fürs Banner (pro Mount)
const [dismissed, setDismissed] = useState(false)
// connectHref optional dynamisch vom Backend ziehen
const [connectHref, setConnectHref] = useState<string | null>(null)
// WS-Reconnect
const aliveRef = useRef(true) const aliveRef = useRef(true)
const retryRef = useRef<number | null>(null) const retryRef = useRef<number | null>(null)
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
// Connect-Href laden (Passwort etc. aus DB)
useEffect(() => {
(async () => {
try {
const r = await fetch('/api/cs2/server', { cache: 'no-store' })
if (r.ok) {
const j = await r.json()
if (j?.connectHref) setConnectHref(j.connectHref)
}
} catch {}
})()
}, [])
useEffect(() => { useEffect(() => {
aliveRef.current = true aliveRef.current = true
@ -65,20 +96,38 @@ export default function TelemetrySocket() {
if (!msg) return if (!msg) return
if (msg.type === 'players' && Array.isArray(msg.players)) { if (msg.type === 'players' && Array.isArray(msg.players)) {
// kompletter Presence-Snapshot
setSnapshot(msg.players) setSnapshot(msg.players)
// 👇 bin ich in der aktuellen Players-Liste?
if (mySteamId) {
const present = msg.players.some((p: any) =>
String(p?.steamId ?? p?.steam_id ?? p?.id) === String(mySteamId)
)
setMyConnected(present)
}
} else if (msg.type === 'player_join' && msg.player) { } else if (msg.type === 'player_join' && msg.player) {
setJoin(msg.player) setJoin(msg.player)
// 👇 falls ich join:
const sid = msg.player?.steamId ?? msg.player?.steam_id ?? msg.player?.id
if (mySteamId && String(sid) === String(mySteamId)) setMyConnected(true)
} else if (msg.type === 'player_leave') { } else if (msg.type === 'player_leave') {
const sid = msg.steamId ?? msg.steam_id ?? msg.id const sid = msg.steamId ?? msg.steam_id ?? msg.id
if (sid != null) setLeave(sid) if (sid != null) setLeave(sid)
// 👇 falls ich leave:
if (mySteamId && String(sid) === String(mySteamId)) setMyConnected(false)
} }
// ⬇️ Map-Event aus /telemetry // Map (inkl. optionaler Phase)
if (msg.type === 'map' && typeof msg.name === 'string') { if (msg.type === 'map' && typeof msg.name === 'string') {
const key = msg.name.toLowerCase() const key = msg.name.toLowerCase()
if (process.env.NODE_ENV!=='production') console.debug('[TelemetrySocket] map:', key) if (process.env.NODE_ENV!=='production') console.debug('[TelemetrySocket] map:', key)
setMapKey(key) setMapKey(key)
if (typeof msg.phase === 'string') {
setPhase(String(msg.phase).toLowerCase() as any)
}
}
// Reine Phase-Events
if (msg.type === 'phase' && typeof msg.phase === 'string') {
setPhase(String(msg.phase).toLowerCase() as any)
} }
} }
} }
@ -89,7 +138,61 @@ export default function TelemetrySocket() {
if (retryRef.current) window.clearTimeout(retryRef.current) if (retryRef.current) window.clearTimeout(retryRef.current)
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {} try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
} }
}, [url, setSnapshot, setJoin, setLeave]) }, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, mySteamId])
return null // Anzeige-Logik Banner:
// - Ich bin im Roster (Match wurde geladen & ich bin Teilnehmer)
// - Match-Phase live
// - Ich bin NICHT connected (disconnect)
const meInRoster = !!mySteamId && rosterSet instanceof Set && rosterSet.has(String(mySteamId))
const shouldShow = !dismissed && (phase === 'live') && meInRoster && !myConnected
const connectUri =
connectHref // ⬅️ bevorzugt API-Route (inkl. Passwort)
|| process.env.NEXT_PUBLIC_STEAM_CONNECT_URI
|| process.env.NEXT_PUBLIC_CS2_CONNECT_URI
|| 'steam://rungameid/730//+retry' // Fallback
const handleReconnect = () => {
try {
window.location.href = connectUri
} catch {
// no-op
}
}
return (
<>
{/* WebSocket-Client selbst rendert nichts */}
{shouldShow && (
<div className="fixed inset-x-0 bottom-0 z-[9999] mx-auto mb-3 max-w-3xl">
<div className="mx-3 rounded-md bg-neutral-900/95 text-white shadow-lg ring-1 ring-black/10">
<div className="p-3 flex items-center gap-3">
<div className="flex-1">
<div className="text-sm font-semibold">Match läuft · Reconnect verfügbar</div>
<div className="text-xs opacity-80">
Du bist im aufgesetzten Match eingetragen. Klicke Reconnect, um wieder zu joinen.
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleReconnect}
className="px-3 py-1.5 rounded bg-emerald-500 hover:bg-emerald-600 text-sm font-semibold"
>
Reconnect
</button>
<button
onClick={() => setDismissed(true)}
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-xs"
aria-label="schließen"
>
</button>
</div>
</div>
</div>
</div>
)}
</>
)
} }

View File

@ -1,15 +0,0 @@
// components/UserClips.tsx
import { useEffect, useState } from 'react'
import { getClips, type Clip } from '../lib/allstar'
export default function UserClips({ steamId }: { steamId: string }) {
const [clips, setClips] = useState<Clip[]>([])
useEffect(() => { getClips(steamId).then(r => setClips(r.clips)) }, [steamId])
return (
<div className="grid gap-4 md:grid-cols-3">
{clips.map(c => (
<video key={c.id} src={c.url} controls className="rounded-lg w-full" />
))}
</div>
)
}

View File

@ -0,0 +1,138 @@
// /src/app/admin/server/ServerView.tsx
import Button from '@/app/components/Button' // ⬅️ neu
type ServerConfigShape = {
serverIp: string
serverPassword?: string | null
pterodactylServerId: string
pterodactylServerApiKey: string
}
type MeUserShape = {
steamId: string
name?: string | null
pterodactylClientApiKey?: string | null
} | null
export default function ServerView({
cfg,
meUser,
meSteamId,
onSave,
}: {
cfg: ServerConfigShape
meUser: MeUserShape
meSteamId: string
onSave: (formData: FormData) => Promise<void>
}) {
const displayName = meUser?.name || meSteamId
return (
<div className="mx-auto max-w-3xl p-6">
<h1 className="text-2xl font-bold mb-4">Server-Einstellungen</h1>
<p className="text-sm text-neutral-500 mb-6">
Hier konfigurierst du den Match-Server (global) und deinen persönlichen Pterodactyl <i>Client</i>-API-Key.
</p>
<form action={onSave} className="space-y-6">
<fieldset className="rounded-md border border-neutral-200/50 p-4">
<legend className="px-2 text-sm font-semibold">Server (global)</legend>
<div className="grid grid-cols-1 gap-4">
<label className="block">
<span className="text-sm font-medium">Server-IP / Host</span>
<input
name="serverIp"
type="text"
defaultValue={cfg.serverIp}
required
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
placeholder="z. B. 1.2.3.4 oder gameserver.domain.tld"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Pterodactyl Server-ID</span>
<input
name="serverId"
type="text"
defaultValue={cfg.pterodactylServerId}
required
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
placeholder="z. B. a1b2c3d4"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Pterodactyl <b>Server</b>-API-Key (global)</span>
<input
name="serverApiKey"
type="password"
defaultValue=""
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
placeholder={cfg.pterodactylServerApiKey ? '•••••••• (gesetzt)' : 'noch nicht gesetzt'}
autoComplete="off"
/>
<p className="mt-1 text-xs text-neutral-500">
Leer lassen, um den bestehenden Key nicht zu überschreiben.
</p>
</label>
{/* Server-Passwort für Connect */}
<label className="block">
<span className="text-sm font-medium">Server-Passwort (für Connect)</span>
<input
name="serverPassword"
type="password"
defaultValue=""
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
placeholder={cfg.serverPassword ? '•••••••• (gesetzt)' : 'noch nicht gesetzt'}
autoComplete="off"
/>
<p className="mt-1 text-xs text-neutral-500">
Leer lassen, um das bestehende Passwort nicht zu überschreiben.
</p>
</label>
</div>
</fieldset>
<fieldset className="rounded-md border border-neutral-200/50 p-4">
<legend className="px-2 text-sm font-semibold">
Dein Pterodactyl <b>Client</b>-API-Key (userbasiert)
</legend>
<div className="grid grid-cols-1 gap-4">
<div className="text-xs text-neutral-500">
Eingeloggt als <b>{displayName}</b>
</div>
<label className="block">
<span className="text-sm font-medium">Client-API-Key</span>
<input
name="clientApiKey"
type="password"
defaultValue=""
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
placeholder={meUser?.pterodactylClientApiKey ? '•••••••• (gesetzt)' : 'noch nicht gesetzt'}
autoComplete="off"
/>
<p className="mt-1 text-xs text-neutral-500">
Leer lassen, um den bestehenden Key nicht zu überschreiben.
</p>
</label>
</div>
</fieldset>
{/* ⬇️ nur noch der Speichern-Button (deine Button-Komponente) */}
<div className="flex items-center justify-end">
<Button type="submit" color="green" variant="solid" size="md" title="Speichern" />
</div>
</form>
</div>
)
}

View File

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

View File

@ -0,0 +1,394 @@
// src/app/components/radar/StaticEffects.tsx
'use client'
import React from 'react'
type BombState = {
x: number
y: number
z: number
status: 'carried'|'dropped'|'planted'|'defusing'|'defused'|'unknown'
changedAt: number
}
type Grenade = {
id: string
kind: 'smoke' | 'molotov' | 'incendiary' | 'he' | 'flash' | 'decoy' | 'unknown'
x: number
y: number
z: number
radius?: number | null
expiresAt?: number | null
team?: 'T' | 'CT' | string | null
phase?: 'projectile' | 'effect' | 'exploded'
headingRad?: number | null
spawnedAt?: number | null
ownerId?: string | null
effectTimeSec?: number
lifeElapsedMs?: number
lifeLeftMs?: number
}
type Mapper = (xw: number, yw: number) => { x: number; y: number }
type UIShape = {
nade: {
stroke: string
smokeFill: string
fireFill: string
heFill: string
flashFill: string
decoyFill: string
teamStrokeCT: string
teamStrokeT: string
minRadiusPx: number
},
player: {
bombStroke: string
}
}
function seedRng(seed: string) {
let h = 2166136261 >>> 0;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return () => {
h += 0x6D2B79F5; h = Math.imul(h ^ (h >>> 15), h | 1);
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
};
}
export default function StaticEffects({
grenades,
bomb,
worldToPx,
unitsToPx,
ui,
beepState,
}: {
grenades: Grenade[]
bomb: BombState | null
worldToPx: Mapper
unitsToPx: (u:number)=>number
ui: UIShape
beepState: { key: number; dur: number } | null
}) {
const smokeNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
// Lebenszeiten robust bestimmen
const DEFAULT_LIFE = 18_000
const leftMs = (typeof g.lifeLeftMs === 'number')
? Math.max(0, g.lifeLeftMs)
: (g.expiresAt ? Math.max(0, g.expiresAt - Date.now()) : null)
const elapsedMs = (typeof g.lifeElapsedMs === 'number')
? Math.max(0, g.lifeElapsedMs)
: (typeof g.effectTimeSec === 'number' ? Math.max(0, g.effectTimeSec * 1000) : null)
const totalMs = (leftMs != null && elapsedMs != null)
? Math.max(1500, leftMs + elapsedMs) // nie zu kurz
: DEFAULT_LIFE
const pRaw = (elapsedMs != null) ? (elapsedMs / totalMs)
: (leftMs != null) ? (1 - Math.min(1, leftMs / totalMs))
: 0
const p = Math.max(0, Math.min(1, pRaw)) // clamp
// Easing
const easeOutCubic = (t:number) => 1 - Math.pow(1 - t, 3)
const easeInQuad = (t:number) => t * t
// Phasen: früh reinzoomen, spät zusammenschrumpfen
const ZOOM_IN_MS = 600; // Dauer fürs Reinzoomen
const COLLAPSE_MS = 800; // Dauer fürs Zusammenfallen
const SCALE_IN_START = 0.68;
const SCALE_OUT_END = 0.65;
let scale = 1;
let phaseAlpha = 1;
if (elapsedMs != null && elapsedMs < ZOOM_IN_MS) {
// frühe Phase: innen -> außen
const t = Math.max(0, Math.min(1, elapsedMs / ZOOM_IN_MS));
const e = easeOutCubic(t);
scale = SCALE_IN_START + (1 - SCALE_IN_START) * e;
phaseAlpha = e;
} else if (leftMs != null && leftMs <= COLLAPSE_MS) {
// Endphase: außen -> innen
const t = Math.max(0, Math.min(1, 1 - (leftMs / COLLAPSE_MS)));
const e = easeInQuad(t);
scale = 1 - (1 - SCALE_OUT_END) * e;
phaseAlpha = 1 - e;
}
// bisherige sanfte Gesamt-Opacity (verbleibende Lebenszeit)
const lifeMs = totalMs
const fracLeft = leftMs == null ? 1 : Math.min(1, leftMs / lifeMs)
const baseOpacity = 0.75 + 0.25 * (1 - (1 - fracLeft)) // 0.75..1.0
const overallOpacity = baseOpacity * phaseAlpha
// Farbe (oder nimm ui.nade.smokeFill)
const fill = '#9bd4ff'
// kompaktere Form + runder oben (wie bei dir zuletzt)
const R = rPx * 0.78
const lobes = [
{ x: -R * 0.68, y: R * 0.14, r: R * 0.58, anim: '' },
{ x: 0, y: R * 0.06, r: R * 0.78, anim: '' },
{ x: R * 0.68, y: R * 0.14, r: R * 0.58, anim: '' },
{ x: -R * 0.36, y: -R * 0.24, r: R * 0.48, anim: 'A' },
{ x: 0, y: -R * 0.28, r: R * 0.52, anim: 'B' },
{ x: R * 0.36, y: -R * 0.24, r: R * 0.48, anim: 'C' },
{ x: 0, y: -R * 0.44, r: R * 0.36, anim: 'A' },
]
return (
<g
key={g.id}
transform={`translate(${P.x}, ${P.y}) scale(${scale})`} // <- Zoom über Zeit
opacity={overallOpacity}
style={{ transformBox: 'fill-box', transformOrigin: 'center' }}
>
{lobes.map((l, i) => (
<circle
key={i}
cx={l.x}
cy={l.y}
r={l.r}
fill={fill}
style={
l.anim
? {
transformBox: 'fill-box',
transformOrigin: 'center',
animation:
l.anim === 'A' ? 'smokeLobeFloatA 2.8s ease-in-out infinite' :
l.anim === 'B' ? 'smokeLobeFloatB 3.1s ease-in-out infinite' :
'smokeLobeFloatC 2.9s ease-in-out infinite'
}
: undefined
}
/>
))}
</g>
)
}
const molotovNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
// 1.0 = wie jetzt. 1.3..1.8 = größerer Radius
const FIRE_RADIUS_MULT = 2
const stroke = g.team === 'CT' ? ui.nade.teamStrokeCT : g.team === 'T' ? ui.nade.teamStrokeT : ui.nade.stroke
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
// Größen analog zum CodePen (Container ~10em breit, Rise ~10em)
const coverPx = rPx * FIRE_RADIUS_MULT // „Radius“ der Fläche
const W = Math.max(26, coverPx * 1.45) // Breite des Feuers
const H = W * 1.20 // Höhe der Säule
const RISE = H * (10/12) // Aufstiegsweg der Partikel
// Partikel (wie im Pen: 50 Stück, identische Größe)
const PARTS = Math.min(70, Math.round(50 * FIRE_RADIUS_MULT)) // Dichte mitwachsen lassen
const DUR_MS = 1000
const PART_SIZE = W * 0.50
const rnd = seedRng(`${g.id}-${g.spawnedAt ?? 0}`)
// Defs für Verlauf & Blur
const gradPartId = `molo-fire-grad-${g.id}`
const blurPartId = `molo-fire-blur-${g.id}`
return (
<g key={g.id}>
<defs>
<radialGradient id={gradPartId} cx="50%" cy="50%" r="50%">
<stop offset="20%" stopColor="rgb(255,80,0)" stopOpacity="1" />
<stop offset="70%" stopColor="rgb(255,80,0)" stopOpacity="0" />
</radialGradient>
<filter id={blurPartId} x="-50%" y="-50%" width="200%" height="200%">
{/* entspricht blur(0.02em) */}
<feGaussianBlur stdDeviation={Math.max(0.6, W * 0.02)} />
</filter>
</defs>
{/* Partikel exakt bei P, über die Breite verteilt */}
<g transform={`translate(${P.x}, ${P.y})`} style={{ ['--rise' as any]: `${Math.round(RISE)}px` }}>
{Array.from({ length: PARTS }).map((_, i) => {
// „left: calc((100% - partSize) * (i/parts))“
const leftX = (i / PARTS) * (W - PART_SIZE) - (W/2 - PART_SIZE/2)
return (
<g key={i} transform={`translate(${leftX}, 0)`}>
<circle
cx={0}
cy={0}
r={PART_SIZE / 2}
fill={`url(#${gradPartId})`}
filter={`url(#${blurPartId})`}
style={{
mixBlendMode: 'screen' as any,
animation: `molotovFireRise ${DUR_MS}ms ease-in infinite`,
animationDelay: `${Math.round(DUR_MS * rnd())}ms`,
transformBox: 'fill-box',
transformOrigin: 'center',
opacity: 0
}}
/>
</g>
)
})}
</g>
</g>
)
}
const decoyNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
const stroke = g.team === 'CT' ? ui.nade.teamStrokeCT : g.team === 'T' ? ui.nade.teamStrokeT : ui.nade.stroke
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
return (
<circle
key={g.id}
cx={P.x}
cy={P.y}
r={rPx}
fill={ui.nade.decoyFill}
stroke={stroke}
strokeWidth={sw}
strokeDasharray="6,4"
/>
)
}
const flashNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
const stroke = g.team === 'CT' ? ui.nade.teamStrokeCT : g.team === 'T' ? ui.nade.teamStrokeT : ui.nade.stroke
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
return (
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
<circle cx={P.x} cy={P.y} r={Math.max(2, rPx*0.25)} fill={ui.nade.flashFill} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
<line x1={P.x-rPx*0.9} y1={P.y} x2={P.x+rPx*0.9} y2={P.y} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
<line x1={P.x} y1={P.y-rPx*0.9} x2={P.x} y2={P.y+rPx*0.9} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
</g>
)
}
const bombNode = (b: BombState) => {
const show = b.status === 'planted' || b.status === 'defusing' || b.status === 'defused'
if (!show) return null
const P = worldToPx(b.x, b.y)
const rBase = Math.max(10, unitsToPx(28))
const iconSize = Math.max(24, rBase * 1.8)
const isActive = b.status === 'planted' || b.status === 'defusing'
const isDefused = b.status === 'defused'
const iconColor = b.status === 'planted' ? '#ef4444' : (isDefused ? '#10b981' : '#e5e7eb')
const maskId = `bomb-mask-${b.changedAt}`
return (
<g key={`bomb-${b.changedAt}`}>
{isActive && beepState && (
<g key={`beep-${beepState.key}`}>
<circle
cx={P.x} cy={P.y} r={rBase}
fill="none"
stroke={isDefused ? '#10b981' : '#ef4444'}
strokeWidth={3}
style={{ transformBox: 'fill-box', transformOrigin: 'center', animation: `bombPing ${beepState.dur}ms linear 1` }}
/>
</g>
)}
<circle cx={P.x} cy={P.y} r={rBase} fill="#111" opacity="0.15" />
<defs>
<mask id={maskId}>
<image
href="/assets/img/icons/ui/bomb_c4.svg"
x={P.x - iconSize/2}
y={P.y - iconSize/2}
width={iconSize}
height={iconSize}
preserveAspectRatio="xMidYMid meet"
/>
</mask>
</defs>
<rect
x={P.x - iconSize/2}
y={P.y - iconSize/2}
width={iconSize}
height={iconSize}
fill={iconColor}
mask={`url(#${maskId})`}
/>
</g>
)
}
// nur die gewünschten statischen Effekte zeichnen
const nodes = grenades
.filter(g => g.phase === 'effect' && (g.kind === 'smoke' || g.kind === 'molotov' || g.kind === 'incendiary' || g.kind === 'decoy' || g.kind === 'flash'))
.map(g => {
const P = worldToPx(g.x, g.y)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
if (g.kind === 'smoke') return smokeNode(g)
if (g.kind === 'molotov' || g.kind === 'incendiary') return molotovNode(g)
if (g.kind === 'decoy') return decoyNode(g)
if (g.kind === 'flash') return flashNode(g)
return null
})
return (
<>
{/* statische Grenade-Effekte */}
{nodes}
{/* Bombe */}
{bomb ? bombNode(bomb) : null}
{/* lokale Keyframes für die Flammen/Glut */}
<style jsx global>{`
@keyframes smokeLobeFloatA {
0% { transform: translateY(0px) scale(1); }
50% { transform: translateY(-2px) scale(1.02); }
100% { transform: translateY(0px) scale(1); }
}
@keyframes smokeLobeFloatB {
0% { transform: translateY(-1px) scale(1.01); }
50% { transform: translateY(-3px) scale(1.03); }
100% { transform: translateY(-1px) scale(1.01); }
}
@keyframes smokeLobeFloatC {
0% { transform: translateY(0px) scale(1); }
50% { transform: translateY(-2px) scale(1.02); }
100% { transform: translateY(0px) scale(1); }
}
@keyframes molotovFireRise {
0% { opacity: 0; transform: translateY(0) scale(1); }
25% { opacity: 1; }
100% { opacity: 0; transform: translateY(calc(var(--rise, 80px) * -1)) scale(0); }
}
`}</style>
</>
)
}

View File

@ -0,0 +1,8 @@
// helper, z.B. /src/app/lib/connectHref.ts
export function buildConnectHref(serverIp: string, password?: string | null, port = 27015) {
const pass = (password ?? '').trim()
// Format wie bisher in deinem Overlay verwendet:
return pass
? `steam://connect/${serverIp}:${port}/${pass}`
: `steam://connect/${serverIp}:${port}`
}

View File

@ -0,0 +1,29 @@
// /app/lib/useMatchRosterStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type RosterState = {
roster: Set<string>
setRoster: (ids: Iterable<string>) => void
clearRoster: () => void
}
export const useMatchRosterStore = create<RosterState>()(
persist(
(set) => ({
roster: new Set(),
setRoster: (ids) => set({ roster: new Set(ids) }),
clearRoster: () => set({ roster: new Set() }),
}),
{
name: 'last-export-roster', // landet in localStorage
partialize: (s) => ({ roster: Array.from(s.roster) as any }),
onRehydrateStorage: () => (state) => {
// Arrays zurück in Set umwandeln
if (state && Array.isArray((state as any).roster)) {
state.roster = new Set((state as any).roster)
}
}
}
)
)

View File

@ -1,40 +1,29 @@
// /src/app/lib/useReadyOverlayStore.ts // /src/app/lib/useReadyOverlayStore.ts
'use client'
import { create } from 'zustand' import { create } from 'zustand'
type ReadyOverlayData = { export type ReadyOverlayData = {
matchId: string matchId: string
mapLabel: string mapLabel: string
mapBg: string mapBg: string
nextHref?: string // Zielroute nach "ACCEPT" nextHref?: string
connectHref?: string // ⬅️ neu
} }
type State = { type ReadyOverlayState = {
open: boolean open: boolean
data: ReadyOverlayData | null data: ReadyOverlayData | null
showAt?: number | null show: (data: ReadyOverlayData) => void
showWithDelay: (data: ReadyOverlayData, delayMs: number) => void showWithDelay: (data: ReadyOverlayData, delayMs?: number) => void
hide: () => void hide: () => void
} }
export const useReadyOverlayStore = create<State>((set) => ({ export const useReadyOverlayStore = create<ReadyOverlayState>((set) => ({
open: false, open: false,
data: null, data: null,
showAt: null, show: (data) => set({ open: true, data }),
showWithDelay: (data, delayMs) => { showWithDelay: (data, delayMs = 0) => {
const showAt = Date.now() + Math.max(0, delayMs) if (delayMs <= 0) return set({ open: true, data })
set({ data, showAt }) setTimeout(() => set({ open: true, data }), delayMs)
const step = () => {
const t = Date.now()
if (t >= showAt) {
set({ open: true, showAt: null })
} else {
requestAnimationFrame(step)
}
}
requestAnimationFrame(step)
}, },
hide: () => set({ open: false, data: null, showAt: null }), hide: () => set({ open: false, data: null }),
})) }))

View File

@ -1,13 +1,26 @@
// /src/app/lib/useTelemetryStore.ts // /app/lib/useTelemetryStore.ts
'use client'
import { create } from 'zustand' import { create } from 'zustand'
type State = { type TelemetryState = {
mapKey: string | null mapKey: string | null
setMapKey: (k: string | null) => void setMapKey: (k: string|null) => void
phase: 'unknown'|'warmup'|'freezetime'|'live'|'bomb'|'over'
setPhase: (p: TelemetryState['phase']) => void
rosterSteamIds: Set<string>
setRosterSteamIds: (ids: string[]) => void
clearRoster: () => void
} }
export const useTelemetryStore = create<State>((set) => ({ export const useTelemetryStore = create<TelemetryState>((set) => ({
mapKey: null, mapKey: null,
setMapKey: (k) => set({ mapKey: k }), setMapKey: (k) => set({ mapKey: k }),
phase: 'unknown',
setPhase: (p) => set({ phase: p }),
rosterSteamIds: new Set<string>(),
setRosterSteamIds: (ids) => set({ rosterSteamIds: new Set(ids ?? []) }),
clearRoster: () => set({ rosterSteamIds: new Set() }),
})) }))

View File

@ -9,6 +9,8 @@ import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
import { useTelemetryStore } from '@/app/lib/useTelemetryStore' import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
import StatusDot from '../components/StatusDot' import StatusDot from '../components/StatusDot'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import StaticEffects from '../components/radar/StaticEffects'
/* ───────── UI config ───────── */ /* ───────── UI config ───────── */
const UI = { const UI = {
@ -776,7 +778,9 @@ export default function LiveRadar() {
} }
} else { } else {
// Effekte dürfen positionsbasiert sein // Effekte dürfen positionsbasiert sein
id = `${kind}#${Math.round(x)}:${Math.round(y)}:${Math.round(z)}:${phase}`; const Q = 64; // 64 Welt-Units Raster; 48..96 funktioniert auch gut
const qx = Math.round(x / Q), qy = Math.round(y / Q);
id = `${kind}@${qx}:${qy}:${ownerId ?? 'u'}`; // stabil trotz kleinem Drift
} }
// Smoke-spezifische Zusatzwerte (mit 19s Default) // Smoke-spezifische Zusatzwerte (mit 19s Default)
@ -941,12 +945,13 @@ export default function LiveRadar() {
...prev, ...prev,
...it, ...it,
spawnedAt: prev?.spawnedAt ?? it.spawnedAt ?? now, spawnedAt: prev?.spawnedAt ?? it.spawnedAt ?? now,
headingRad: (it.headingRad ?? prev?.headingRad ?? null),
_lastSeen: now, _lastSeen: now,
// _cacheKey kommt von normalizeGrenades (nur Projektile)
_cacheKey: (it as any)._cacheKey ?? (prev as any)?._cacheKey
}; };
next.set(it.id, merged); // Effekt-/Explosions-Lebensdauer nie „verlängern“
if (it.phase === 'effect' || it.phase === 'exploded') {
merged.expiresAt = (prev?.expiresAt ?? it.expiresAt) ?? null;
}
next.set(merged.id, merged);
} }
// Cleanup: Effekte nach Ablauf; Projektile nach Schonfrist (+ Cache leeren) // Cleanup: Effekte nach Ablauf; Projektile nach Schonfrist (+ Cache leeren)
@ -956,12 +961,15 @@ export default function LiveRadar() {
if (nade.phase === 'effect' || nade.phase === 'exploded') { if (nade.phase === 'effect' || nade.phase === 'exploded') {
const left = (typeof nade.lifeLeftMs === 'number') const left = (typeof nade.lifeLeftMs === 'number')
? nade.lifeLeftMs ? nade.lifeLeftMs
: (typeof nade.expiresAt === 'number' ? (nade.expiresAt - Date.now()) : null); : (typeof nade.expiresAt === 'number' ? (nade.expiresAt - now) : null);
if (left != null && left <= 0) {
// Fallback: harte TTL von 8s, falls left==null
const age = now - (nade.spawnedAt ?? now);
if ((left != null && left <= 0) || (left == null && age > 8000)) {
next.delete(id); next.delete(id);
}
continue; continue;
} }
}
if (nade.phase === 'projectile') { if (nade.phase === 'projectile') {
if (!seenIds.has(id)) { if (!seenIds.has(id)) {
@ -1433,34 +1441,6 @@ export default function LiveRadar() {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
> >
<defs>
<radialGradient id="smokeRadial" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#B9B9B9" stopOpacity="0.70" />
<stop offset="65%" stopColor="#A0A0A0" stopOpacity="0.35" />
<stop offset="100%" stopColor="#A0A0A0" stopOpacity="0.00" />
</radialGradient>
<linearGradient id="flameGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fff59d"/>
<stop offset="45%" stopColor="#ffd54f"/>
<stop offset="100%" stopColor="#ff7043"/>
</linearGradient>
<symbol id="flameIcon" viewBox="0 0 64 64">
{/* äußere Flamme */}
<path
d="M32 4c6 11-4 14 2 23 3 4 10 7 10 16 0 10-8 17-18 17S8 53 8 43c0-8 5-13 9-17 6-6 8-10 15-22z"
fill="url(#flameGrad)"
/>
{/* innerer, heller Kern */}
<path
d="M33 20c3 6-2 8 1 12 2 2 6 3 6 8 0 5-4 9-10 9s-10-4-10-9c0-4 3-7 5-9 3-3 4-5 8-11z"
fill="#ffffff66"
/>
</symbol>
</defs>
{/* Trails */} {/* Trails */}
{trails.map(tr => { {trails.map(tr => {
const pts = tr.pts.map(p => { const pts = tr.pts.map(p => {
@ -1481,10 +1461,18 @@ export default function LiveRadar() {
) )
})} })}
{/* Grenades: Projectiles + Effekte */} {/* 🔽 NEU: statische Effekte (Smoke/Molotov/Incendiary/Decoy/Flash) + Bombe */}
{grenades <StaticEffects
//.filter(shouldShowGrenade) grenades={grenades}
.map((g) => { bomb={bomb}
worldToPx={worldToPx}
unitsToPx={unitsToPx}
ui={{ nade: UI.nade, player: { bombStroke: UI.player.bombStroke } }}
beepState={beepState}
/>
{/* Grenades: nur Projectiles + HE-Explosionen (statische Effekte macht <StaticEffects />) */}
{grenades.map((g) => {
const P = worldToPx(g.x, g.y) const P = worldToPx(g.x, g.y)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
@ -1496,9 +1484,9 @@ export default function LiveRadar() {
// 1) Projektil-Icon // 1) Projektil-Icon
if (g.phase === 'projectile') { if (g.phase === 'projectile') {
const size = Math.max(18, 22); // fix/klein, statt radius-basiert (optional) const size = Math.max(18, 22)
const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown; const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown
const rotDeg = Number.isFinite(g.headingRad as number) ? (g.headingRad! * 180 / Math.PI) : 0; const rotDeg = Number.isFinite(g.headingRad as number) ? (g.headingRad! * 180 / Math.PI) : 0
return ( return (
<g key={`nade-proj-${g.id}`} transform={`rotate(${rotDeg} ${P.x} ${P.y})`}> <g key={`nade-proj-${g.id}`} transform={`rotate(${rotDeg} ${P.x} ${P.y})`}>
<image <image
@ -1510,10 +1498,9 @@ export default function LiveRadar() {
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
/> />
</g> </g>
); )
} }
// 2) HE-Explosion // 2) HE-Explosion
if (g.kind === 'he' && g.phase === 'exploded') { if (g.kind === 'he' && g.phase === 'exploded') {
const base = Math.max(18, unitsToPx(22)) const base = Math.max(18, unitsToPx(22))
@ -1530,142 +1517,10 @@ export default function LiveRadar() {
) )
} }
// 3) Statische Effekte // statische Effekte (smoke/molotov/incendiary/decoy/flash) werden NICHT mehr hier gezeichnet
if (g.kind === 'smoke') { return null
const lifeMs = 18_000
const left = (typeof g.lifeLeftMs === 'number')
? Math.max(0, g.lifeLeftMs)
: (g.expiresAt ? Math.max(0, g.expiresAt - Date.now()) : null)
const frac = left == null ? 1 : Math.min(1, left / lifeMs)
// leichte Aufhellung/Abdunklung via Opacity-Multiplikator
const opacity = 0.35 + 0.45 * frac // 0.35 .. 0.80
return (
<circle
key={g.id}
cx={P.x}
cy={P.y}
r={rPx}
fill={UI.nade.smokeFill}
fillOpacity={opacity}
stroke={stroke}
strokeWidth={sw}
/>
)
}
if (g.kind === 'molotov' || g.kind === 'incendiary') {
const W = Math.max(28, rPx * 1.4);
const H = W * 1.25;
return (
<g key={g.id}>
{/* optionaler Team-Ring */}
<circle
cx={P.x}
cy={P.y}
r={rPx}
fill="none"
stroke={stroke}
strokeWidth={Math.max(1, sw * 0.8)}
strokeDasharray="6,5"
opacity="0.6"
/>
{/* WICHTIG: äußere Gruppe = nur Translate */}
<g transform={`translate(${P.x}, ${P.y})`}>
{/* innere Gruppe = nur Animation */}
<g className="flame-anim">
<use
href="#flameIcon"
x={-W / 2}
y={-H / 2}
width={W}
height={H}
preserveAspectRatio="xMidYMid meet"
opacity="0.95"
/>
</g>
</g>
</g>
);
}
if (g.kind === 'decoy') {
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
}
if (g.kind === 'flash') {
return (
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
<circle cx={P.x} cy={P.y} r={Math.max(2, rPx*0.25)} fill={UI.nade.flashFill} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
<line x1={P.x-rPx*0.9} y1={P.y} x2={P.x+rPx*0.9} y2={P.y} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
<line x1={P.x} y1={P.y-rPx*0.9} x2={P.x} y2={P.y+rPx*0.9} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
</g>
)
}
// Fallback
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill="#999" stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
})} })}
{/* Bombe */}
{bomb && (() => {
const showBomb = bomb.status === 'planted' || bomb.status === 'defusing' || bomb.status === 'defused'
if (!showBomb) return null
const P = worldToPx(bomb.x, bomb.y)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
const rBase = Math.max(10, unitsToPx(28))
const iconSize = Math.max(24, rBase * 1.8)
const isActive = bomb.status === 'planted' || bomb.status === 'defusing'
const isDefused = bomb.status === 'defused'
const iconColor = bomb.status === 'planted' ? '#ef4444' : (isDefused ? '#10b981' : '#e5e7eb')
const maskId = `bomb-mask-${bomb.changedAt}`
return (
<g key={`bomb-${bomb.changedAt}`}>
{isActive && beepState && (
<g key={`beep-${beepState.key}`}>
<circle
cx={P.x} cy={P.y} r={rBase}
fill="none"
stroke={isDefused ? '#10b981' : '#ef4444'}
strokeWidth={3}
style={{ transformBox: 'fill-box', transformOrigin: 'center', animation: `bombPing ${beepState.dur}ms linear 1` }}
/>
</g>
)}
<circle cx={P.x} cy={P.y} r={rBase} fill="#111" opacity="0.15" />
<defs>
<mask id={maskId}>
<image
href="/assets/img/icons/ui/bomb_c4.svg"
xlinkHref="/assets/img/icons/ui/bomb_c4.svg"
x={P.x - iconSize/2}
y={P.y - iconSize/2}
width={iconSize}
height={iconSize}
preserveAspectRatio="xMidYMid meet"
/>
</mask>
</defs>
<rect
x={P.x - iconSize/2}
y={P.y - iconSize/2}
width={iconSize}
height={iconSize}
fill={iconColor}
mask={`url(#${maskId})`}
/>
</g>
)
})()}
{/* Spieler */} {/* Spieler */}
{players {players
.filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false && (!myTeam || p.team === myTeam)) .filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false && (!myTeam || p.team === myTeam))

View File

@ -1,4 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! /* !!! This is code generated by Prisma. Do not edit directly. !!!
/* eslint-disable */ /* eslint-disable */
module.exports = { ...require('.') } module.exports = { ...require('#main-entry-point') }

File diff suppressed because one or more lines are too long

View File

@ -20,12 +20,12 @@ exports.Prisma = Prisma
exports.$Enums = {} exports.$Enums = {}
/** /**
* Prisma Client JS version: 6.15.0 * Prisma Client JS version: 6.16.1
* Query Engine version: 85179d7826409ee107a6ba334b5e305ae3fba9fb * Query Engine version: 1c57fdcd7e44b29b9313256c76699e91c3ac3c43
*/ */
Prisma.prismaVersion = { Prisma.prismaVersion = {
client: "6.15.0", client: "6.16.1",
engine: "85179d7826409ee107a6ba334b5e305ae3fba9fb" engine: "1c57fdcd7e44b29b9313256c76699e91c3ac3c43"
} }
Prisma.PrismaClientKnownRequestError = () => { Prisma.PrismaClientKnownRequestError = () => {
@ -133,7 +133,8 @@ exports.Prisma.UserScalarFieldEnum = {
lastKnownShareCodeDate: 'lastKnownShareCodeDate', lastKnownShareCodeDate: 'lastKnownShareCodeDate',
createdAt: 'createdAt', createdAt: 'createdAt',
status: 'status', status: 'status',
lastActiveAt: 'lastActiveAt' lastActiveAt: 'lastActiveAt',
pterodactylClientApiKey: 'pterodactylClientApiKey'
}; };
exports.Prisma.TeamScalarFieldEnum = { exports.Prisma.TeamScalarFieldEnum = {
@ -310,6 +311,16 @@ exports.Prisma.MatchReadyScalarFieldEnum = {
acceptedAt: 'acceptedAt' acceptedAt: 'acceptedAt'
}; };
exports.Prisma.ServerConfigScalarFieldEnum = {
id: 'id',
serverIp: 'serverIp',
serverPassword: 'serverPassword',
pterodactylServerId: 'pterodactylServerId',
pterodactylServerApiKey: 'pterodactylServerApiKey',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@ -369,7 +380,8 @@ exports.Prisma.ModelName = {
ServerRequest: 'ServerRequest', ServerRequest: 'ServerRequest',
MapVote: 'MapVote', MapVote: 'MapVote',
MapVoteStep: 'MapVoteStep', MapVoteStep: 'MapVoteStep',
MatchReady: 'MatchReady' MatchReady: 'MatchReady',
ServerConfig: 'ServerConfig'
}; };
/** /**

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,8 @@
{ {
"name": "prisma-client-2e5c1f8f0e3dd7b271c7986ac6cb6fd933eac0338be7033c8e86d6bde46642fd", "name": "prisma-client-85c440bbfe4ddbdbf4749495c6ef753c2d4a73eb44baae0e95774ed8e7b86d85",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "default.js",
"exports": { "exports": {
"./client": { "./client": {
"require": { "require": {
@ -125,6 +125,12 @@
"import": "./runtime/react-native.js", "import": "./runtime/react-native.js",
"default": "./runtime/react-native.js" "default": "./runtime/react-native.js"
}, },
"./runtime/index-browser": {
"types": "./runtime/index-browser.d.ts",
"require": "./runtime/index-browser.js",
"import": "./runtime/index-browser.mjs",
"default": "./runtime/index-browser.mjs"
},
"./generator-build": { "./generator-build": {
"require": "./generator-build/index.js", "require": "./generator-build/index.js",
"import": "./generator-build/index.js", "import": "./generator-build/index.js",
@ -145,6 +151,33 @@
}, },
"./*": "./*" "./*": "./*"
}, },
"version": "6.15.0", "version": "6.16.1",
"sideEffects": false "sideEffects": false,
"imports": {
"#wasm-engine-loader": {
"edge-light": "./wasm-edge-light-loader.mjs",
"workerd": "./wasm-worker-loader.mjs",
"worker": "./wasm-worker-loader.mjs",
"default": "./wasm-worker-loader.mjs"
},
"#main-entry-point": {
"require": {
"node": "./index.js",
"edge-light": "./wasm.js",
"workerd": "./wasm.js",
"worker": "./wasm.js",
"browser": "./index-browser.js",
"default": "./index.js"
},
"import": {
"node": "./index.js",
"edge-light": "./wasm.js",
"workerd": "./wasm.js",
"worker": "./wasm.js",
"browser": "./index-browser.js",
"default": "./index.js"
},
"default": "./index.js"
}
}
} }

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -50,6 +50,8 @@ model User {
lastActiveAt DateTime? // optional: wann zuletzt aktiv lastActiveAt DateTime? // optional: wann zuletzt aktiv
readyAcceptances MatchReady[] @relation("MatchReadyUser") readyAcceptances MatchReady[] @relation("MatchReadyUser")
pterodactylClientApiKey String?
} }
enum UserStatus { enum UserStatus {
@ -370,3 +372,17 @@ model MatchReady {
@@id([matchId, steamId]) @@id([matchId, steamId])
@@index([steamId]) @@index([steamId])
} }
// ──────────────────────────────────────────────
// 🛠️ Server-Konfiguration & Pterodactyl
// ──────────────────────────────────────────────
model ServerConfig {
id String @id
serverIp String
serverPassword String? // ⬅️ neu
pterodactylServerId String
pterodactylServerApiKey String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -0,0 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!!
/* eslint-disable */
export default import('./query_engine_bg.wasm?module')

View File

@ -0,0 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!!
/* eslint-disable */
export default import('./query_engine_bg.wasm')

View File

@ -1 +1 @@
export * from "./index" export * from "./default"

File diff suppressed because one or more lines are too long