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

19
.env
View File

@ -9,19 +9,20 @@ SHARE_CODE_SECRET_KEY=6f9d4a2951b8eae35cdd3fb28e1a74550d177c3900ad1111c8e48b4e3b
SHARE_CODE_IV=9f1d67b8a3c4d261fa2b7c44a1d4f9c8
STEAM_API_KEY=0B3B2BF79ECD1E9262BB118A7FEF1973
NEXTAUTH_SECRET=ironieopen
NEXTAUTH_URL=https://new.ironieopen.de
NEXT_PUBLIC_APP_URL=https://new.ironieopen.de
#NEXTAUTH_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
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
PTERODACTYL_CLIENT_API=ptlc_c31BKDEXy63fHUxeQDahk6eeC3CL19TpG2rgao7mUl5
PTERODACTYL_APP_API=ptla_6IcEHfK0CMiA5clzFSGXPEhczC9jTRZz7pr8sNn1iSB
PTERODACTYL_CLIENT_API=ptlc_hSYxGaVlp7dklvSWAcuGjjGfcBiCaAedKXYnI3SKMV3
PTERODACTYL_PANEL_URL=https://panel.ironieopen.de
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_ID=37a11489
NEXT_PUBLIC_SSE_URL=https://new.ironieopen.de/events
PTERO_SERVER_ID=acdef8fc
TRUST_PROXY=1
# META (vom CS2-Server-Plugin)
NEXT_PUBLIC_CS2_TELEMETRY_WS_HOST=new.ironieopen.de
@ -37,4 +38,4 @@ NEXT_PUBLIC_CS2_GAME_WS_SCHEME=wss
NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000"
FACEIT_API_KEY=28ff4916-65da-4415-ba67-3d6d6b5dc850
FACEIT_API_KEY=28ff4916-65da-4415-ba67-3d6d6b5dc850

View File

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

View File

@ -35,29 +35,31 @@ export default function TeamAdminClient({ teamId }: Props) {
const steamId = session?.user?.steamId
if (!steamId) return
const base = process.env.NEXT_PUBLIC_SSE_URL
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`
let es: EventSource | null = new EventSource(url, { withCredentials: false })
const raw = process.env.NEXT_PUBLIC_APP_URL?.trim()
let origin: string
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) => {
try {
const msg = JSON.parse((ev as MessageEvent).data as string)
if (msg.teamId === teamId) {
fetchTeam()
}
} catch (e) {
console.error('[SSE] parse error:', e)
}
if (msg.teamId === teamId) fetchTeam()
} catch (e) { console.error('[SSE] parse error:', e) }
}
es.addEventListener('team-updated', onTeamUpdated)
es.onerror = () => {
es?.close()
es = null
es?.close(); es = null
setTimeout(() => {
const next = new EventSource(url, { withCredentials: false })
const next = new EventSource(u.toString(), { withCredentials: false })
next.addEventListener('team-updated', onTeamUpdated)
next.onerror = () => { next.close() }
es = next

View File

@ -76,6 +76,16 @@ function combineLocalDateTime(dateStr: string, timeStr: string) {
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({
show,
onClose,
@ -104,6 +114,7 @@ export default function EditMatchMetaModal({
const pad2 = (n:number)=>String(n).padStart(2,'0');
const hours = Array.from({ length: 24 }, (_, i) => i);
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)
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 openDT = isoToLocalDateTimeStrings(openISO, userTZ);
setVoteOpenDateStr(openDT.dateStr);
setVoteOpenTimeStr(openDT.timeStr);
setVoteOpenTimeStr(snapTo5(openDT.timeStr));
const boFromMeta = normalizeBestOf(j?.bestOf)
setBestOf(boFromMeta)
@ -450,18 +461,19 @@ export default function EditMatchMetaModal({
{hours.map(h => <option key={h} value={pad2(h)}>{pad2(h)}</option>)}
</select>
{/* Minuten: 00/15/30/45 */}
{/* Minuten: 5er-Raster */}
<select
value={pad2(Math.round(Number((voteOpenTimeStr.split(':')[1] || '0')) / 15) * 15 % 60)}
value={pad2(Number((voteOpenTimeStr.split(':')[1] || '0')))}
onChange={(e) => {
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"
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>
</div>
</div>
</div>

View File

@ -1,19 +1,17 @@
// /src/app/[locale]/components/EditMatchPlayersModal.tsx
'use client'
import { useEffect, useState, useMemo } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useSession } from 'next-auth/react'
import { DndContext, closestCenter, DragOverlay, type DragStartEvent, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import Modal from '../components/Modal'
import Modal from '../components/Modal'
import SortableMiniCard from '../components/SortableMiniCard'
import LoadingSpinner from '../components/LoadingSpinner'
import LoadingSpinner from '../components/LoadingSpinner'
import { DroppableZone } from '../components/DroppableZone'
import type { Player, Team } from '../../../types/team'
/* ───────────────────────── Typen ────────────────────────── */
export type EditSide = 'A' | 'B'
interface Props {
@ -22,61 +20,75 @@ interface Props {
matchId : string
teamA : Team
teamB : Team
side : EditSide // welches Team wird editiert?
initialA: string[] // bereits eingesetzte Spieler-IDs
side : EditSide
initialA: string[]
initialB: string[]
onSaved?: () => void
}
/* ───────────────────── Komponente ──────────────────────── */
export default function EditMatchPlayersModal (props: Props) {
const {
show, onClose, matchId,
teamA, teamB, side,
initialA, initialB,
onSaved,
} = props
const { show, onClose, matchId, teamA, teamB, side, initialA, initialB, onSaved } = props
/* ---- Rollen-Check --------------------------------------- */
const { data: session } = useSession()
const meSteam = session?.user?.steamId
const isAdmin = session?.user?.isAdmin
const isAdmin = !!session?.user?.isAdmin
const isLeader = side === 'A'
? meSteam === teamA.leader?.steamId
: meSteam === teamB.leader?.steamId
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 --------------------------------------------- */
const [players, setPlayers] = useState<Player[]>([])
const [selected, setSelected] = useState<string[]>([])
const [dragItem, setDragItem] = useState<Player | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [players, setPlayers] = useState<Player[]>([])
const [selected, setSelected] = useState<string[]>([])
const [dragItem, setDragItem] = useState<Player | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
/* ---- 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
// 🧷 Snapshots, die NUR beim Öffnen gesetzt werden
const myInitSnapRef = useRef<string[]>([])
const otherInitSnapRef = useRef<string[]>([])
const otherInitSetRef = useRef<Set<string>>(new Set())
// 🔧 NEU: schnelles Lookup der "verbotenen" Spieler (bereits im anderen Team)
const otherInitSet = useMemo(() => new Set(otherInit), [otherInit])
/* ---- Initialisierung NUR beim Öffnen -------------------- */
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(() => {
if (!show) return
if (!team?.id) {
// ❗ Kein verknüpftes Team zeig einen klaren Hinweis
setPlayers([])
setSelected([])
setError('Kein Team mit diesem Match verknüpft (fehlende Team-ID).')
setLoading(false)
return
}
const ctrl = new AbortController()
setLoading(true)
setError(null)
@ -84,6 +96,7 @@ export default function EditMatchPlayersModal (props: Props) {
try {
const res = await fetch(`/api/team/${encodeURIComponent(team.id)}`, {
cache: 'no-store',
signal: ctrl.signal,
})
if (!res.ok) {
setError(`Team-API: ${res.status}`)
@ -96,28 +109,32 @@ export default function EditMatchPlayersModal (props: Props) {
...(data.activePlayers ?? []),
...(data.inactivePlayers ?? []),
]
.filter((p: Player) => !!p?.steamId)
.filter((p: Player, i: number, arr: Player[]) => arr.findIndex(x => x.steamId === p.steamId) === i)
.filter((p: Player) => !!p?.steamId)
.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
.filter((p: Player) => !otherInitSet.has(p.steamId))
.filter((p: Player) => !otherSet.has(p.steamId))
.sort((a: Player, b: Player) => (a.name || '').localeCompare(b.name || ''))
setPlayers(all)
setSelected(myInit.filter(id => !otherInitSet.has(id)))
setSaved(false)
} catch (e) {
console.error('[EditMatchPlayersModal] load error:', e)
setError('Laden fehlgeschlagen')
setPlayers([])
} catch (e: unknown) {
const name = (e as { name?: unknown })?.name
if (name !== 'AbortError') {
console.error('[EditMatchPlayersModal] load error:', e)
setError('Laden fehlgeschlagen')
setPlayers([])
}
} finally {
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) => {
setDragItem(players.find(p => p.steamId === active.id) ?? null)
}
@ -125,19 +142,13 @@ export default function EditMatchPlayersModal (props: Props) {
const onDragEnd = ({ active, over }: DragEndEvent) => {
setDragItem(null)
if (!over) return
const id = active.id as string
const dropZone = over.id as string // "active" | "inactive"
const already = selected.includes(id)
const toActive = dropZone === 'active'
if ((toActive && already) || (!toActive && !already)) return
const id = active.id as string
const dropZone = over.id as string // "active" | "inactive"
const already = selected.includes(id)
const toActive = dropZone === 'active'
if ( (toActive && already) || (!toActive && !already) ) return
setSelected(sel =>
toActive
? [...sel, id].slice(0, 5) // max 5 einsatzfähig
: sel.filter(x => x !== id),
)
setSelected(sel => toActive ? [...sel, id].slice(0, 5) : sel.filter(x => x !== id))
}
/* ---- Speichern ------------------------------------------ */
@ -147,23 +158,18 @@ export default function EditMatchPlayersModal (props: Props) {
const body = {
players: [
...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}`, {
method : 'PUT',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(body),
})
if (!res.ok) throw new Error()
setSaved(true)
// ⏳ 3 Sekunden warten, dann schließen und danach refreshen
setTimeout(() => {
onClose?.()
// onSaved (z. B. router.refresh) im nächsten Tick nach dem Schließen
setTimeout(() => { onSaved?.() }, 0)
}, 1500)
} catch (e) {
@ -173,13 +179,11 @@ export default function EditMatchPlayersModal (props: Props) {
}
}
/* ---- Listen trennen ------------------------------------- */
/* ---- Listen --------------------------------------------- */
const active = players.filter(p => selected.includes(p.steamId))
const inactive = players.filter(p => !selected.includes(p.steamId))
/* ---- UI -------------------------------------------------- */
return (
<Modal
id="edit-match-players"
@ -187,9 +191,7 @@ export default function EditMatchPlayersModal (props: Props) {
show={show}
onClose={onClose}
onSave={handleSave}
closeButtonTitle={
saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'
}
closeButtonTitle={ saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern' }
closeButtonColor={saved ? 'green' : 'blue'}
disableSave={!canEdit || saving || !team?.id}
maxWidth='sm:max-w-2xl'
@ -201,78 +203,65 @@ export default function EditMatchPlayersModal (props: Props) {
)}
{canEdit && (
<>
{loading && <LoadingSpinner />}
<>
{loading && <LoadingSpinner />}
{!loading && error && (
<p className="text-sm text-red-600">Fehler: {error}</p>
)}
{!loading && error && (
<p className="text-sm text-red-600">Fehler: {error}</p>
)}
{!loading && !error && players.length === 0 && (
<p className="text-sm text-gray-500">Keine Spieler gefunden.</p>
)}
{!loading && !error && players.length === 0 && (
<p className="text-sm text-gray-500">Keine Spieler gefunden.</p>
)}
{!loading && !error && players.length > 0 && (
<DndContext
collisionDetection={closestCenter}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{/* --- Zone: Aktuell eingestellte Spieler ------------- */}
{!loading && !error && players.length > 0 && (
<DndContext
collisionDetection={closestCenter}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<DroppableZone
id="active"
className="mb-4"
label={`Eingesetzte Spieler (${active.length} / 5)`}
activeDragItem={dragItem}
>
<SortableContext
items={active.map(p => p.steamId)}
strategy={verticalListSortingStrategy}
>
<SortableContext items={active.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{active.map(p => (
<SortableMiniCard
key={p.steamId}
player={p}
currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin}
isAdmin={isAdmin}
hideOverlay
/>
))}
</SortableContext>
</DroppableZone>
{/* --- Zone: Verfügbar (restliche) ------------------- */}
<DroppableZone
id="inactive"
label="Verfügbare Spieler"
activeDragItem={dragItem}
>
<SortableContext
items={inactive.map(p => p.steamId)}
strategy={verticalListSortingStrategy}
>
<DroppableZone id="inactive" label="Verfügbare Spieler" activeDragItem={dragItem}>
<SortableContext items={inactive.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{inactive.map(p => (
<SortableMiniCard
key={p.steamId}
player={p}
currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin}
isAdmin={isAdmin}
hideOverlay
/>
))}
</SortableContext>
</DroppableZone>
{/* Drag-Overlay */}
<DragOverlay>
{dragItem && (
<SortableMiniCard
player={dragItem}
currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin}
isAdmin={isAdmin}
hideOverlay
/>
)}

View File

@ -1069,6 +1069,11 @@ export default function MapVotePanel({ match }: Props) {
const taken = !!status
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 intentStyles =
intent === 'ban'
@ -1132,7 +1137,10 @@ export default function MapVotePanel({ match }: Props) {
onTouchEnd={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 && (
<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 { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import MapVoteBanner from './MapVoteBanner'
import Table from './Table'
import PremierRankBadge from './PremierRankBadge'
import CompRankBadge from './CompRankBadge'
@ -14,7 +15,6 @@ import type { EditSide } from './EditMatchPlayersModal'
import type { Match, MatchPlayer } from '../../../types/match'
import Button from './Button'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import MapVoteBanner from './MapVoteBanner'
import { useSSEStore } from '@/lib/useSSEStore'
import { Team } from '../../../types/team'
import Alert from './Alert'
@ -911,10 +911,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
{/* Team A */}
<div className="min-w-0">
<div className="flex items-center gap-3">
{isCommunity && match.teamA?.logo && (
{isCommunity && (
<Image
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamA.name ?? 'Team A'}
src={match.teamA?.logo
? `/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"
/>
)}
@ -951,10 +955,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
{match.teamB?.name ?? 'Unbekannt'}
</div>
</div>
{isCommunity && match.teamB?.logo && (
{isCommunity && (
<Image
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamB.name ?? 'Team B'}
src={match.teamB?.logo
? `/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"
/>
)}

View File

@ -519,6 +519,7 @@ export default function MatchReadyOverlay({
className="absolute inset-0 object-cover"
decoding="async"
priority
unoptimized
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
</div>

View File

@ -20,6 +20,13 @@ function parseTeams(json: TeamsJson): TeamLike[] {
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() {
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">
{teams.map((t) => {
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 (
<li
key={t.id ?? label}
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="relative h-10 w-10 overflow-hidden rounded-full ring-1 ring-gray-200 dark:ring-neutral-700">
<Image src={logoPath} alt={label} fill className="object-cover" sizes="40px" />
<div className="overflow-hidden rounded-full ring-1 ring-gray-200 dark:ring-neutral-700">
<Image
src={logoSrc}
alt={label}
width={40}
height={40}
className="h-10 w-10 object-cover"
/>
</div>
<div className="min-w-0">
<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 (
<Card maxWidth='auto'>
<CommunityMatchList matchType="community" />
</Card>
<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" />
</Card>
</div>
)
}

View File

@ -30,9 +30,9 @@ export async function POST(req: NextRequest) {
// Panel-URL aus ENV (wie in mapvote)
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) {
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)

View File

@ -119,11 +119,11 @@ function buildPteroClientUrl(base: string, serverId: string) {
async function sendServerCommand(command: string) {
try {
const panelBase =
process.env.PTERO_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERO_PANEL_URL ||
process.env.PTERODACTYL_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
''
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
}
@ -419,6 +419,24 @@ type MatchLike = {
type MapVoteStep = { action: 'ban' | 'pick' | 'decider'; map?: string | null; teamId?: string | null }
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) {
const out: Record<string, string> = {}
for (const p of list ?? []) {
@ -453,17 +471,21 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
const team1Players = playersMapFromList(match.teamA?.players)
const team2Players = playersMapFromList(match.teamB?.players)
// 👇 hier neu: zufällige Integer-ID
const rndId = makeRandomMatchId()
return {
matchid: rndId, // vorher: ""
matchid: rndId,
team1: { name: team1Name, players: team1Players },
team2: { name: team2Name, players: team2Players },
num_maps: bestOf,
maplist,
map_sides,
spectators: { players: {} as Record<string, string> },
// ⬇️ NEU: feste Spectators mitsenden
spectators: {
players: SPECTATORS_MAP,
},
clinch_series: true,
players_per_team: 5,
cvars: {
@ -473,6 +495,7 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
}
}
async function exportMatchToSftpDirect(match: MatchDb, vote: VoteDb) {
try {
const SFTPClient = (await import('ssh2-sftp-client')).default

View File

@ -2,7 +2,7 @@
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 SID = process.env.PTERO_SERVER_ID!

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,14 @@
// /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';
import {hasLocale} from 'next-intl';
import {routing} from './routing';
export default getRequestConfig(async ({requestLocale}) => {
const requested = await requestLocale
const locale = hasLocale(routing.locales, requested) ? (requested as string) : routing.defaultLocale
export default getRequestConfig(async ({locale}) => {
const effective =
hasLocale(routing.locales, locale) ? (locale as string) : routing.defaultLocale;
// ⬇️ Eine JSON pro Locale laden
const messages = (await import(`../messages/${locale}.json`)).default
const messages = (await import(`../messages/${effective}.json`)).default;
return { locale: effective, messages };
});
return { locale, messages }
})

View File

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

View File

@ -84,7 +84,7 @@ export const useSSEStore = create<SSEState>((set, get) => {
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 source = new EventSource(url);

View File

@ -1,4 +1,4 @@
// /src/middleware.ts
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import createIntlMiddleware from 'next-intl/middleware';
@ -7,10 +7,12 @@ import { routing } from './i18n/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 } {
return typeof v === 'object' && v !== null &&
typeof (v as Record<string, unknown>).isAdmin === 'boolean';
}
function getCurrentLocaleFromPath(pathname: string, locales: readonly string[], fallback: string) {
const first = pathname.split('/')[1];
return locales.includes(first) ? first : fallback;
@ -26,10 +28,10 @@ function stripLeadingLocale(pathname: string, locales: readonly string[]) {
}
function isProtectedPath(pathnameNoLocale: string) {
return (
pathnameNoLocale === '/' ||
pathnameNoLocale.startsWith('/settings') ||
pathnameNoLocale.startsWith('/matches') ||
pathnameNoLocale.startsWith('/team') ||
pathnameNoLocale.startsWith('/') ||
pathnameNoLocale.startsWith('/settings') ||
pathnameNoLocale.startsWith('/matches') ||
pathnameNoLocale.startsWith('/team') ||
pathnameNoLocale.startsWith('/admin')
);
}
@ -52,31 +54,10 @@ export default async function middleware(req: NextRequest) {
}
const i18nRes = handleI18n(req);
// 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
}
if (i18nRes.headers.get('location') || i18nRes.headers.get('x-middleware-rewrite')) {
return i18nRes;
}
// 3) Geschützte Bereiche
const { locales, defaultLocale } = routing;
const url = req.nextUrl;
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 });
// Adminschutz (ohne any)
if (pathnameNoLocale.startsWith('/admin')) {
const isAdmin = hasAdminFlag(token) && token.isAdmin === true;
if (!isAdmin) {
@ -96,6 +78,7 @@ export default async function middleware(req: NextRequest) {
}
}
// Allgemeiner Auth-Schutz
if (!token) {
const loginUrl = new URL('/api/auth/signin', req.url);
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';
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 =
process.env.DEMOS_ROOT ?? 'demos';

View File

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