This commit is contained in:
Linrador 2025-10-20 10:32:47 +02:00
parent 28774efff1
commit d1c0d9297c
24 changed files with 251 additions and 205 deletions

17
.env
View File

@ -9,19 +9,20 @@ SHARE_CODE_SECRET_KEY=6f9d4a2951b8eae35cdd3fb28e1a74550d177c3900ad1111c8e48b4e3b
SHARE_CODE_IV=9f1d67b8a3c4d261fa2b7c44a1d4f9c8 SHARE_CODE_IV=9f1d67b8a3c4d261fa2b7c44a1d4f9c8
STEAM_API_KEY=0B3B2BF79ECD1E9262BB118A7FEF1973 STEAM_API_KEY=0B3B2BF79ECD1E9262BB118A7FEF1973
NEXTAUTH_SECRET=ironieopen NEXTAUTH_SECRET=ironieopen
NEXTAUTH_URL=https://new.ironieopen.de #NEXTAUTH_URL=https://new.ironieopen.de
NEXT_PUBLIC_APP_URL=https://new.ironieopen.de #NEXT_PUBLIC_APP_URL=https://new.ironieopen.de
NEXTAUTH_URL=https://ironieopen.local
NEXT_PUBLIC_APP_URL=https://ironieopen.local
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3 ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9 PTERODACTYL_APP_API=ptla_6IcEHfK0CMiA5clzFSGXPEhczC9jTRZz7pr8sNn1iSB
PTERODACTYL_CLIENT_API=ptlc_c31BKDEXy63fHUxeQDahk6eeC3CL19TpG2rgao7mUl5 PTERODACTYL_CLIENT_API=ptlc_hSYxGaVlp7dklvSWAcuGjjGfcBiCaAedKXYnI3SKMV3
PTERODACTYL_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.acdef8fc
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
PTERO_SERVER_ID=37a11489 PTERO_SERVER_ID=acdef8fc
TRUST_PROXY=1
NEXT_PUBLIC_SSE_URL=https://new.ironieopen.de/events
# META (vom CS2-Server-Plugin) # META (vom CS2-Server-Plugin)
NEXT_PUBLIC_CS2_TELEMETRY_WS_HOST=new.ironieopen.de NEXT_PUBLIC_CS2_TELEMETRY_WS_HOST=new.ironieopen.de

View File

@ -4,7 +4,6 @@ import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'; import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone',
images: { images: {
remotePatterns: [ remotePatterns: [
{ {

View File

@ -35,29 +35,31 @@ export default function TeamAdminClient({ teamId }: Props) {
const steamId = session?.user?.steamId const steamId = session?.user?.steamId
if (!steamId) return if (!steamId) return
const base = process.env.NEXT_PUBLIC_SSE_URL const raw = process.env.NEXT_PUBLIC_APP_URL?.trim()
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}` let origin: string
let es: EventSource | null = new EventSource(url, { withCredentials: false }) try {
origin = raw && /^https?:\/\//i.test(raw) ? new URL(raw).origin : window.location.origin
} catch {
origin = window.location.origin
}
const u = new URL('/events', origin) // überschreibt jeden Pfad
u.searchParams.set('steamId', steamId)
let es: EventSource | null = new EventSource(u.toString(), { withCredentials: false })
// Listener als EventListener typisieren
const onTeamUpdated: EventListener = (ev) => { const onTeamUpdated: EventListener = (ev) => {
try { try {
const msg = JSON.parse((ev as MessageEvent).data as string) const msg = JSON.parse((ev as MessageEvent).data as string)
if (msg.teamId === teamId) { if (msg.teamId === teamId) fetchTeam()
fetchTeam() } catch (e) { console.error('[SSE] parse error:', e) }
}
} catch (e) {
console.error('[SSE] parse error:', e)
}
} }
es.addEventListener('team-updated', onTeamUpdated) es.addEventListener('team-updated', onTeamUpdated)
es.onerror = () => { es.onerror = () => {
es?.close() es?.close(); es = null
es = null
setTimeout(() => { setTimeout(() => {
const next = new EventSource(url, { withCredentials: false }) const next = new EventSource(u.toString(), { withCredentials: false })
next.addEventListener('team-updated', onTeamUpdated) next.addEventListener('team-updated', onTeamUpdated)
next.onerror = () => { next.close() } next.onerror = () => { next.close() }
es = next es = next

View File

@ -76,6 +76,16 @@ function combineLocalDateTime(dateStr: string, timeStr: string) {
return dt.toISOString(); return dt.toISOString();
} }
/** HH:MM auf 5-Minuten-Raster snappen (floor) */
function snapTo5(timeStr: string) {
const [hhRaw, mmRaw] = (timeStr || '00:00').split(':');
const hh = Math.max(0, Math.min(23, Number(hhRaw) || 0));
const mm = Math.max(0, Math.min(59, Number(mmRaw) || 0));
const mm5 = Math.floor(mm / 5) * 5;
const pad2 = (n:number)=>String(n).padStart(2,'0');
return `${pad2(hh)}:${pad2(mm5)}`;
}
export default function EditMatchMetaModal({ export default function EditMatchMetaModal({
show, show,
onClose, onClose,
@ -104,6 +114,7 @@ export default function EditMatchMetaModal({
const pad2 = (n:number)=>String(n).padStart(2,'0'); const pad2 = (n:number)=>String(n).padStart(2,'0');
const hours = Array.from({ length: 24 }, (_, i) => i); const hours = Array.from({ length: 24 }, (_, i) => i);
const quarters = [0, 15, 30, 45]; const quarters = [0, 15, 30, 45];
const minutes5 = [0,5,10,15,20,25,30,35,40,45,50,55];
// Map-Vote öffnet: Datum & Uhrzeit (lokal) // Map-Vote öffnet: Datum & Uhrzeit (lokal)
const [voteOpenDateStr, setVoteOpenDateStr] = useState<string>(''); // YYYY-MM-DD const [voteOpenDateStr, setVoteOpenDateStr] = useState<string>(''); // YYYY-MM-DD
@ -202,7 +213,7 @@ export default function EditMatchMetaModal({
const openISO = new Date(new Date(matchISO).getTime() - leadMin * 60_000).toISOString(); const openISO = new Date(new Date(matchISO).getTime() - leadMin * 60_000).toISOString();
const openDT = isoToLocalDateTimeStrings(openISO, userTZ); const openDT = isoToLocalDateTimeStrings(openISO, userTZ);
setVoteOpenDateStr(openDT.dateStr); setVoteOpenDateStr(openDT.dateStr);
setVoteOpenTimeStr(openDT.timeStr); setVoteOpenTimeStr(snapTo5(openDT.timeStr));
const boFromMeta = normalizeBestOf(j?.bestOf) const boFromMeta = normalizeBestOf(j?.bestOf)
setBestOf(boFromMeta) setBestOf(boFromMeta)
@ -450,18 +461,19 @@ export default function EditMatchMetaModal({
{hours.map(h => <option key={h} value={pad2(h)}>{pad2(h)}</option>)} {hours.map(h => <option key={h} value={pad2(h)}>{pad2(h)}</option>)}
</select> </select>
{/* Minuten: 00/15/30/45 */} {/* Minuten: 5er-Raster */}
<select <select
value={pad2(Math.round(Number((voteOpenTimeStr.split(':')[1] || '0')) / 15) * 15 % 60)} value={pad2(Number((voteOpenTimeStr.split(':')[1] || '0')))}
onChange={(e) => { onChange={(e) => {
const [hh] = (voteOpenTimeStr || '00:00').split(':'); const [hh] = (voteOpenTimeStr || '00:00').split(':');
setVoteOpenTimeStr(`${hh}:${pad2(Number(e.target.value))}`); setVoteOpenTimeStr(`${pad2(Number(hh)||0)}:${pad2(Number(e.target.value))}`);
}} }}
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50" className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
disabled={loadingMeta} disabled={loadingMeta}
> >
{[0,15,30,45].map(q => <option key={q} value={pad2(q)}>{pad2(q)}</option>)} {minutes5.map(m => <option key={m} value={pad2(m)}>{pad2(m)}</option>)}
</select> </select>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,6 @@
// /src/app/[locale]/components/EditMatchPlayersModal.tsx
'use client' 'use client'
import { useEffect, useState, useMemo } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { DndContext, closestCenter, DragOverlay, type DragStartEvent, type DragEndEvent } from '@dnd-kit/core' import { DndContext, closestCenter, DragOverlay, type DragStartEvent, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
@ -13,7 +12,6 @@ import { DroppableZone } from '../components/DroppableZone'
import type { Player, Team } from '../../../types/team' import type { Player, Team } from '../../../types/team'
/* ───────────────────────── Typen ────────────────────────── */
export type EditSide = 'A' | 'B' export type EditSide = 'A' | 'B'
interface Props { interface Props {
@ -22,30 +20,30 @@ interface Props {
matchId : string matchId : string
teamA : Team teamA : Team
teamB : Team teamB : Team
side : EditSide // welches Team wird editiert? side : EditSide
initialA: string[] // bereits eingesetzte Spieler-IDs initialA: string[]
initialB: string[] initialB: string[]
onSaved?: () => void onSaved?: () => void
} }
/* ───────────────────── Komponente ──────────────────────── */
export default function EditMatchPlayersModal (props: Props) { export default function EditMatchPlayersModal (props: Props) {
const { const { show, onClose, matchId, teamA, teamB, side, initialA, initialB, onSaved } = props
show, onClose, matchId,
teamA, teamB, side,
initialA, initialB,
onSaved,
} = props
/* ---- Rollen-Check --------------------------------------- */ /* ---- Rollen-Check --------------------------------------- */
const { data: session } = useSession() const { data: session } = useSession()
const meSteam = session?.user?.steamId const meSteam = session?.user?.steamId
const isAdmin = session?.user?.isAdmin const isAdmin = !!session?.user?.isAdmin
const isLeader = side === 'A' const isLeader = side === 'A'
? meSteam === teamA.leader?.steamId ? meSteam === teamA.leader?.steamId
: meSteam === teamB.leader?.steamId : meSteam === teamB.leader?.steamId
const canEdit = isAdmin || isLeader const canEdit = isAdmin || isLeader
/* ---- Team-Info ------------------------------------------ */
const team = side === 'A' ? teamA : teamB
const other = side === 'A' ? teamB : teamA
const otherInit = side === 'A' ? initialB : initialA
const myInit = side === 'A' ? initialA : initialB
/* ---- States --------------------------------------------- */ /* ---- States --------------------------------------------- */
const [players, setPlayers] = useState<Player[]>([]) const [players, setPlayers] = useState<Player[]>([])
const [selected, setSelected] = useState<string[]>([]) const [selected, setSelected] = useState<string[]>([])
@ -55,28 +53,42 @@ export default function EditMatchPlayersModal (props: Props) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
/* ---- Team-Info ------------------------------------------ */ // 🧷 Snapshots, die NUR beim Öffnen gesetzt werden
const team = side === 'A' ? teamA : teamB const myInitSnapRef = useRef<string[]>([])
const other = side === 'A' ? teamB : teamA const otherInitSnapRef = useRef<string[]>([])
const otherInit = side === 'A' ? initialB : initialA const otherInitSetRef = useRef<Set<string>>(new Set())
const myInit = side === 'A' ? initialA : initialB
// 🔧 NEU: schnelles Lookup der "verbotenen" Spieler (bereits im anderen Team) /* ---- Initialisierung NUR beim Öffnen -------------------- */
const otherInitSet = useMemo(() => new Set(otherInit), [otherInit]) useEffect(() => {
if (!show) return
// Inhalte zum Zeitpunkt des Öffnens sichern
myInitSnapRef.current = Array.isArray(myInit) ? [...myInit] : []
otherInitSnapRef.current = Array.isArray(otherInit) ? [...otherInit] : []
otherInitSetRef.current = new Set(otherInitSnapRef.current)
/* ---- Komplett-Spielerliste laden ------------------------ */ // Selected EINMAL setzen (und nicht mehr durch Polling überschreiben)
const initialSelected = myInitSnapRef.current
.filter(id => !otherInitSetRef.current.has(id))
.slice(0, 5)
setSelected(initialSelected)
setSaved(false)
setError(null)
// bewusst NUR an "show" hängen (Snapshot beim Öffnen)
}, [show]) // eslint-disable-line react-hooks/exhaustive-deps
/* ---- Team laden nur an show + team.id koppeln --------- */
useEffect(() => { useEffect(() => {
if (!show) return if (!show) return
if (!team?.id) { if (!team?.id) {
// ❗ Kein verknüpftes Team zeig einen klaren Hinweis
setPlayers([]) setPlayers([])
setSelected([])
setError('Kein Team mit diesem Match verknüpft (fehlende Team-ID).') setError('Kein Team mit diesem Match verknüpft (fehlende Team-ID).')
setLoading(false) setLoading(false)
return return
} }
const ctrl = new AbortController()
setLoading(true) setLoading(true)
setError(null) setError(null)
@ -84,6 +96,7 @@ export default function EditMatchPlayersModal (props: Props) {
try { try {
const res = await fetch(`/api/team/${encodeURIComponent(team.id)}`, { const res = await fetch(`/api/team/${encodeURIComponent(team.id)}`, {
cache: 'no-store', cache: 'no-store',
signal: ctrl.signal,
}) })
if (!res.ok) { if (!res.ok) {
setError(`Team-API: ${res.status}`) setError(`Team-API: ${res.status}`)
@ -99,25 +112,29 @@ export default function EditMatchPlayersModal (props: Props) {
.filter((p: Player) => !!p?.steamId) .filter((p: Player) => !!p?.steamId)
.filter((p: Player, i: number, arr: Player[]) => arr.findIndex(x => x.steamId === p.steamId) === i) .filter((p: Player, i: number, arr: Player[]) => arr.findIndex(x => x.steamId === p.steamId) === i)
// 🔧 NEU: Spieler entfernen, die im anderen Team bereits gesetzt sind // mit Snapshot des anderen Teams filtern (stabil während des Editierens)
const otherSet = otherInitSetRef.current
const all = allRaw const all = allRaw
.filter((p: Player) => !otherInitSet.has(p.steamId)) .filter((p: Player) => !otherSet.has(p.steamId))
.sort((a: Player, b: Player) => (a.name || '').localeCompare(b.name || '')) .sort((a: Player, b: Player) => (a.name || '').localeCompare(b.name || ''))
setPlayers(all) setPlayers(all)
setSelected(myInit.filter(id => !otherInitSet.has(id))) } catch (e: unknown) {
setSaved(false) const name = (e as { name?: unknown })?.name
} catch (e) { if (name !== 'AbortError') {
console.error('[EditMatchPlayersModal] load error:', e) console.error('[EditMatchPlayersModal] load error:', e)
setError('Laden fehlgeschlagen') setError('Laden fehlgeschlagen')
setPlayers([]) setPlayers([])
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
})() })()
}, [show, team?.id, myInit, otherInitSet])
/* ---- DragnDrop-Handler -------------------------------- */ return () => ctrl.abort()
}, [show, team?.id]) // ⚠️ keine Abhängigkeit von myInit/otherInit oder Sets (Refs sind stabil)
/* ---- DragnDrop ---------------------------------------- */
const onDragStart = ({ active }: DragStartEvent) => { const onDragStart = ({ active }: DragStartEvent) => {
setDragItem(players.find(p => p.steamId === active.id) ?? null) setDragItem(players.find(p => p.steamId === active.id) ?? null)
} }
@ -125,19 +142,13 @@ export default function EditMatchPlayersModal (props: Props) {
const onDragEnd = ({ active, over }: DragEndEvent) => { const onDragEnd = ({ active, over }: DragEndEvent) => {
setDragItem(null) setDragItem(null)
if (!over) return if (!over) return
const id = active.id as string const id = active.id as string
const dropZone = over.id as string // "active" | "inactive" const dropZone = over.id as string // "active" | "inactive"
const already = selected.includes(id) const already = selected.includes(id)
const toActive = dropZone === 'active' const toActive = dropZone === 'active'
if ((toActive && already) || (!toActive && !already)) return
if ( (toActive && already) || (!toActive && !already) ) return setSelected(sel => toActive ? [...sel, id].slice(0, 5) : sel.filter(x => x !== id))
setSelected(sel =>
toActive
? [...sel, id].slice(0, 5) // max 5 einsatzfähig
: sel.filter(x => x !== id),
)
} }
/* ---- Speichern ------------------------------------------ */ /* ---- Speichern ------------------------------------------ */
@ -147,23 +158,18 @@ export default function EditMatchPlayersModal (props: Props) {
const body = { const body = {
players: [ players: [
...selected.map(steamId => ({ steamId, teamId: team.id })), ...selected.map(steamId => ({ steamId, teamId: team.id })),
...otherInit.map(steamId => ({ steamId, teamId: other.id })), ...otherInitSnapRef.current.map(steamId => ({ steamId, teamId: other.id })),
], ],
} }
const res = await fetch(`/api/matches/${matchId}`, { const res = await fetch(`/api/matches/${matchId}`, {
method : 'PUT', method : 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(body), body : JSON.stringify(body),
}) })
if (!res.ok) throw new Error() if (!res.ok) throw new Error()
setSaved(true) setSaved(true)
// ⏳ 3 Sekunden warten, dann schließen und danach refreshen
setTimeout(() => { setTimeout(() => {
onClose?.() onClose?.()
// onSaved (z. B. router.refresh) im nächsten Tick nach dem Schließen
setTimeout(() => { onSaved?.() }, 0) setTimeout(() => { onSaved?.() }, 0)
}, 1500) }, 1500)
} catch (e) { } catch (e) {
@ -173,13 +179,11 @@ export default function EditMatchPlayersModal (props: Props) {
} }
} }
/* ---- Listen --------------------------------------------- */
/* ---- Listen trennen ------------------------------------- */
const active = players.filter(p => selected.includes(p.steamId)) const active = players.filter(p => selected.includes(p.steamId))
const inactive = players.filter(p => !selected.includes(p.steamId)) const inactive = players.filter(p => !selected.includes(p.steamId))
/* ---- UI -------------------------------------------------- */ /* ---- UI -------------------------------------------------- */
return ( return (
<Modal <Modal
id="edit-match-players" id="edit-match-players"
@ -187,9 +191,7 @@ export default function EditMatchPlayersModal (props: Props) {
show={show} show={show}
onClose={onClose} onClose={onClose}
onSave={handleSave} onSave={handleSave}
closeButtonTitle={ closeButtonTitle={ saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern' }
saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'
}
closeButtonColor={saved ? 'green' : 'blue'} closeButtonColor={saved ? 'green' : 'blue'}
disableSave={!canEdit || saving || !team?.id} disableSave={!canEdit || saving || !team?.id}
maxWidth='sm:max-w-2xl' maxWidth='sm:max-w-2xl'
@ -218,61 +220,48 @@ export default function EditMatchPlayersModal (props: Props) {
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
> >
{/* --- Zone: Aktuell eingestellte Spieler ------------- */}
<DroppableZone <DroppableZone
id="active" id="active"
className="mb-4" className="mb-4"
label={`Eingesetzte Spieler (${active.length} / 5)`} label={`Eingesetzte Spieler (${active.length} / 5)`}
activeDragItem={dragItem} activeDragItem={dragItem}
> >
<SortableContext <SortableContext items={active.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
items={active.map(p => p.steamId)}
strategy={verticalListSortingStrategy}
>
{active.map(p => ( {active.map(p => (
<SortableMiniCard <SortableMiniCard
key={p.steamId} key={p.steamId}
player={p} player={p}
currentUserSteamId={meSteam ?? ''} currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader?.steamId} teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin} isAdmin={isAdmin}
hideOverlay hideOverlay
/> />
))} ))}
</SortableContext> </SortableContext>
</DroppableZone> </DroppableZone>
{/* --- Zone: Verfügbar (restliche) ------------------- */} <DroppableZone id="inactive" label="Verfügbare Spieler" activeDragItem={dragItem}>
<DroppableZone <SortableContext items={inactive.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
id="inactive"
label="Verfügbare Spieler"
activeDragItem={dragItem}
>
<SortableContext
items={inactive.map(p => p.steamId)}
strategy={verticalListSortingStrategy}
>
{inactive.map(p => ( {inactive.map(p => (
<SortableMiniCard <SortableMiniCard
key={p.steamId} key={p.steamId}
player={p} player={p}
currentUserSteamId={meSteam ?? ''} currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader?.steamId} teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin} isAdmin={isAdmin}
hideOverlay hideOverlay
/> />
))} ))}
</SortableContext> </SortableContext>
</DroppableZone> </DroppableZone>
{/* Drag-Overlay */}
<DragOverlay> <DragOverlay>
{dragItem && ( {dragItem && (
<SortableMiniCard <SortableMiniCard
player={dragItem} player={dragItem}
currentUserSteamId={meSteam ?? ''} currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader?.steamId} teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin} isAdmin={isAdmin}
hideOverlay hideOverlay
/> />
)} )}

View File

@ -1069,6 +1069,11 @@ export default function MapVotePanel({ match }: Props) {
const taken = !!status const taken = !!status
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
const bgOpacityClass =
!taken && leftIsActiveTurn
? 'opacity-70' // sichtbarer, „weniger transparent“
: 'opacity-30' // Standard
const intent = isAvailable ? currentStep?.action : null const intent = isAvailable ? currentStep?.action : null
const intentStyles = const intentStyles =
intent === 'ban' intent === 'ban'
@ -1132,7 +1137,10 @@ export default function MapVotePanel({ match }: Props) {
onTouchEnd={onTouchEnd(map)} onTouchEnd={onTouchEnd(map)}
onTouchCancel={onTouchEnd(map)} onTouchCancel={onTouchEnd(map)}
> >
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} /> <div
className={`absolute inset-0 bg-center bg-cover filter ${bgOpacityClass} transition-opacity duration-300`}
style={{ backgroundImage: `url('${bg}')` }}
/>
{showProgress && ( {showProgress && (
<span aria-hidden className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-10`} style={{ width: `${Math.round(progress * 100)}%` }} /> <span aria-hidden className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-10`} style={{ width: `${Math.round(progress * 100)}%` }} />
)} )}

View File

@ -5,6 +5,7 @@
import { useState, useEffect, useMemo, useRef } from 'react' import { useState, useEffect, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import MapVoteBanner from './MapVoteBanner'
import Table from './Table' import Table from './Table'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
import CompRankBadge from './CompRankBadge' import CompRankBadge from './CompRankBadge'
@ -14,7 +15,6 @@ import type { EditSide } from './EditMatchPlayersModal'
import type { Match, MatchPlayer } from '../../../types/match' import type { Match, MatchPlayer } from '../../../types/match'
import Button from './Button' import Button from './Button'
import { MAP_OPTIONS } from '@/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import MapVoteBanner from './MapVoteBanner'
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { Team } from '../../../types/team' import { Team } from '../../../types/team'
import Alert from './Alert' import Alert from './Alert'
@ -911,10 +911,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
{/* Team A */} {/* Team A */}
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isCommunity && match.teamA?.logo && ( {isCommunity && (
<Image <Image
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`} src={match.teamA?.logo
alt={match.teamA.name ?? 'Team A'} ? `/assets/img/logos/${match.teamA.logo}`
: `/assets/img/logos/cs2.webp`}
alt={match.teamA?.name ?? 'Team A'}
width={48} // ✅ feste Abmessungen angeben
height={48}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12" className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
/> />
)} )}
@ -951,10 +955,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
{match.teamB?.name ?? 'Unbekannt'} {match.teamB?.name ?? 'Unbekannt'}
</div> </div>
</div> </div>
{isCommunity && match.teamB?.logo && ( {isCommunity && (
<Image <Image
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`} src={match.teamB?.logo
alt={match.teamB.name ?? 'Team B'} ? `/assets/img/logos/${match.teamB.logo}`
: `/assets/img/logos/cs2.webp`}
alt={match.teamB?.name ?? 'Team B'}
width={48} // ✅ feste Abmessungen angeben
height={48}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12" className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
/> />
)} )}

View File

@ -519,6 +519,7 @@ export default function MatchReadyOverlay({
className="absolute inset-0 object-cover" className="absolute inset-0 object-cover"
decoding="async" decoding="async"
priority priority
unoptimized
/> />
<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>

View File

@ -20,6 +20,13 @@ function parseTeams(json: TeamsJson): TeamLike[] {
return [] return []
} }
const resolveLogoSrc = (logo?: string | null): string => {
if (!logo) return '/assets/img/logos/cs2.webp' // Fallback im /public
if (logo.startsWith('http://') || logo.startsWith('https://')) return logo
if (logo.startsWith('/')) return logo // bereits ab Root
return `/assets/img/logos/${logo}` // Dateiname -> public-Pfad
}
export default function Dashboard() { export default function Dashboard() {
const t = useTranslations('dashboard') const t = useTranslations('dashboard')
@ -156,15 +163,21 @@ export default function Dashboard() {
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{teams.map((t) => { {teams.map((t) => {
const label = t.teamname ?? t.name ?? 'Unbenannt' const label = t.teamname ?? t.name ?? 'Unbenannt'
const logoPath = t.logo ? `/assets/img/logos/${t.logo}` : '/assets/img/logos/cs2.webp' const logoSrc = resolveLogoSrc(t.logo)
return ( return (
<li <li
key={t.id ?? label} key={t.id ?? label}
className="group rounded-lg border border-gray-200 p-4 transition hover:shadow-sm dark:border-neutral-800" className="group rounded-lg border border-gray-200 p-4 transition hover:shadow-sm dark:border-neutral-800"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative h-10 w-10 overflow-hidden rounded-full ring-1 ring-gray-200 dark:ring-neutral-700"> <div className="overflow-hidden rounded-full ring-1 ring-gray-200 dark:ring-neutral-700">
<Image src={logoPath} alt={label} fill className="object-cover" sizes="40px" /> <Image
src={logoSrc}
alt={label}
width={40}
height={40}
className="h-10 w-10 object-cover"
/>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{label}</div> <div className="truncate text-sm font-medium text-gray-900 dark:text-white">{label}</div>

View File

@ -26,8 +26,14 @@ export default function MatchesPage() {
}, []) }, [])
return ( return (
<Card maxWidth='auto'> <div className="h-[calc(100vh-32px)] min-h-0"> {/* Höhe vorgeben + min-h-0 */}
<Card
maxWidth="auto"
className="h-full flex flex-col min-h-0 " // Card selber als Flex-Container
bodyScrollable // falls dein Card das Flag braucht
>
<CommunityMatchList matchType="community" /> <CommunityMatchList matchType="community" />
</Card> </Card>
</div>
) )
} }

View File

@ -30,9 +30,9 @@ export async function POST(req: NextRequest) {
// Panel-URL aus ENV (wie in mapvote) // Panel-URL aus ENV (wie in mapvote)
const panelBase = const panelBase =
(process.env.PTERO_PANEL_URL ?? process.env.NEXT_PUBLIC_PTERO_PANEL_URL ?? '').trim() (process.env.PTERODACTYL_PANEL_URL ?? process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ?? '').trim()
if (!panelBase) { if (!panelBase) {
return NextResponse.json({ error: 'PTERO_PANEL_URL not set' }, { status: 500 }) return NextResponse.json({ error: 'PTERODACTYL_PANEL_URL not set' }, { status: 500 })
} }
// Server-ID aus DB (optional via Body überschreibbar) // Server-ID aus DB (optional via Body überschreibbar)

View File

@ -119,11 +119,11 @@ function buildPteroClientUrl(base: string, serverId: string) {
async function sendServerCommand(command: string) { async function sendServerCommand(command: string) {
try { try {
const panelBase = const panelBase =
process.env.PTERO_PANEL_URL || process.env.PTERODACTYL_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERO_PANEL_URL || process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
'' ''
if (!panelBase) { if (!panelBase) {
console.warn('[mapvote] PTERO_PANEL_URL fehlt Command wird nicht gesendet.') console.warn('[mapvote] PTERODACTYL_PANEL_URL fehlt Command wird nicht gesendet.')
return return
} }
@ -419,6 +419,24 @@ type MatchLike = {
type MapVoteStep = { action: 'ban' | 'pick' | 'decider'; map?: string | null; teamId?: string | null } type MapVoteStep = { action: 'ban' | 'pick' | 'decider'; map?: string | null; teamId?: string | null }
type MapVoteStateForExport = { bestOf?: number; steps: MapVoteStep[]; locked?: boolean } type MapVoteStateForExport = { bestOf?: number; steps: MapVoteStep[]; locked?: boolean }
const SPECTATORS = [
['76561198006541937', 'Mützchen'],
['76561197971310489', 'Fail'],
['76561198006697116', 'Schlomo'],
['76561198153867594', 'Luther'],
['76561198022207129', 'Lizec'],
['76561198132260879', 'SuperDuperDaria'],
['76561198006470215', 'Jagarion'],
['76561198986703551', 'captainanni'],
['76561198063413621', 'Litboi'],
['76561198133191395', 'Windy'],
['76561198094445415', 'Torrul'],
['76561199026888445', 'Flix Geduldnix'],
] as const;
const SPECTATORS_MAP: Record<string, string> =
Object.fromEntries(SPECTATORS) as Record<string, string>;
function playersMapFromList(list: PlayerLike[] | undefined) { function playersMapFromList(list: PlayerLike[] | undefined) {
const out: Record<string, string> = {} const out: Record<string, string> = {}
for (const p of list ?? []) { for (const p of list ?? []) {
@ -453,17 +471,21 @@ 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() const rndId = makeRandomMatchId()
return { return {
matchid: rndId, // vorher: "" matchid: rndId,
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,
maplist, maplist,
map_sides, map_sides,
spectators: { players: {} as Record<string, string> },
// ⬇️ NEU: feste Spectators mitsenden
spectators: {
players: SPECTATORS_MAP,
},
clinch_series: true, clinch_series: true,
players_per_team: 5, players_per_team: 5,
cvars: { cvars: {
@ -473,6 +495,7 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
} }
} }
async function exportMatchToSftpDirect(match: MatchDb, vote: VoteDb) { async function exportMatchToSftpDirect(match: MatchDb, vote: VoteDb) {
try { try {
const SFTPClient = (await import('ssh2-sftp-client')).default const SFTPClient = (await import('ssh2-sftp-client')).default

View File

@ -2,7 +2,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
const PANEL = process.env.PTERO_PANEL_URL! const PANEL = process.env.PTERODACTYL_PANEL_URL!
const KEY = process.env.PTERODACTYL_CLIENT_API! const KEY = process.env.PTERODACTYL_CLIENT_API!
const SID = process.env.PTERO_SERVER_ID! const SID = process.env.PTERO_SERVER_ID!

View File

@ -5,6 +5,10 @@ import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client' import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
import 'server-only';
export async function POST() { export async function POST() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId // <-- hier definieren const steamId = session?.user?.steamId // <-- hier definieren

View File

@ -439,7 +439,8 @@ const config = {
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {
"rootEnvPath": null "rootEnvPath": null,
"schemaEnvPath": "../../../.env"
}, },
"relativePath": "../../../prisma", "relativePath": "../../../prisma",
"clientVersion": "6.17.1", "clientVersion": "6.17.1",
@ -448,7 +449,6 @@ const config = {
"db" "db"
], ],
"activeProvider": "postgresql", "activeProvider": "postgresql",
"postinstall": false,
"inlineDatasources": { "inlineDatasources": {
"db": { "db": {
"url": { "url": {

View File

@ -440,7 +440,8 @@ const config = {
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {
"rootEnvPath": null "rootEnvPath": null,
"schemaEnvPath": "../../../.env"
}, },
"relativePath": "../../../prisma", "relativePath": "../../../prisma",
"clientVersion": "6.17.1", "clientVersion": "6.17.1",
@ -449,7 +450,6 @@ const config = {
"db" "db"
], ],
"activeProvider": "postgresql", "activeProvider": "postgresql",
"postinstall": false,
"inlineDatasources": { "inlineDatasources": {
"db": { "db": {
"url": { "url": {

View File

@ -439,7 +439,8 @@ const config = {
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {
"rootEnvPath": null "rootEnvPath": null,
"schemaEnvPath": "../../../.env"
}, },
"relativePath": "../../../prisma", "relativePath": "../../../prisma",
"clientVersion": "6.17.1", "clientVersion": "6.17.1",
@ -448,7 +449,6 @@ const config = {
"db" "db"
], ],
"activeProvider": "postgresql", "activeProvider": "postgresql",
"postinstall": false,
"inlineDatasources": { "inlineDatasources": {
"db": { "db": {
"url": { "url": {

View File

@ -3,7 +3,5 @@
import {createNavigation} from 'next-intl/navigation'; import {createNavigation} from 'next-intl/navigation';
import {routing} from './routing'; import {routing} from './routing';
// Lightweight wrappers around Next.js' navigation
// APIs that consider the routing configuration
export const {Link, redirect, usePathname, useRouter, getPathname} = export const {Link, redirect, usePathname, useRouter, getPathname} =
createNavigation(routing); createNavigation(routing);

View File

@ -1,15 +1,14 @@
// /src/i18n/request.ts // /src/i18n/request.ts
export const runtime = 'nodejs'; // wichtig: nicht Edge import {getRequestConfig} from 'next-intl/server'
import {hasLocale} from 'next-intl'
import {routing} from './routing'
import {getRequestConfig} from 'next-intl/server'; export default getRequestConfig(async ({requestLocale}) => {
import {hasLocale} from 'next-intl'; const requested = await requestLocale
import {routing} from './routing'; const locale = hasLocale(routing.locales, requested) ? (requested as string) : routing.defaultLocale
export default getRequestConfig(async ({locale}) => { // ⬇️ Eine JSON pro Locale laden
const effective = const messages = (await import(`../messages/${locale}.json`)).default
hasLocale(routing.locales, locale) ? (locale as string) : routing.defaultLocale;
const messages = (await import(`../messages/${effective}.json`)).default; return { locale, messages }
})
return { locale: effective, messages };
});

View File

@ -1,6 +1,6 @@
// /src/lib/sse-server-client.ts // /src/lib/sse-server-client.ts
const host = process.env.NEXT_PUBLIC_SSE_URL const host = process.env.NEXTAUTH_URL
export type ServerSSEMessage = { export type ServerSSEMessage = {
type: string type: string
@ -8,8 +8,8 @@ export type ServerSSEMessage = {
export async function sendServerSSEMessage(message: ServerSSEMessage): Promise<void> { export async function sendServerSSEMessage(message: ServerSSEMessage): Promise<void> {
try { try {
console.log('Sending message:', message) //console.log('Sending message:', message)
const res = await fetch(`http://${host}/send`, { const res = await fetch(`${host}/send`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message), body: JSON.stringify(message),

View File

@ -84,7 +84,7 @@ export const useSSEStore = create<SSEState>((set, get) => {
set({ source: null, isConnected: false }); set({ source: null, isConnected: false });
} }
const base = process.env.NEXT_PUBLIC_SSE_URL const base = process.env.NEXT_PUBLIC_APP_URL
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`; const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`;
const source = new EventSource(url); const source = new EventSource(url);

View File

@ -1,4 +1,4 @@
// /src/middleware.ts // middleware.ts
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import createIntlMiddleware from 'next-intl/middleware'; import createIntlMiddleware from 'next-intl/middleware';
@ -7,10 +7,12 @@ import { routing } from './i18n/routing';
const handleI18n = createIntlMiddleware(routing); const handleI18n = createIntlMiddleware(routing);
// ---- Type-Guard: prüft, ob ein Objekt eine boolsche isAdmin-Property hat
function hasAdminFlag(v: unknown): v is { isAdmin: boolean } { function hasAdminFlag(v: unknown): v is { isAdmin: boolean } {
return typeof v === 'object' && v !== null && return typeof v === 'object' && v !== null &&
typeof (v as Record<string, unknown>).isAdmin === 'boolean'; typeof (v as Record<string, unknown>).isAdmin === 'boolean';
} }
function getCurrentLocaleFromPath(pathname: string, locales: readonly string[], fallback: string) { function getCurrentLocaleFromPath(pathname: string, locales: readonly string[], fallback: string) {
const first = pathname.split('/')[1]; const first = pathname.split('/')[1];
return locales.includes(first) ? first : fallback; return locales.includes(first) ? first : fallback;
@ -26,7 +28,7 @@ function stripLeadingLocale(pathname: string, locales: readonly string[]) {
} }
function isProtectedPath(pathnameNoLocale: string) { function isProtectedPath(pathnameNoLocale: string) {
return ( return (
pathnameNoLocale === '/' || pathnameNoLocale.startsWith('/') ||
pathnameNoLocale.startsWith('/settings') || pathnameNoLocale.startsWith('/settings') ||
pathnameNoLocale.startsWith('/matches') || pathnameNoLocale.startsWith('/matches') ||
pathnameNoLocale.startsWith('/team') || pathnameNoLocale.startsWith('/team') ||
@ -52,31 +54,10 @@ export default async function middleware(req: NextRequest) {
} }
const i18nRes = handleI18n(req); const i18nRes = handleI18n(req);
if (i18nRes.headers.get('location') || i18nRes.headers.get('x-middleware-rewrite')) {
// 1) Rewrites -> immer auf aktuelle öffentliche Origin
const rew = i18nRes.headers.get('x-middleware-rewrite');
if (rew) {
const t = new URL(rew, req.url);
const out = new URL(req.url);
out.pathname = t.pathname;
out.search = t.search;
return NextResponse.rewrite(out);
}
// 2) Redirects (Location) -> ebenfalls auf aktuelle Origin mappen
const loc = i18nRes.headers.get('location');
if (loc) {
const t = new URL(loc, req.url);
if (t.hostname !== req.nextUrl.hostname) {
const out = new URL(req.url);
out.pathname = t.pathname;
out.search = t.search;
return NextResponse.redirect(out); // 307
}
return i18nRes; return i18nRes;
} }
// 3) Geschützte Bereiche
const { locales, defaultLocale } = routing; const { locales, defaultLocale } = routing;
const url = req.nextUrl; const url = req.nextUrl;
const pathnameNoLocale = stripLeadingLocale(pathname, locales); const pathnameNoLocale = stripLeadingLocale(pathname, locales);
@ -86,6 +67,7 @@ export default async function middleware(req: NextRequest) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
// Adminschutz (ohne any)
if (pathnameNoLocale.startsWith('/admin')) { if (pathnameNoLocale.startsWith('/admin')) {
const isAdmin = hasAdminFlag(token) && token.isAdmin === true; const isAdmin = hasAdminFlag(token) && token.isAdmin === true;
if (!isAdmin) { if (!isAdmin) {
@ -96,6 +78,7 @@ export default async function middleware(req: NextRequest) {
} }
} }
// Allgemeiner Auth-Schutz
if (!token) { if (!token) {
const loginUrl = new URL('/api/auth/signin', req.url); const loginUrl = new URL('/api/auth/signin', req.url);
loginUrl.searchParams.set('callbackUrl', url.toString()); loginUrl.searchParams.set('callbackUrl', url.toString());

View File

@ -12,7 +12,7 @@ export const CS2_DOWNLOADER_URL =
process.env.CS2_DOWNLOADER_URL ?? 'http://localhost:4000'; process.env.CS2_DOWNLOADER_URL ?? 'http://localhost:4000';
export const CS2_INTERNAL_API_URL = export const CS2_INTERNAL_API_URL =
process.env.CS2_INTERNAL_API_URL ?? 'http://localhost:3000'; process.env.CS2_INTERNAL_API_URL ?? 'http://localhost:30000';
export const DEMOS_ROOT = export const DEMOS_ROOT =
process.env.DEMOS_ROOT ?? 'demos'; process.env.DEMOS_ROOT ?? 'demos';

View File

@ -36,7 +36,7 @@ export async function parseDemoViaGo(
const parserPath = path.resolve( const parserPath = path.resolve(
__dirname, __dirname,
'../../../../ironie-cs2-parser/parser_cs2-win.exe' '../../../../ironie-cs2-parser/parser_cs2-linux'
); );
const decoded = decodeMatchShareCode(shareCode); const decoded = decodeMatchShareCode(shareCode);
const matchId = decoded.matchId.toString(); const matchId = decoded.matchId.toString();