updated
This commit is contained in:
parent
e693af798b
commit
237be94ebe
2
.env
2
.env
@ -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
72
package-lock.json
generated
@ -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"
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
88
src/app/admin/server/page.tsx
Normal file
88
src/app/admin/server/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/app/api/cs2/server/route.ts
Normal file
60
src/app/api/cs2/server/route.ts
Normal 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' } })
|
||||||
|
}
|
||||||
98
src/app/api/cs2/server/send-command/route.ts
Normal file
98
src/app/api/cs2/server/send-command/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 {
|
||||||
|
// 9–10-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) {
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
138
src/app/components/admin/server/ServerView.tsx
Normal file
138
src/app/components/admin/server/ServerView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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[] }
|
||||||
|
|||||||
394
src/app/components/radar/StaticEffects.tsx
Normal file
394
src/app/components/radar/StaticEffects.tsx
Normal 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>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/app/lib/connectHref.ts
Normal file
8
src/app/lib/connectHref.ts
Normal 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}`
|
||||||
|
}
|
||||||
29
src/app/lib/useMatchRosterStore.ts
Normal file
29
src/app/lib/useMatchRosterStore.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -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 }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -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() }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
@ -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'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
1393
src/generated/prisma/index.d.ts
vendored
1393
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp17192
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp17192
Normal file
Binary file not shown.
2
src/generated/prisma/query_engine_bg.js
Normal file
2
src/generated/prisma/query_engine_bg.js
Normal file
File diff suppressed because one or more lines are too long
BIN
src/generated/prisma/query_engine_bg.wasm
Normal file
BIN
src/generated/prisma/query_engine_bg.wasm
Normal file
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
56
src/generated/prisma/runtime/react-native.js
vendored
56
src/generated/prisma/runtime/react-native.js
vendored
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
@ -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
|
||||||
|
}
|
||||||
|
|||||||
4
src/generated/prisma/wasm-edge-light-loader.mjs
Normal file
4
src/generated/prisma/wasm-edge-light-loader.mjs
Normal 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')
|
||||||
4
src/generated/prisma/wasm-worker-loader.mjs
Normal file
4
src/generated/prisma/wasm-worker-loader.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||||
|
/* eslint-disable */
|
||||||
|
export default import('./query_engine_bg.wasm')
|
||||||
2
src/generated/prisma/wasm.d.ts
vendored
2
src/generated/prisma/wasm.d.ts
vendored
@ -1 +1 @@
|
|||||||
export * from "./index"
|
export * from "./default"
|
||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user