updated
This commit is contained in:
parent
9eaab19c5f
commit
5531a68da0
12
.env
12
.env
@ -20,12 +20,16 @@ PTERO_SERVER_SFTP_USER=army.37a11489
|
|||||||
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
||||||
PTERO_SERVER_ID=37a11489
|
PTERO_SERVER_ID=37a11489
|
||||||
|
|
||||||
# 🌍 Meta-WebSocket (CS2 Server Plugin)
|
# META (vom CS2-Server-Plugin)
|
||||||
NEXT_PUBLIC_CS2_META_WS_HOST=cs2.ironieopen.de
|
NEXT_PUBLIC_CS2_META_WS_HOST=ironieopen.local
|
||||||
NEXT_PUBLIC_CS2_META_WS_PORT=443
|
NEXT_PUBLIC_CS2_META_WS_PORT=443
|
||||||
NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry
|
NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry
|
||||||
|
NEXT_PUBLIC_CS2_META_WS_SCHEME=wss
|
||||||
|
|
||||||
# 🖥️ Positionen / GSI-WebSocket (lokaler Aggregator)
|
# POS (lokaler Aggregator)
|
||||||
NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local
|
NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local
|
||||||
NEXT_PUBLIC_CS2_POS_WS_PORT=8082
|
NEXT_PUBLIC_CS2_POS_WS_PORT=443
|
||||||
NEXT_PUBLIC_CS2_POS_WS_PATH=/positions
|
NEXT_PUBLIC_CS2_POS_WS_PATH=/positions
|
||||||
|
NEXT_PUBLIC_CS2_POS_WS_SCHEME=wss
|
||||||
|
|
||||||
|
NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000"
|
||||||
|
|||||||
@ -63,7 +63,7 @@ function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
|
|||||||
{ order: 3, action: 'PICK', teamId: teamBId },
|
{ order: 3, action: 'PICK', teamId: teamBId },
|
||||||
{ order: 4, action: 'PICK', teamId: teamAId },
|
{ order: 4, action: 'PICK', teamId: teamAId },
|
||||||
{ order: 5, action: 'PICK', teamId: teamBId },
|
{ order: 5, action: 'PICK', teamId: teamBId },
|
||||||
{ order: 6, action: 'PICK', teamId: teamAId },
|
{ order: 6, action: 'DECIDER', teamId: null },
|
||||||
] as const
|
] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
// /app/api/matches/[matchId]/meta/route.ts
|
|
||||||
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/app/lib/auth'
|
import { authOptions } from '@/app/lib/auth'
|
||||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
|
||||||
|
import { MapVoteAction } from '@/generated/prisma'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@ -50,6 +50,46 @@ function voteOpensAt(base: Date, leadMinutes: number) {
|
|||||||
return new Date(base.getTime() - leadMinutes * 60_000)
|
return new Date(base.getTime() - leadMinutes * 60_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Steps-Builder für BO1/BO3/BO5
|
||||||
|
function buildSteps(bestOf: 1 | 3 | 5, firstTeamId: string | null, secondTeamId: string | null) {
|
||||||
|
const A = firstTeamId
|
||||||
|
const B = secondTeamId
|
||||||
|
if (bestOf === 1) {
|
||||||
|
// Klassischer BO1: 6x Ban (A/B abwechselnd), danach Decider
|
||||||
|
return [
|
||||||
|
{ order: 0, action: 'BAN', teamId: A },
|
||||||
|
{ order: 1, action: 'BAN', teamId: B },
|
||||||
|
{ order: 2, action: 'BAN', teamId: A },
|
||||||
|
{ order: 3, action: 'BAN', teamId: B },
|
||||||
|
{ order: 4, action: 'BAN', teamId: A },
|
||||||
|
{ order: 5, action: 'BAN', teamId: B },
|
||||||
|
{ order: 6, action: 'DECIDER', teamId: null },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
if (bestOf === 3) {
|
||||||
|
// 2x Ban, 2x Pick, 2x Ban, Decider
|
||||||
|
return [
|
||||||
|
{ order: 0, action: 'BAN', teamId: A },
|
||||||
|
{ order: 1, action: 'BAN', teamId: B },
|
||||||
|
{ order: 2, action: 'PICK', teamId: A },
|
||||||
|
{ order: 3, action: 'PICK', teamId: B },
|
||||||
|
{ order: 4, action: 'BAN', teamId: A },
|
||||||
|
{ order: 5, action: 'BAN', teamId: B },
|
||||||
|
{ order: 6, action: 'DECIDER', teamId: null },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
// BO5: 2x Ban, dann 5 Picks (kein Decider)
|
||||||
|
return [
|
||||||
|
{ order: 0, action: 'BAN', teamId: A },
|
||||||
|
{ order: 1, action: 'BAN', teamId: B },
|
||||||
|
{ order: 2, action: 'PICK', teamId: A },
|
||||||
|
{ order: 3, action: 'PICK', teamId: B },
|
||||||
|
{ order: 4, action: 'PICK', teamId: A },
|
||||||
|
{ order: 5, action: 'PICK', teamId: B },
|
||||||
|
{ order: 6, action: 'PICK', teamId: A },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { matchId: string } }
|
{ params }: { params: { matchId: string } }
|
||||||
@ -71,15 +111,20 @@ export async function PUT(
|
|||||||
map,
|
map,
|
||||||
voteLeadMinutes, // optional
|
voteLeadMinutes, // optional
|
||||||
demoDate,
|
demoDate,
|
||||||
|
bestOf: bestOfRaw, // <- NEU
|
||||||
} = body ?? {}
|
} = body ?? {}
|
||||||
|
|
||||||
|
// BestOf validieren (nur 1/3/5 zulassen)
|
||||||
|
const bestOf =
|
||||||
|
[1, 3, 5].includes(Number(bestOfRaw)) ? (Number(bestOfRaw) as 1 | 3 | 5) : undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const match = await prisma.match.findUnique({
|
const match = await prisma.match.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
teamA: { include: { leader: true } },
|
teamA: { include: { leader: true } },
|
||||||
teamB: { include: { leader: true } },
|
teamB: { include: { leader: true } },
|
||||||
mapVote: true,
|
mapVote: { include: { steps: true } }, // <- Steps laden
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||||
@ -98,6 +143,7 @@ export async function PUT(
|
|||||||
if (typeof map !== 'undefined') updateData.map = map
|
if (typeof map !== 'undefined') updateData.map = map
|
||||||
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
|
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
|
||||||
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
|
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
|
||||||
|
if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten
|
||||||
|
|
||||||
const parsedMatchDate = parseDateOrNull(matchDate)
|
const parsedMatchDate = parseDateOrNull(matchDate)
|
||||||
if (parsedMatchDate !== undefined) updateData.matchDate = parsedMatchDate
|
if (parsedMatchDate !== undefined) updateData.matchDate = parsedMatchDate
|
||||||
@ -106,7 +152,6 @@ export async function PUT(
|
|||||||
if (parsedDemoDate !== undefined) {
|
if (parsedDemoDate !== undefined) {
|
||||||
updateData.demoDate = parsedDemoDate
|
updateData.demoDate = parsedDemoDate
|
||||||
} else if (parsedMatchDate instanceof Date) {
|
} else if (parsedMatchDate instanceof Date) {
|
||||||
// demoDate mitschieben, wenn matchDate geändert und demoDate nicht gesendet wurde
|
|
||||||
updateData.demoDate = parsedMatchDate
|
updateData.demoDate = parsedMatchDate
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,22 +167,29 @@ export async function PUT(
|
|||||||
(match.matchDate ?? null) ??
|
(match.matchDate ?? null) ??
|
||||||
(match.demoDate ?? null)
|
(match.demoDate ?? null)
|
||||||
|
|
||||||
// 4) Updaten & opensAt ggf. neu setzen
|
// 4) Updaten & ggf. MapVote anlegen/aktualisieren + Reset bei BestOf-Änderung
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
const m = await tx.match.update({
|
const m = await tx.match.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: { mapVote: true },
|
include: { mapVote: { include: { steps: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// MapVote-Zeit/Lead pflegen (opensAt immer aus Basiszeit+Lead)
|
||||||
if (baseDate) {
|
if (baseDate) {
|
||||||
const opensAt = voteOpensAt(baseDate, leadMinutes)
|
const opensAt = voteOpensAt(baseDate, leadMinutes)
|
||||||
if (!m.mapVote) {
|
if (!m.mapVote) {
|
||||||
|
// Neu anlegen
|
||||||
|
const mapPool = MAP_OPTIONS.filter(o => o.active).map(o => o.key)
|
||||||
await tx.mapVote.create({
|
await tx.mapVote.create({
|
||||||
data: {
|
data: {
|
||||||
matchId: m.id,
|
matchId: m.id,
|
||||||
leadMinutes,
|
bestOf : (m.bestOf as 1|3|5) ?? 3,
|
||||||
|
mapPool,
|
||||||
|
currentIdx: 0,
|
||||||
|
locked: false,
|
||||||
opensAt,
|
opensAt,
|
||||||
|
leadMinutes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@ -150,26 +202,103 @@ export async function PUT(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (leadBody !== undefined && m.mapVote) {
|
} else if (leadBody !== undefined && m.mapVote) {
|
||||||
// Nur Lead geändert
|
|
||||||
await tx.mapVote.update({
|
await tx.mapVote.update({
|
||||||
where: { id: m.mapVote.id },
|
where: { id: m.mapVote.id },
|
||||||
data: { leadMinutes },
|
data: { leadMinutes },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Reset, WENN bestOf übergeben wurde und sich etwas ändert ---
|
||||||
|
if (typeof bestOf !== 'undefined') {
|
||||||
|
const vote = await tx.mapVote.findUnique({
|
||||||
|
where: { matchId: m.id },
|
||||||
|
include: { steps: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wenn noch kein MapVote existiert, jetzt direkt mit Steps anlegen
|
||||||
|
if (!vote) {
|
||||||
|
const mapPool = MAP_OPTIONS.filter(o => o.active).map(o => o.key)
|
||||||
|
const opensAt = baseDate ? voteOpensAt(baseDate, leadMinutes) : null
|
||||||
|
const firstTeamId =
|
||||||
|
m.teamAId ?? null // Startheuristik: TeamA beginnt (kannst du auch randomisieren)
|
||||||
|
const secondTeamId = firstTeamId === m.teamAId ? m.teamBId ?? null : m.teamAId ?? null
|
||||||
|
const def = buildSteps(bestOf, firstTeamId, secondTeamId)
|
||||||
|
|
||||||
|
await tx.mapVote.create({
|
||||||
|
data: {
|
||||||
|
matchId : m.id,
|
||||||
|
bestOf : bestOf,
|
||||||
|
mapPool,
|
||||||
|
currentIdx: 0,
|
||||||
|
locked : false,
|
||||||
|
opensAt : opensAt ?? undefined,
|
||||||
|
leadMinutes,
|
||||||
|
steps : {
|
||||||
|
create: def.map(s => ({
|
||||||
|
order : s.order,
|
||||||
|
action: s.action as MapVoteAction,
|
||||||
|
teamId: s.teamId ?? undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Prüfen: nur wenn tatsächlich abweicht → Reset
|
||||||
|
const differs = vote.bestOf !== bestOf
|
||||||
|
if (differs) {
|
||||||
|
const opensAt = baseDate ? voteOpensAt(baseDate, leadMinutes) : vote.opensAt ?? null
|
||||||
|
|
||||||
|
// "Erstes Team" für neuen Ablauf bestimmen:
|
||||||
|
const firstTeamId =
|
||||||
|
[...vote.steps].sort((a, b) => a.order - b.order)[0]?.teamId ??
|
||||||
|
m.teamAId ?? null
|
||||||
|
const secondTeamId = firstTeamId === m.teamAId ? m.teamBId ?? null : m.teamAId ?? null
|
||||||
|
|
||||||
|
// Alte Steps weg + neue Steps anlegen
|
||||||
|
await tx.mapVoteStep.deleteMany({ where: { voteId: vote.id } })
|
||||||
|
|
||||||
|
const def = buildSteps(bestOf, firstTeamId, secondTeamId)
|
||||||
|
await tx.mapVote.update({
|
||||||
|
where: { id: vote.id },
|
||||||
|
data: {
|
||||||
|
bestOf,
|
||||||
|
currentIdx: 0,
|
||||||
|
locked: false,
|
||||||
|
adminEditingBy: null,
|
||||||
|
adminEditingSince: null,
|
||||||
|
...(opensAt ? { opensAt } : {}),
|
||||||
|
steps: {
|
||||||
|
create: def.map(s => ({
|
||||||
|
order : s.order,
|
||||||
|
action: s.action as MapVoteAction,
|
||||||
|
teamId: s.teamId ?? undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// SSE: dediziertes Reset-Event
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'map-vote-reset',
|
||||||
|
payload: { matchId: m.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return tx.match.findUnique({
|
return tx.match.findUnique({
|
||||||
where: { id },
|
where: { id: m.id },
|
||||||
include: {
|
include: {
|
||||||
teamA: { include: { leader: true } },
|
teamA: { include: { leader: true } },
|
||||||
teamB: { include: { leader: true } },
|
teamB: { include: { leader: true } },
|
||||||
mapVote: true,
|
mapVote: { include: { steps: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
|
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
|
||||||
|
|
||||||
// Immer map-vote-updated senden, wenn es einen MapVote gibt
|
// Immer map-vote-updated senden, wenn es einen MapVote gibt (Zeit/Lead)
|
||||||
if (updated.mapVote) {
|
if (updated.mapVote) {
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'map-vote-updated',
|
type: 'map-vote-updated',
|
||||||
@ -181,7 +310,6 @@ export async function PUT(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) Response
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
@ -191,6 +319,7 @@ export async function PUT(
|
|||||||
matchDate: updated.matchDate,
|
matchDate: updated.matchDate,
|
||||||
demoDate: updated.demoDate,
|
demoDate: updated.demoDate,
|
||||||
map: updated.map,
|
map: updated.map,
|
||||||
|
bestOf: updated.bestOf,
|
||||||
mapVote: updated.mapVote,
|
mapVote: updated.mapVote,
|
||||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -145,26 +145,60 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
return () => { cancelled = true; clearTimeout(t) }
|
return () => { cancelled = true; clearTimeout(t) }
|
||||||
}, [lastEvent, loadMatches])
|
}, [lastEvent, loadMatches])
|
||||||
|
|
||||||
// Teams laden, wenn Modal aufgeht
|
// Teams laden, wenn Modal aufgeht (robust gegen verschiedene Response-Shapes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCreate || teams.length) return
|
if (!showCreate) return
|
||||||
|
|
||||||
|
let ignore = false
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
setLoadingTeams(true)
|
setLoadingTeams(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/teams', { cache: 'no-store' })
|
const res = await fetch('/api/teams', {
|
||||||
const json = await res.json()
|
cache: 'no-store',
|
||||||
const opts: TeamOption[] = (json.teams ?? []).map((t: any) => ({
|
credentials: 'same-origin', // wichtig: Cookies mitnehmen
|
||||||
id: t.id, name: t.name, logo: t.logo,
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
const json = await res.json().catch(() => ({} as any))
|
||||||
|
|
||||||
|
// ➜ egal ob {teams: [...]}, {data: [...]}, {items: [...]} oder direkt [...]
|
||||||
|
const raw =
|
||||||
|
Array.isArray(json?.teams) ? json.teams :
|
||||||
|
Array.isArray(json?.data) ? json.data :
|
||||||
|
Array.isArray(json?.items) ? json.items :
|
||||||
|
Array.isArray(json) ? json :
|
||||||
|
[]
|
||||||
|
|
||||||
|
const opts: TeamOption[] = raw
|
||||||
|
.map((t: any) => ({
|
||||||
|
id: t.id ?? t._id ?? t.teamId ?? t.uuid ?? '',
|
||||||
|
name: t.name ?? t.title ?? t.displayName ?? t.tag ?? 'Unbenanntes Team',
|
||||||
|
logo: t.logo ?? t.logoUrl ?? t.image ?? null,
|
||||||
}))
|
}))
|
||||||
setTeams(opts)
|
.filter((t: TeamOption) => !!t.id && !!t.name)
|
||||||
|
|
||||||
|
if (!ignore) setTeams(opts)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!ignore) {
|
||||||
console.error('[MatchList] /api/teams fehlgeschlagen:', e)
|
console.error('[MatchList] /api/teams fehlgeschlagen:', e)
|
||||||
setTeams([])
|
setTeams([])
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingTeams(false)
|
if (!ignore) setLoadingTeams(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [showCreate, teams.length])
|
|
||||||
|
return () => { ignore = true; ctrl.abort() }
|
||||||
|
}, [showCreate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCreate) return
|
||||||
|
if (teams.length >= 2 && !teamAId && !teamBId) {
|
||||||
|
setTeamAId(teams[0].id)
|
||||||
|
setTeamBId(teams[1].id)
|
||||||
|
}
|
||||||
|
}, [teams, showCreate, teamAId, teamBId])
|
||||||
|
|
||||||
const resetCreateState = () => {
|
const resetCreateState = () => {
|
||||||
setTeamAId('')
|
setTeamAId('')
|
||||||
@ -490,6 +524,12 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
{teamAId && teamBId && teamAId === teamBId && (
|
{teamAId && teamBId && teamAId === teamBId && (
|
||||||
<p className="text-sm text-red-600 mt-2">Bitte zwei unterschiedliche Teams wählen.</p>
|
<p className="text-sm text-red-600 mt-2">Bitte zwei unterschiedliche Teams wählen.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loadingTeams && showCreate && teams.length === 0 && (
|
||||||
|
<p className="text-sm text-amber-600">
|
||||||
|
Keine Teams gefunden. Prüfe den /api/teams Response (erwartet id & name).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
// app/components/EditMatchMetaModal.tsx
|
// app/components/EditMatchMetaModal.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import Modal from '@/app/components/Modal'
|
import Modal from '@/app/components/Modal'
|
||||||
import Alert from '@/app/components/Alert'
|
import Alert from '@/app/components/Alert'
|
||||||
import Select from '@/app/components/Select'
|
import Select from '@/app/components/Select'
|
||||||
import { MAP_OPTIONS } from '../lib/mapOptions'
|
|
||||||
|
|
||||||
type TeamOption = { id: string; name: string; logo?: string | null }
|
type TeamOption = { id: string; name: string; logo?: string | null }
|
||||||
|
|
||||||
@ -19,9 +18,10 @@ type Props = {
|
|||||||
defaultTeamAName?: string | null
|
defaultTeamAName?: string | null
|
||||||
defaultTeamBName?: string | null
|
defaultTeamBName?: string | null
|
||||||
defaultDateISO?: string | null
|
defaultDateISO?: string | null
|
||||||
defaultMap?: string | null
|
defaultMap?: string | null // bleibt im Typ für Kompatibilität, wird aber nicht mehr genutzt
|
||||||
defaultVoteLeadMinutes?: number
|
defaultVoteLeadMinutes?: number
|
||||||
onSaved?: () => void
|
onSaved?: () => void
|
||||||
|
defaultBestOf?: 1 | 3 | 5
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditMatchMetaModal({
|
export default function EditMatchMetaModal({
|
||||||
@ -34,40 +34,44 @@ export default function EditMatchMetaModal({
|
|||||||
defaultTeamAName,
|
defaultTeamAName,
|
||||||
defaultTeamBName,
|
defaultTeamBName,
|
||||||
defaultDateISO,
|
defaultDateISO,
|
||||||
defaultMap,
|
// defaultMap, // nicht mehr genutzt
|
||||||
defaultVoteLeadMinutes = 60,
|
defaultVoteLeadMinutes = 60,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
defaultBestOf = 3,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// -------- state
|
|
||||||
const [title, setTitle] = useState(defaultTitle ?? '')
|
const [title, setTitle] = useState(defaultTitle ?? '')
|
||||||
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
||||||
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
||||||
|
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
|
||||||
|
|
||||||
const [date, setDate] = useState<string>(() => {
|
const [date, setDate] = useState<string>(() => {
|
||||||
if (!defaultDateISO) return ''
|
if (!defaultDateISO) return ''
|
||||||
const d = new Date(defaultDateISO)
|
const d = new Date(defaultDateISO)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
})
|
})
|
||||||
const [mapKey, setMapKey] = useState<string>(defaultMap ?? 'lobby_mapvote')
|
|
||||||
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
|
// Nur noch BestOf editierbar
|
||||||
|
const [bestOf, setBestOf] = useState<1 | 3 | 5>(defaultBestOf)
|
||||||
|
|
||||||
const [teams, setTeams] = useState<TeamOption[]>([])
|
const [teams, setTeams] = useState<TeamOption[]>([])
|
||||||
const [loadingTeams, setLoadingTeams] = useState(false)
|
const [loadingTeams, setLoadingTeams] = useState(false)
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// -------- load teams when open
|
const openedOnceRef = useRef(false)
|
||||||
|
|
||||||
|
// Teams laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return
|
if (!show) return
|
||||||
setLoadingTeams(true)
|
setLoadingTeams(true)
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/teams', { cache: 'no-store' })
|
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||||||
if (!res.ok) throw new Error(`Team-API: ${res.status}`)
|
const data = res.ok ? await res.json() : []
|
||||||
const data = (await res.json()) as TeamOption[]
|
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? [])
|
||||||
setTeams((Array.isArray(data) ? data : []).filter(t => t?.id && t?.name))
|
setTeams((list ?? []).filter((t: any) => t?.id && t?.name))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[EditMatchMetaModal] load teams failed:', e)
|
console.error('[EditMatchMetaModal] load teams failed:', e)
|
||||||
setTeams([])
|
setTeams([])
|
||||||
@ -77,14 +81,17 @@ export default function EditMatchMetaModal({
|
|||||||
})()
|
})()
|
||||||
}, [show])
|
}, [show])
|
||||||
|
|
||||||
// -------- reset defaults on open
|
// Defaults beim Öffnen (einmal)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return
|
if (!show) { openedOnceRef.current = false; return }
|
||||||
|
if (openedOnceRef.current) return
|
||||||
|
openedOnceRef.current = true
|
||||||
|
|
||||||
setTitle(defaultTitle ?? '')
|
setTitle(defaultTitle ?? '')
|
||||||
setTeamAId(defaultTeamAId ?? '')
|
setTeamAId(defaultTeamAId ?? '')
|
||||||
setTeamBId(defaultTeamBId ?? '')
|
setTeamBId(defaultTeamBId ?? '')
|
||||||
setMapKey(defaultMap ?? 'lobby_mapvote')
|
|
||||||
setVoteLead(defaultVoteLeadMinutes)
|
setVoteLead(defaultVoteLeadMinutes)
|
||||||
|
|
||||||
if (defaultDateISO) {
|
if (defaultDateISO) {
|
||||||
const d = new Date(defaultDateISO)
|
const d = new Date(defaultDateISO)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
@ -92,6 +99,8 @@ export default function EditMatchMetaModal({
|
|||||||
} else {
|
} else {
|
||||||
setDate('')
|
setDate('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBestOf(defaultBestOf ?? 3)
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
}, [
|
}, [
|
||||||
@ -100,31 +109,25 @@ export default function EditMatchMetaModal({
|
|||||||
defaultTeamAId,
|
defaultTeamAId,
|
||||||
defaultTeamBId,
|
defaultTeamBId,
|
||||||
defaultDateISO,
|
defaultDateISO,
|
||||||
defaultMap,
|
|
||||||
defaultVoteLeadMinutes,
|
defaultVoteLeadMinutes,
|
||||||
|
defaultBestOf,
|
||||||
])
|
])
|
||||||
|
|
||||||
// -------- derived: options
|
// Optionen
|
||||||
const teamOptionsA = useMemo(() => {
|
const teamOptionsA = useMemo(
|
||||||
// Team B nicht in A auswählbar machen
|
() => teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.name })),
|
||||||
return teams
|
[teams, teamBId]
|
||||||
.filter(t => t.id !== teamBId)
|
)
|
||||||
.map(t => ({ value: t.id, label: t.name }));
|
const teamOptionsB = useMemo(
|
||||||
}, [teams, teamBId]);
|
() => teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.name })),
|
||||||
|
[teams, teamAId]
|
||||||
const teamOptionsB = useMemo(() => {
|
|
||||||
// Team A nicht in B auswählbar machen
|
|
||||||
return teams
|
|
||||||
.filter(t => t.id !== teamAId)
|
|
||||||
.map(t => ({ value: t.id, label: t.name }));
|
|
||||||
}, [teams, teamAId]);
|
|
||||||
|
|
||||||
const mapOptions = useMemo(
|
|
||||||
() => MAP_OPTIONS.map(m => ({ value: m.key, label: m.label })),
|
|
||||||
[]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// -------- validation
|
// Hinweis-Flag: Best Of geändert?
|
||||||
|
const defaultBestOfNormalized = (defaultBestOf ?? 3) as 1 | 3 | 5
|
||||||
|
const bestOfChanged = bestOf !== defaultBestOfNormalized
|
||||||
|
|
||||||
|
// Validation
|
||||||
const canSave = useMemo(() => {
|
const canSave = useMemo(() => {
|
||||||
if (saving) return false
|
if (saving) return false
|
||||||
if (!date) return false
|
if (!date) return false
|
||||||
@ -132,7 +135,7 @@ export default function EditMatchMetaModal({
|
|||||||
return true
|
return true
|
||||||
}, [saving, date, teamAId, teamBId])
|
}, [saving, date, teamAId, teamBId])
|
||||||
|
|
||||||
// -------- save
|
// Save → nur bestOf wird (zusätzlich) übertragen; Server resettet MapVote bei Änderung
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@ -142,8 +145,8 @@ export default function EditMatchMetaModal({
|
|||||||
teamAId: teamAId || null,
|
teamAId: teamAId || null,
|
||||||
teamBId: teamBId || null,
|
teamBId: teamBId || null,
|
||||||
matchDate: date ? new Date(date).toISOString() : null,
|
matchDate: date ? new Date(date).toISOString() : null,
|
||||||
map: mapKey || null,
|
|
||||||
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
|
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
|
||||||
|
bestOf, // <- wichtig
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/matches/${matchId}/meta`, {
|
const res = await fetch(`/api/matches/${matchId}/meta`, {
|
||||||
@ -158,9 +161,7 @@ export default function EditMatchMetaModal({
|
|||||||
|
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
onClose()
|
onClose()
|
||||||
setTimeout(() => {
|
setTimeout(() => onSaved?.(), 0)
|
||||||
onSaved?.()
|
|
||||||
}, 0)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[EditMatchMetaModal] save error:', e)
|
console.error('[EditMatchMetaModal] save error:', e)
|
||||||
setError(e?.message || 'Speichern fehlgeschlagen')
|
setError(e?.message || 'Speichern fehlgeschlagen')
|
||||||
@ -169,7 +170,6 @@ export default function EditMatchMetaModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platzhalter mit aktuellem Namen (falls Options noch laden)
|
|
||||||
const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …')
|
const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …')
|
||||||
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …')
|
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …')
|
||||||
|
|
||||||
@ -241,18 +241,6 @@ export default function EditMatchMetaModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map */}
|
|
||||||
<div className="col-span-2 sm:col-span-1">
|
|
||||||
<label className="block text-sm font-medium mb-1">Map</label>
|
|
||||||
<Select
|
|
||||||
options={mapOptions}
|
|
||||||
value={mapKey}
|
|
||||||
onChange={setMapKey}
|
|
||||||
placeholder="Map wählen …"
|
|
||||||
dropDirection="auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vote-Lead */}
|
{/* Vote-Lead */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Map-Vote lead (Minuten)</label>
|
<label className="block text-sm font-medium mb-1">Map-Vote lead (Minuten)</label>
|
||||||
@ -267,6 +255,33 @@ export default function EditMatchMetaModal({
|
|||||||
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
|
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nur noch Best Of */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Modus (Best of)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 3, 5].map(bo => (
|
||||||
|
<button
|
||||||
|
key={bo}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBestOf(bo as 1|3|5)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm border
|
||||||
|
${bestOf === bo
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
|
||||||
|
>
|
||||||
|
BO{bo}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bestOfChanged && (
|
||||||
|
<Alert type="soft" color="warning" className="mt-2">
|
||||||
|
Du hast den Modus von <b>BO{defaultBestOfNormalized}</b> auf <b>BO{bestOf}</b> geändert.
|
||||||
|
Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden verworfen).
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -560,6 +560,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
||||||
defaultMap={match.map ?? null}
|
defaultMap={match.map ?? null}
|
||||||
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
|
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
|
||||||
|
// ⬇️ neu:
|
||||||
|
defaultBestOf={(match.bestOf as 1 | 3 | 5) ?? 3}
|
||||||
|
defaultSeries={extractSeriesMaps(match)} // Array mit map-Keys (kann '' enthalten)
|
||||||
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -39,12 +39,17 @@ export default function MatchReadyOverlay({
|
|||||||
deadlineAt,
|
deadlineAt,
|
||||||
onTimeout,
|
onTimeout,
|
||||||
forceGif,
|
forceGif,
|
||||||
connectHref = 'steam://connect/cs2.ironieopen.de:27015/ironie',
|
connectHref
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const mySteamId = session?.user?.steamId
|
const mySteamId = session?.user?.steamId
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
|
|
||||||
|
const ENV_CONNECT_HREF = process.env.NEXT_PUBLIC_CONNECT_HREF
|
||||||
|
const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000'
|
||||||
|
const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF
|
||||||
|
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
const [startedAt] = useState(() => Date.now())
|
const [startedAt] = useState(() => Date.now())
|
||||||
const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt])
|
const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt])
|
||||||
@ -54,7 +59,7 @@ export default function MatchReadyOverlay({
|
|||||||
// UI-States
|
// UI-States
|
||||||
const [accepted, setAccepted] = useState(false)
|
const [accepted, setAccepted] = useState(false)
|
||||||
const [finished, setFinished] = useState(false)
|
const [finished, setFinished] = useState(false)
|
||||||
const [showWaitHint, setShowWaitHint] = useState(false)
|
const [showWaitHint, setShowWaitHint] = useState(false) // ⬅️ nutzt du unten zum Ausblenden des Countdowns
|
||||||
const [connecting, setConnecting] = useState(false)
|
const [connecting, setConnecting] = useState(false)
|
||||||
const isVisible = open || accepted || showWaitHint
|
const isVisible = open || accepted || showWaitHint
|
||||||
|
|
||||||
@ -144,7 +149,7 @@ export default function MatchReadyOverlay({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) { setShowBackdrop(false); setShowContent(false); return }
|
if (!isVisible) { setShowBackdrop(false); setShowContent(false); return }
|
||||||
setShowBackdrop(true)
|
setShowBackdrop(true)
|
||||||
const id = setTimeout(() => setShowContent(true), 2000)
|
const id = setTimeout(() => setShowContent(true), 300) // vorher: 2000
|
||||||
return () => clearTimeout(id)
|
return () => clearTimeout(id)
|
||||||
}, [isVisible])
|
}, [isVisible])
|
||||||
|
|
||||||
@ -158,6 +163,7 @@ export default function MatchReadyOverlay({
|
|||||||
}, [accepted, loadReady])
|
}, [accepted, loadReady])
|
||||||
|
|
||||||
// SSE
|
// SSE
|
||||||
|
const { lastEvent: le } = useSSEStore()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
||||||
@ -230,9 +236,15 @@ export default function MatchReadyOverlay({
|
|||||||
}, [isVisible])
|
}, [isVisible])
|
||||||
|
|
||||||
// ----- AUDIO: Beeps starten/stoppen -----
|
// ----- AUDIO: Beeps starten/stoppen -----
|
||||||
|
// Beeps erst starten, wenn der Content sichtbar ist
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) { stopBeeps(); audioStartedRef.current = false; return }
|
if (!showContent) { // vorher: if (!isVisible)
|
||||||
|
stopBeeps()
|
||||||
|
audioStartedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
if (audioStartedRef.current) return
|
if (audioStartedRef.current) return
|
||||||
|
|
||||||
let cleanup = () => {}
|
let cleanup = () => {}
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const ok = await ensureAudioUnlocked()
|
const ok = await ensureAudioUnlocked()
|
||||||
@ -250,8 +262,9 @@ export default function MatchReadyOverlay({
|
|||||||
window.removeEventListener('keydown', onGesture)
|
window.removeEventListener('keydown', onGesture)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => { cleanup(); stopBeeps() }
|
return () => { cleanup(); stopBeeps() }
|
||||||
}, [isVisible])
|
}, [showContent]) // vorher: [isVisible]
|
||||||
|
|
||||||
// ----- countdown / timeout -----
|
// ----- countdown / timeout -----
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
@ -269,11 +282,11 @@ export default function MatchReadyOverlay({
|
|||||||
try { sound.play('loading') } catch {}
|
try { sound.play('loading') } catch {}
|
||||||
|
|
||||||
const doConnect = () => {
|
const doConnect = () => {
|
||||||
try { window.location.href = connectHref }
|
try { window.location.href = effectiveConnectHref }
|
||||||
catch {
|
catch {
|
||||||
try {
|
try {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = connectHref
|
a.href = effectiveConnectHref
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
a.remove()
|
a.remove()
|
||||||
@ -283,7 +296,7 @@ export default function MatchReadyOverlay({
|
|||||||
}
|
}
|
||||||
setTimeout(doConnect, 2000)
|
setTimeout(doConnect, 2000)
|
||||||
} else {
|
} else {
|
||||||
setShowWaitHint(true)
|
setShowWaitHint(true) // ⬅️ triggert Hinweis „Dein Team wartet auf dich!“
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -293,16 +306,6 @@ export default function MatchReadyOverlay({
|
|||||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
||||||
}, [isVisible, effectiveDeadline, accepted, finished, connectHref, onTimeout])
|
}, [isVisible, effectiveDeadline, accepted, finished, connectHref, onTimeout])
|
||||||
|
|
||||||
// ---- Präsenz → Rahmenfarbe ----
|
|
||||||
const borderByPresence = (s: Presence | undefined): string => {
|
|
||||||
switch (s) {
|
|
||||||
case 'online': return 'border-[#2ecc71]'
|
|
||||||
case 'away': return 'border-yellow-400'
|
|
||||||
case 'offline':
|
|
||||||
default: return 'border-white/20'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔎 Map-Icon aus MAP_OPTIONS ermitteln
|
// 🔎 Map-Icon aus MAP_OPTIONS ermitteln
|
||||||
const mapIconUrl = useMemo(() => {
|
const mapIconUrl = useMemo(() => {
|
||||||
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase()
|
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase()
|
||||||
@ -325,7 +328,10 @@ export default function MatchReadyOverlay({
|
|||||||
const p = participants[i]
|
const p = participants[i]
|
||||||
const isReady = p ? !!readyMap[p.steamId] : false
|
const isReady = p ? !!readyMap[p.steamId] : false
|
||||||
const presence: Presence = (p && statusMap[p.steamId]) || 'offline'
|
const presence: Presence = (p && statusMap[p.steamId]) || 'offline'
|
||||||
const borderCls = borderByPresence(presence)
|
const borderCls =
|
||||||
|
presence === 'online' ? 'border-[#2ecc71]' :
|
||||||
|
presence === 'away' ? 'border-yellow-400' :
|
||||||
|
'border-white/20'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -372,7 +378,7 @@ export default function MatchReadyOverlay({
|
|||||||
{/* Backdrop: 2s-Fade */}
|
{/* Backdrop: 2s-Fade */}
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'absolute inset-0 bg-black/60 transition-opacity duration-[2000ms] ease-out',
|
'absolute inset-0 bg-black/30 transition-opacity duration-[2000ms] ease-out',
|
||||||
showBackdrop ? 'opacity-100' : 'opacity-0'
|
showBackdrop ? 'opacity-100' : 'opacity-0'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
/>
|
/>
|
||||||
@ -389,24 +395,41 @@ export default function MatchReadyOverlay({
|
|||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
<img src={mapBg} alt={mapLabel} className="absolute inset-0 w-full h-full object-cover brightness-90" />
|
<img
|
||||||
|
src={mapBg}
|
||||||
|
alt={mapLabel}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover brightness-90"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Deko-Layer */}
|
{/* Deko-Layer (Gif/Video) */}
|
||||||
{useGif ? (
|
{useGif ? (
|
||||||
<div className="absolute inset-0 opacity-50 pointer-events-none">
|
<div className="absolute inset-0 opacity-50 pointer-events-none">
|
||||||
<img src="/assets/vids/overlay_cs2_accept.gif" alt="" className="absolute inset-0 w-full h-full object-cover" decoding="async" loading="eager" />
|
<img
|
||||||
|
src="/assets/vids/overlay_cs2_accept.webp"
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
decoding="async"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="absolute inset-0 w-full h-full object-cover opacity-50 pointer-events-none"
|
className="absolute inset-0 w-full h-full object-cover opacity-50 pointer-events-none"
|
||||||
autoPlay loop muted playsInline preload="auto"
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
>
|
>
|
||||||
<source src="/assets/vids/overlay_cs2_accept.webm" type="video/webm" />
|
<source src="/assets/vids/overlay_cs2_accept.webm" type="video/webm" />
|
||||||
</video>
|
</video>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 🔽 NEU: dunkler Gradient wie bei „Gewählte Maps“ */}
|
||||||
|
<div className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
|
||||||
|
|
||||||
{/* Inhalt */}
|
{/* Inhalt */}
|
||||||
<div className="relative z-10 h-full w-full flex flex-col items-center">
|
<div className="relative z-10 h-full w-full flex flex-col items-center">
|
||||||
<div className="mt-[28px] text-[30px] font-semibold text-[#6ae364]">
|
<div className="mt-[28px] text-[30px] font-semibold text-[#6ae364]">
|
||||||
@ -414,7 +437,7 @@ export default function MatchReadyOverlay({
|
|||||||
<span aria-hidden className="block h-px w-full bg-[#6ae364] shadow-[1px_1px_1px_#6ae3642c] rounded-sm" />
|
<span aria-hidden className="block h-px w-full bg-[#6ae364] shadow-[1px_1px_1px_#6ae3642c] rounded-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ⬇️ Icon aus MAP_OPTIONS + Label */}
|
{/* Icon + Label */}
|
||||||
<div className="mt-[10px] flex items-center justify-center text-[#8af784]">
|
<div className="mt-[10px] flex items-center justify-center text-[#8af784]">
|
||||||
<img src={mapIconUrl} alt={`${mapLabel} Icon`} className="w-5 h-5 object-contain" />
|
<img src={mapIconUrl} alt={`${mapLabel} Icon`} className="w-5 h-5 object-contain" />
|
||||||
<span className="ml-2 text-[15px] [transform:scale(1,0.9)]">{mapLabel}</span>
|
<span className="ml-2 text-[15px] [transform:scale(1,0.9)]">{mapLabel}</span>
|
||||||
@ -431,7 +454,7 @@ export default function MatchReadyOverlay({
|
|||||||
Dein Team wartet auf dich!
|
Dein Team wartet auf dich!
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={connectHref}
|
href={effectiveConnectHref}
|
||||||
className="px-4 py-2 rounded-md bg-[#61d365] hover:bg-[#4dc250] text-[#174d10] font-semibold text-lg shadow"
|
className="px-4 py-2 rounded-md bg-[#61d365] hover:bg-[#4dc250] text-[#174d10] font-semibold text-lg shadow"
|
||||||
>
|
>
|
||||||
Verbinden
|
Verbinden
|
||||||
@ -449,6 +472,8 @@ export default function MatchReadyOverlay({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Countdown oder Verbinde-Status */}
|
{/* Countdown oder Verbinde-Status */}
|
||||||
|
{/* 🔽 NEU: Countdown ausblenden, wenn der Warte-Hinweis gezeigt wird */}
|
||||||
|
{!showWaitHint && (
|
||||||
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
|
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
|
||||||
{connecting ? (
|
{connecting ? (
|
||||||
<span
|
<span
|
||||||
@ -463,6 +488,7 @@ export default function MatchReadyOverlay({
|
|||||||
<span>{fmt(rest)}</span>
|
<span>{fmt(rest)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
// /app/components/radar/LiveRadar.tsx
|
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@ -14,7 +12,8 @@ const UI = {
|
|||||||
dirLenRel: 0.70,
|
dirLenRel: 0.70,
|
||||||
dirMinLenPx: 6,
|
dirMinLenPx: 6,
|
||||||
lineWidthRel: 0.25,
|
lineWidthRel: 0.25,
|
||||||
stroke: '#ffffff',
|
stroke: '#ffffff', // normaler Outline (weiß)
|
||||||
|
bombStroke: '#ef4444', // Outline wenn Bombe (rot)
|
||||||
fillCT: '#3b82f6',
|
fillCT: '#3b82f6',
|
||||||
fillT: '#f59e0b',
|
fillT: '#f59e0b',
|
||||||
dirColor: 'auto' as 'auto' | string,
|
dirColor: 'auto' as 'auto' | string,
|
||||||
@ -30,6 +29,17 @@ const UI = {
|
|||||||
teamStrokeT: '#f59e0b',
|
teamStrokeT: '#f59e0b',
|
||||||
minRadiusPx: 6,
|
minRadiusPx: 6,
|
||||||
},
|
},
|
||||||
|
death: {
|
||||||
|
stroke: '#9ca3af', // graues X
|
||||||
|
lineWidthPx: 2,
|
||||||
|
sizePx: 10,
|
||||||
|
},
|
||||||
|
trail: {
|
||||||
|
maxPoints: 60,
|
||||||
|
fadeMs: 1500,
|
||||||
|
stroke: 'rgba(60,60,60,0.7)',
|
||||||
|
widthPx: 2,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── helpers ───────── */
|
/* ───────── helpers ───────── */
|
||||||
@ -49,20 +59,72 @@ function mapTeam(t: any): 'T' | 'CT' | string {
|
|||||||
return String(t ?? '')
|
return String(t ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWsUrl(prefix: 'CS2_META' | 'CS2_POS') {
|
// Versuche robust zu erkennen, ob ein Spieler die Bombe hat
|
||||||
const host = process.env[`NEXT_PUBLIC_${prefix}_WS_HOST`] || '127.0.0.1'
|
function detectHasBomb(src: any): boolean {
|
||||||
const port = String(process.env[`NEXT_PUBLIC_${prefix}_WS_PORT`] || (prefix === 'CS2_META' ? '443' : '8082'))
|
const flags = [
|
||||||
const path = process.env[`NEXT_PUBLIC_${prefix}_WS_PATH`] || '/telemetry'
|
'hasBomb','has_bomb','bomb','c4','hasC4','carryingBomb','bombCarrier','isBombCarrier'
|
||||||
|
]
|
||||||
// Heuristik: wenn explizit 443 -> wss, wenn Seite https und Host != localhost -> wss, sonst ws
|
for (const k of flags) {
|
||||||
const isLocal = ['127.0.0.1', 'localhost', '::1'].includes(host)
|
if (typeof src?.[k] === 'boolean') return !!src[k]
|
||||||
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
|
if (typeof src?.[k] === 'string') {
|
||||||
const proto = (port === '443' || (!isLocal && pageHttps)) ? 'wss' : 'ws'
|
const s = String(src[k]).toLowerCase()
|
||||||
|
if (s === 'true' || s === '1' || s === 'c4' || s.includes('bomb')) return true
|
||||||
const portPart = (port === '80' || port === '443') ? '' : `:${port}`
|
}
|
||||||
return `${proto}://${host}${portPart}${path}`
|
}
|
||||||
|
const arrays = [src?.weapons, src?.inventory, src?.items]
|
||||||
|
for (const arr of arrays) {
|
||||||
|
if (!arr) continue
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
if (arr.some((w:any)=>
|
||||||
|
typeof w === 'string'
|
||||||
|
? w.toLowerCase().includes('c4') || w.toLowerCase().includes('bomb')
|
||||||
|
: (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('c4') ||
|
||||||
|
(w?.name||w?.type||w?.weapon||'').toLowerCase().includes('bomb')
|
||||||
|
)) return true
|
||||||
|
} else if (typeof arr === 'object') {
|
||||||
|
const vals = Object.values(arr)
|
||||||
|
if (vals.some((w:any)=>
|
||||||
|
typeof w === 'string'
|
||||||
|
? w.toLowerCase().includes('c4') || w.toLowerCase().includes('bomb')
|
||||||
|
: (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('c4') ||
|
||||||
|
(w?.name||w?.type||w?.weapon||'').toLowerCase().includes('bomb')
|
||||||
|
)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL-Builder
|
||||||
|
function makeWsUrl(
|
||||||
|
host?: string,
|
||||||
|
port?: string,
|
||||||
|
path?: string,
|
||||||
|
scheme?: string
|
||||||
|
) {
|
||||||
|
const h = (host ?? '').trim() || '127.0.0.1'
|
||||||
|
const p = (port ?? '').trim() || '8081'
|
||||||
|
const pa = (path ?? '').trim() || '/telemetry'
|
||||||
|
const sch = (scheme ?? '').toLowerCase()
|
||||||
|
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
|
||||||
|
const useWss = sch === 'wss' || (sch !== 'ws' && (p === '443' || pageHttps))
|
||||||
|
const proto = useWss ? 'wss' : 'ws'
|
||||||
|
const portPart = (p === '80' || p === '443') ? '' : `:${p}`
|
||||||
|
return `${proto}://${h}${portPart}${pa}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaUrl = makeWsUrl(
|
||||||
|
process.env.NEXT_PUBLIC_CS2_META_WS_HOST,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_META_WS_PORT,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_META_WS_PATH,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_META_WS_SCHEME
|
||||||
|
)
|
||||||
|
|
||||||
|
const posUrl = makeWsUrl(
|
||||||
|
process.env.NEXT_PUBLIC_CS2_POS_WS_HOST,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_POS_WS_PORT,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_POS_WS_PATH,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_POS_WS_SCHEME
|
||||||
|
)
|
||||||
|
|
||||||
const RAD2DEG = 180 / Math.PI
|
const RAD2DEG = 180 / Math.PI
|
||||||
const normalizeDeg = (d: number) => (d % 360 + 360) % 360
|
const normalizeDeg = (d: number) => (d % 360 + 360) % 360
|
||||||
@ -83,6 +145,7 @@ type PlayerState = {
|
|||||||
z: number
|
z: number
|
||||||
yaw?: number | null
|
yaw?: number | null
|
||||||
alive?: boolean
|
alive?: boolean
|
||||||
|
hasBomb?: boolean
|
||||||
}
|
}
|
||||||
type Grenade = {
|
type Grenade = {
|
||||||
id: string
|
id: string
|
||||||
@ -94,22 +157,36 @@ type Grenade = {
|
|||||||
expiresAt?: number | null
|
expiresAt?: number | null
|
||||||
team?: 'T' | 'CT' | string | null
|
team?: 'T' | 'CT' | string | null
|
||||||
}
|
}
|
||||||
|
type DeathMarker = { id: string; x: number; y: number; t: number }
|
||||||
|
type Trail = { id: string; kind: Grenade['kind']; pts: {x:number,y:number}[]; lastSeen: number }
|
||||||
|
|
||||||
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
|
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
|
||||||
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
||||||
|
|
||||||
/* ───────── Komponente ───────── */
|
/* ───────── Komponente ───────── */
|
||||||
export default function LiveRadar() {
|
export default function LiveRadar() {
|
||||||
// WS-Status separat anzeigen
|
// WS-Status
|
||||||
const [metaWsStatus, setMetaWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
const [metaWsStatus, setMetaWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
||||||
const [posWsStatus, setPosWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
const [posWsStatus, setPosWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
||||||
|
|
||||||
// Zustand
|
// Map
|
||||||
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
|
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Spieler
|
||||||
const playersRef = useRef<Map<string, PlayerState>>(new Map())
|
const playersRef = useRef<Map<string, PlayerState>>(new Map())
|
||||||
const [players, setPlayers] = useState<PlayerState[]>([])
|
const [players, setPlayers] = useState<PlayerState[]>([])
|
||||||
|
|
||||||
|
// Grenaden + Trails
|
||||||
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
|
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
|
||||||
const [grenades, setGrenades] = useState<Grenade[]>([])
|
const [grenades, setGrenades] = useState<Grenade[]>([])
|
||||||
|
const trailsRef = useRef<Map<string, Trail>>(new Map())
|
||||||
|
const [trails, setTrails] = useState<Trail[]>([])
|
||||||
|
|
||||||
|
// Death-Marker
|
||||||
|
const deathMarkersRef = useRef<DeathMarker[]>([])
|
||||||
|
const [deathMarkers, setDeathMarkers] = useState<DeathMarker[]>([])
|
||||||
|
|
||||||
|
// Flush
|
||||||
const flushTimer = useRef<number | null>(null)
|
const flushTimer = useRef<number | null>(null)
|
||||||
const scheduleFlush = () => {
|
const scheduleFlush = () => {
|
||||||
if (flushTimer.current != null) return
|
if (flushTimer.current != null) return
|
||||||
@ -117,9 +194,10 @@ export default function LiveRadar() {
|
|||||||
flushTimer.current = null
|
flushTimer.current = null
|
||||||
setPlayers(Array.from(playersRef.current.values()))
|
setPlayers(Array.from(playersRef.current.values()))
|
||||||
setGrenades(Array.from(grenadesRef.current.values()))
|
setGrenades(Array.from(grenadesRef.current.values()))
|
||||||
|
setTrails(Array.from(trailsRef.current.values()))
|
||||||
|
setDeathMarkers([...deathMarkersRef.current])
|
||||||
}, 66)
|
}, 66)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (flushTimer.current != null) {
|
if (flushTimer.current != null) {
|
||||||
@ -129,11 +207,21 @@ export default function LiveRadar() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const metaUrl = buildWsUrl('CS2_META')
|
// Runden-/Map-Reset
|
||||||
const posUrl = buildWsUrl('CS2_POS')
|
const clearRoundArtifacts = () => {
|
||||||
|
deathMarkersRef.current = []
|
||||||
|
trailsRef.current.clear()
|
||||||
|
grenadesRef.current.clear()
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeMapKey) clearRoundArtifacts()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeMapKey])
|
||||||
|
|
||||||
/* ───────── Meta-Callbacks ───────── */
|
/* ───────── Meta-Callbacks ───────── */
|
||||||
const handleMetaMap = (key: string) => setActiveMapKey(key.toLowerCase())
|
const handleMetaMap = (key: string) => setActiveMapKey(key.toLowerCase())
|
||||||
|
|
||||||
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
|
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
|
||||||
for (const p of list) {
|
for (const p of list) {
|
||||||
const id = String(p.steamId ?? '')
|
const id = String(p.steamId ?? '')
|
||||||
@ -146,10 +234,12 @@ export default function LiveRadar() {
|
|||||||
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
|
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
|
||||||
yaw: old?.yaw ?? null,
|
yaw: old?.yaw ?? null,
|
||||||
alive: old?.alive,
|
alive: old?.alive,
|
||||||
|
hasBomb: old?.hasBomb ?? false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
scheduleFlush()
|
scheduleFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMetaPlayerJoin = (p: any) => {
|
const handleMetaPlayerJoin = (p: any) => {
|
||||||
const id = String(p?.steamId ?? p?.id ?? p?.name ?? '')
|
const id = String(p?.steamId ?? p?.id ?? p?.name ?? '')
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@ -161,9 +251,11 @@ export default function LiveRadar() {
|
|||||||
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
|
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
|
||||||
yaw: old?.yaw ?? null,
|
yaw: old?.yaw ?? null,
|
||||||
alive: true,
|
alive: true,
|
||||||
|
hasBomb: old?.hasBomb ?? false,
|
||||||
})
|
})
|
||||||
scheduleFlush()
|
scheduleFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMetaPlayerLeave = (steamId: string | number) => {
|
const handleMetaPlayerLeave = (steamId: string | number) => {
|
||||||
const id = String(steamId)
|
const id = String(steamId)
|
||||||
const old = playersRef.current.get(id)
|
const old = playersRef.current.get(id)
|
||||||
@ -174,6 +266,10 @@ export default function LiveRadar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── Positions-Callbacks ───────── */
|
/* ───────── Positions-Callbacks ───────── */
|
||||||
|
const addDeathMarker = (x:number, y:number, idHint?: string) => {
|
||||||
|
deathMarkersRef.current.push({ id: idHint ?? `d#${Date.now()}`, x, y, t: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
const upsertPlayer = (e: any) => {
|
const upsertPlayer = (e: any) => {
|
||||||
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
|
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@ -194,18 +290,28 @@ export default function LiveRadar() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const old = playersRef.current.get(id)
|
const old = playersRef.current.get(id)
|
||||||
|
const nextAlive = (e.alive !== undefined) ? !!e.alive : old?.alive
|
||||||
|
const hasBomb = detectHasBomb(e) || old?.hasBomb
|
||||||
|
|
||||||
|
// Alive→Dead → Death-X an aktueller Position speichern
|
||||||
|
if (old?.alive !== false && nextAlive === false) addDeathMarker(x, y, id)
|
||||||
|
|
||||||
playersRef.current.set(id, {
|
playersRef.current.set(id, {
|
||||||
id,
|
id,
|
||||||
name: e.name ?? old?.name ?? null,
|
name: e.name ?? old?.name ?? null,
|
||||||
team: mapTeam(e.team ?? old?.team),
|
team: mapTeam(e.team ?? old?.team),
|
||||||
x, y, z,
|
x, y, z,
|
||||||
yaw: Number.isFinite(yaw) ? yaw : old?.yaw ?? null,
|
yaw: Number.isFinite(yaw) ? yaw : old?.yaw ?? null,
|
||||||
alive: e.alive ?? old?.alive,
|
alive: nextAlive,
|
||||||
|
hasBomb: !!hasBomb,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePlayersAll = (msg: any) => {
|
const handlePlayersAll = (msg: any) => {
|
||||||
const ap = msg?.allplayers
|
const ap = msg?.allplayers
|
||||||
if (!ap || typeof ap !== 'object') return
|
if (!ap || typeof ap !== 'object') return
|
||||||
|
|
||||||
|
let total = 0, aliveCount = 0
|
||||||
for (const key of Object.keys(ap)) {
|
for (const key of Object.keys(ap)) {
|
||||||
const p = ap[key]
|
const p = ap[key]
|
||||||
const pos = parseVec3String(p.position)
|
const pos = parseVec3String(p.position)
|
||||||
@ -213,16 +319,33 @@ export default function LiveRadar() {
|
|||||||
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
|
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
|
||||||
const id = String(key)
|
const id = String(key)
|
||||||
const old = playersRef.current.get(id)
|
const old = playersRef.current.get(id)
|
||||||
|
const isAlive = p.state?.health > 0 || p.state?.health == null
|
||||||
|
const hasBomb = detectHasBomb(p) || old?.hasBomb
|
||||||
|
|
||||||
|
if ((old?.alive ?? true) && !isAlive) addDeathMarker(pos.x, pos.y, id)
|
||||||
|
|
||||||
playersRef.current.set(id, {
|
playersRef.current.set(id, {
|
||||||
id,
|
id,
|
||||||
name: p.name ?? old?.name ?? null,
|
name: p.name ?? old?.name ?? null,
|
||||||
team: mapTeam(p.team ?? old?.team),
|
team: mapTeam(p.team ?? old?.team),
|
||||||
x: pos.x, y: pos.y, z: pos.z,
|
x: pos.x, y: pos.y, z: pos.z,
|
||||||
yaw,
|
yaw,
|
||||||
alive: p.state?.health > 0 || p.state?.health == null ? true : false,
|
alive: isAlive,
|
||||||
|
hasBomb: !!hasBomb,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
total++
|
||||||
|
if (isAlive) aliveCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Heuristik: Neue Runde → alles leeren
|
||||||
|
if (total > 0 && aliveCount === total && (deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0)) {
|
||||||
|
clearRoundArtifacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeGrenades = (raw: any): Grenade[] => {
|
const normalizeGrenades = (raw: any): Grenade[] => {
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
|
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
|
||||||
@ -291,14 +414,41 @@ export default function LiveRadar() {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGrenades = (g: any) => {
|
const handleGrenades = (g: any) => {
|
||||||
const list = normalizeGrenades(g)
|
const list = normalizeGrenades(g)
|
||||||
|
|
||||||
|
// Trails updaten
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const now = Date.now()
|
||||||
|
for (const it of list) {
|
||||||
|
seen.add(it.id)
|
||||||
|
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 }
|
||||||
|
const last = prev.pts[prev.pts.length - 1]
|
||||||
|
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
|
||||||
|
prev.pts.push({ x: it.x, y: it.y })
|
||||||
|
if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints)
|
||||||
|
}
|
||||||
|
prev.kind = it.kind
|
||||||
|
prev.lastSeen = now
|
||||||
|
trailsRef.current.set(it.id, prev)
|
||||||
|
}
|
||||||
|
// Nicht mehr gesehene Trails ausdünnen
|
||||||
|
for (const [id, tr] of trailsRef.current) {
|
||||||
|
if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) {
|
||||||
|
trailsRef.current.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aktuelle Nades übernehmen
|
||||||
const next = new Map<string, Grenade>()
|
const next = new Map<string, Grenade>()
|
||||||
for (const it of list) next.set(it.id, it)
|
for (const it of list) next.set(it.id, it)
|
||||||
grenadesRef.current = next
|
grenadesRef.current = next
|
||||||
|
|
||||||
|
scheduleFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
// gemeinsamer flush bei Positionsdaten
|
// erster Flush
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playersRef.current && !grenadesRef.current) return
|
if (!playersRef.current && !grenadesRef.current) return
|
||||||
scheduleFlush()
|
scheduleFlush()
|
||||||
@ -515,7 +665,7 @@ export default function LiveRadar() {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 inline-block"
|
className="relative mx-auto rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 inline-block"
|
||||||
style={{ maxHeight: maxImgHeight ?? undefined }}
|
style={{ maxHeight: (typeof window !== 'undefined' ? (window.innerHeight - (headerRef.current?.getBoundingClientRect().bottom ?? 0) - 16) : undefined) ?? undefined }}
|
||||||
>
|
>
|
||||||
{currentSrc ? (
|
{currentSrc ? (
|
||||||
<>
|
<>
|
||||||
@ -524,7 +674,6 @@ export default function LiveRadar() {
|
|||||||
src={currentSrc}
|
src={currentSrc}
|
||||||
alt={activeMapKey}
|
alt={activeMapKey}
|
||||||
className="block h-auto max-w-full"
|
className="block h-auto max-w-full"
|
||||||
style={{ maxHeight: maxImgHeight ?? undefined }}
|
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget
|
const img = e.currentTarget
|
||||||
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
|
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
|
||||||
@ -540,6 +689,26 @@ export default function LiveRadar() {
|
|||||||
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
|
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
>
|
>
|
||||||
|
{/* Trails */}
|
||||||
|
{trails.map(tr => {
|
||||||
|
const pts = tr.pts.map(p => {
|
||||||
|
const q = worldToPx(p.x, p.y)
|
||||||
|
return `${q.x},${q.y}`
|
||||||
|
}).join(' ')
|
||||||
|
if (!pts) return null
|
||||||
|
return (
|
||||||
|
<polyline
|
||||||
|
key={`trail-${tr.id}`}
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke={UI.trail.stroke}
|
||||||
|
strokeWidth={UI.trail.widthPx}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Grenades */}
|
{/* Grenades */}
|
||||||
{grenades.map((g) => {
|
{grenades.map((g) => {
|
||||||
const P = worldToPx(g.x, g.y)
|
const P = worldToPx(g.x, g.y)
|
||||||
@ -578,15 +747,15 @@ export default function LiveRadar() {
|
|||||||
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
|
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Spieler */}
|
{/* Spieler (nur lebende anzeigen; Tote werden als X separat gezeichnet) */}
|
||||||
{players
|
{players
|
||||||
.filter(p => p.team === 'CT' || p.team === 'T')
|
.filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false)
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const A = worldToPx(p.x, p.y)
|
const A = worldToPx(p.x, p.y)
|
||||||
const base = Math.min(imgSize.w, imgSize.h)
|
const base = Math.min(imgSize.w, imgSize.h)
|
||||||
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
|
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
|
||||||
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
|
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
|
||||||
const stroke = UI.player.stroke
|
const stroke = p.hasBomb ? UI.player.bombStroke : UI.player.stroke
|
||||||
const strokeW = Math.max(1, r * UI.player.lineWidthRel)
|
const strokeW = Math.max(1, r * UI.player.lineWidthRel)
|
||||||
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
|
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
|
||||||
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
|
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
|
||||||
@ -612,18 +781,30 @@ export default function LiveRadar() {
|
|||||||
cx={A.x} cy={A.y} r={r}
|
cx={A.x} cy={A.y} r={r}
|
||||||
fill={fillColor} stroke={stroke}
|
fill={fillColor} stroke={stroke}
|
||||||
strokeWidth={Math.max(1, r*0.3)}
|
strokeWidth={Math.max(1, r*0.3)}
|
||||||
opacity={p.alive === false ? 0.6 : 1}
|
|
||||||
/>
|
/>
|
||||||
{Number.isFinite(p.yaw as number) && (
|
{Number.isFinite(p.yaw as number) && (
|
||||||
<line
|
<line
|
||||||
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
|
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
|
||||||
stroke={dirColor} strokeWidth={strokeW} strokeLinecap="round"
|
stroke={dirColor} strokeWidth={strokeW} strokeLinecap="round"
|
||||||
opacity={p.alive === false ? 0.5 : 1}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Death-Marker (graues X an Todesposition) */}
|
||||||
|
{deathMarkers.map(dm => {
|
||||||
|
const P = worldToPx(dm.x, dm.y)
|
||||||
|
const s = UI.death.sizePx
|
||||||
|
return (
|
||||||
|
<g key={`death-${dm.t}-${dm.x}-${dm.y}`}>
|
||||||
|
<line x1={P.x - s} y1={P.y - s} x2={P.x + s} y2={P.y + s}
|
||||||
|
stroke={UI.death.stroke} strokeWidth={UI.death.lineWidthPx} strokeLinecap="round" />
|
||||||
|
<line x1={P.x - s} y1={P.y + s} x2={P.x + s} y2={P.y - s}
|
||||||
|
stroke={UI.death.stroke} strokeWidth={UI.death.lineWidthPx} strokeLinecap="round" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
// /app/components/MetaSocket.tsx
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
|
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
|
||||||
|
|
||||||
type MetaSocketProps = {
|
type MetaSocketProps = {
|
||||||
url?: string
|
url?: string
|
||||||
onStatus?: (s: Status) => void
|
onStatus?: (s: Status) => void
|
||||||
@ -26,45 +23,59 @@ export default function MetaSocket({
|
|||||||
const aliveRef = useRef(true)
|
const aliveRef = useRef(true)
|
||||||
const retryRef = useRef<number | null>(null)
|
const retryRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
// aktuelle Handler in Refs spiegeln (ändern NICHT die Effect-Dependencies)
|
||||||
|
const onMapRef = useRef(onMap)
|
||||||
|
const onPlayersSnapshotRef = useRef(onPlayersSnapshot)
|
||||||
|
const onPlayerJoinRef = useRef(onPlayerJoin)
|
||||||
|
const onPlayerLeaveRef = useRef(onPlayerLeave)
|
||||||
|
useEffect(() => { onMapRef.current = onMap }, [onMap])
|
||||||
|
useEffect(() => { onPlayersSnapshotRef.current = onPlayersSnapshot }, [onPlayersSnapshot])
|
||||||
|
useEffect(() => { onPlayerJoinRef.current = onPlayerJoin }, [onPlayerJoin])
|
||||||
|
useEffect(() => { onPlayerLeaveRef.current = onPlayerLeave }, [onPlayerLeave])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
aliveRef.current = true
|
aliveRef.current = true
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (!aliveRef.current) return
|
if (!aliveRef.current || !url) return
|
||||||
onStatus?.('connecting')
|
onStatus?.('connecting')
|
||||||
const ws = new WebSocket(url!)
|
|
||||||
|
const ws = new WebSocket(url)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => onStatus?.('open')
|
ws.onopen = () => onStatus?.('open')
|
||||||
ws.onerror = () => onStatus?.('error')
|
ws.onerror = () => onStatus?.('error')
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
onStatus?.('closed')
|
onStatus?.('closed')
|
||||||
|
// optional: Backoff oder ganz ohne Auto-Reconnect, je nach Wunsch
|
||||||
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
|
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
let msg: any = null
|
let msg: any = null
|
||||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
// KEINE matchId-Filterung mehr
|
|
||||||
if (msg.type === 'map' && typeof msg.name === 'string') {
|
if (msg.type === 'map' && typeof msg.name === 'string') {
|
||||||
onMap?.(msg.name.toLowerCase())
|
onMapRef.current?.(msg.name.toLowerCase())
|
||||||
} else if (msg.type === 'players' && Array.isArray(msg.players)) {
|
} else if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||||
onPlayersSnapshot?.(msg.players)
|
onPlayersSnapshotRef.current?.(msg.players)
|
||||||
} else if (msg.type === 'player_join' && msg.player) {
|
} else if (msg.type === 'player_join' && msg.player) {
|
||||||
onPlayerJoin?.(msg.player)
|
onPlayerJoinRef.current?.(msg.player)
|
||||||
} else if (msg.type === 'player_leave') {
|
} else if (msg.type === 'player_leave') {
|
||||||
onPlayerLeave?.(msg.steamId ?? msg.steam_id ?? msg.id)
|
onPlayerLeaveRef.current?.(msg.steamId ?? msg.steam_id ?? msg.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url) connect()
|
connect()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
aliveRef.current = false
|
aliveRef.current = false
|
||||||
if (retryRef.current) window.clearTimeout(retryRef.current)
|
if (retryRef.current) window.clearTimeout(retryRef.current)
|
||||||
try { wsRef.current?.close(1000, 'meta unmounted') } catch {}
|
try { wsRef.current?.close(1000, 'meta unmounted') } catch {}
|
||||||
}
|
}
|
||||||
}, [url, onStatus, onMap, onPlayersSnapshot, onPlayerJoin, onPlayerLeave])
|
}, [url, onStatus]) // <— nur auf url/onStatus hören!
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// /app/components/PositionsSocket.tsx
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
@ -12,6 +11,8 @@ type PositionsSocketProps = {
|
|||||||
onPlayerUpdate?: (p: any) => void
|
onPlayerUpdate?: (p: any) => void
|
||||||
onPlayersAll?: (allplayers: any) => void
|
onPlayersAll?: (allplayers: any) => void
|
||||||
onGrenades?: (g: any) => void
|
onGrenades?: (g: any) => void
|
||||||
|
onRoundStart?: () => void // ⬅️ NEU
|
||||||
|
onRoundEnd?: () => void // ⬅️ optional
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PositionsSocket({
|
export default function PositionsSocket({
|
||||||
@ -21,6 +22,8 @@ export default function PositionsSocket({
|
|||||||
onPlayerUpdate,
|
onPlayerUpdate,
|
||||||
onPlayersAll,
|
onPlayersAll,
|
||||||
onGrenades,
|
onGrenades,
|
||||||
|
onRoundStart,
|
||||||
|
onRoundEnd,
|
||||||
}: PositionsSocketProps) {
|
}: PositionsSocketProps) {
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const aliveRef = useRef(true)
|
const aliveRef = useRef(true)
|
||||||
@ -29,7 +32,11 @@ export default function PositionsSocket({
|
|||||||
const dispatch = (msg: any) => {
|
const dispatch = (msg: any) => {
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
// KEINE matchId-Filterung mehr
|
// Runde:
|
||||||
|
if (msg.type === 'round_start') { onRoundStart?.(); return }
|
||||||
|
if (msg.type === 'round_end') { onRoundEnd?.(); return }
|
||||||
|
|
||||||
|
// Tick (Fast-Path)
|
||||||
if (msg.type === 'tick') {
|
if (msg.type === 'tick') {
|
||||||
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase())
|
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase())
|
||||||
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}))
|
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}))
|
||||||
|
|||||||
@ -354,7 +354,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -368,7 +368,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
|
|||||||
@ -355,7 +355,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -369,7 +369,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user