This commit is contained in:
Linrador 2025-10-06 15:38:35 +02:00
parent 72a0ca015f
commit c6dd921400
14 changed files with 332 additions and 356 deletions

View File

@ -1,50 +1,42 @@
// Card.tsx
'use client'
import React from 'react'
type CardWidth =
| 'sm' // 24rem
| 'md' // 28rem
| 'lg' // 32rem
| 'xl' // 36rem
| '2xl' // 42rem
| 'full' // 100%
| 'auto' // keine Begrenzung
type CardWidth = 'sm'|'md'|'lg'|'xl'|'2xl'|'full'|'auto'
type CardProps = {
title?: string
description?: string
actions?: React.ReactNode
/** Zusatzbereich UNTER Titel/Actions (z.B. Alert) */
headerAddon?: React.ReactNode
children?: React.ReactNode
/** links, rechts oder (Default) zentriert */
align?: 'left' | 'right' | 'center'
/** gewünschte Max-Breite (Default: lg) */
maxWidth?: CardWidth
/** zusätzliche Klassen für den Body-Bereich (Padding etc.) */
className?: string
/** bei Überlauf innerhalb der Card scrollen (Default: false) */
bodyScrollable?: boolean
/** Höhe der Card (z.B. 'inherit', '100%', '80vh', 600 ...) */
noPadding?: boolean
height?: 'inherit' | string | number
}
export default function Card({
title,
description,
actions,
headerAddon, // ← neu
children,
align = 'center',
maxWidth = 'lg',
className,
bodyScrollable = false,
noPadding = false,
height,
}: CardProps) {
// Ausrichtung
const alignClasses =
align === 'left' ? 'mr-auto'
: align === 'right' ? 'ml-auto'
: 'mx-auto'
// Breite
const widthClasses: Record<CardWidth, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
@ -55,10 +47,10 @@ export default function Card({
auto: '',
}
// style.height ableiten (Zahl => px)
const style: React.CSSProperties | undefined = height != null
? { height: typeof height === 'number' ? `${height}px` : height }
: undefined
const style: React.CSSProperties | undefined =
height != null ? { height: typeof height === 'number' ? `${height}px` : height } : undefined
const showHeader = Boolean(title || description || actions || headerAddon)
return (
<div
@ -69,20 +61,35 @@ export default function Card({
alignClasses,
widthClasses[maxWidth],
'min-h-0',
'box-border'
className ?? '',
].join(' ')}
>
{(title || description) && (
{showHeader && (
<div className="px-4 py-3 border-b border-gray-200/70 dark:border-neutral-700/60">
{title && <h3 className="text-base font-semibold">{title}</h3>}
{description && (
<p className="mt-0.5 text-sm text-gray-500 dark:text-neutral-400">{description}</p>
{(title || description || actions) && (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
{title && <h3 className="text-base font-semibold truncate">{title}</h3>}
{description && (
<p className="mt-0.5 text-sm text-gray-500 dark:text-neutral-400 truncate">
{description}
</p>
)}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
)}
{headerAddon && (
<div className={title || description || actions ? 'mt-2' : undefined}>
{headerAddon}
</div>
)}
</div>
)}
<div className={`flex-1 min-h-0 ${bodyScrollable ? 'overflow-auto overscroll-contain' : ''}`}>
<div className="p-4 sm:p-6 h-full min-h-0">
<div className={`flex-1 min-h-0 ${bodyScrollable ? 'overflow-y-auto overflow-x-hidden overscroll-contain' : ''}`}>
<div className={`h-full min-h-0 ${noPadding ? '' : 'p-4 sm:p-6'}`}>
{children}
</div>
</div>

View File

@ -62,7 +62,7 @@ export type ChartType =
| 'scatter';
export type ChartHandle = {
chart: ChartJS | null; // <-- ungenauer, aber kompatibel
chart: ChartJS | null;
update: (mutator?: (c: ChartJS) => void) => void;
};
@ -107,6 +107,8 @@ type BaseProps<TType extends ChartJSType = ChartJSType> = {
radarAddRingOffset?: boolean;
};
const RADAR_OFFSET = 20;
const imgCache = new Map<string, HTMLImageElement>();
function getImage(src: string): HTMLImageElement {
let img = imgCache.get(src);
@ -129,6 +131,9 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
datasets,
options,
plugins,
} = props;
const {
className,
style,
height,
@ -149,26 +154,49 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
} = props;
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<ChartJS | null>(null); // <-- hier
const chartRef = useRef<ChartJS | null>(null);
const prevTypeRef = useRef<ChartJSType | null>(null);
const autoData = useMemo<ChartData<TType> | undefined>(() => {
// Auto-Daten (labels + datasets) ODER explizites data-Objekt
const baseData = useMemo<ChartData<TType> | undefined>(() => {
if (data) return data;
if (!labels || !datasets) return undefined;
return { labels, datasets: datasets as any } as ChartData<TType>;
}, [data, labels, datasets]);
// ▼ Für RADAR: Daten intern um +20 verschieben (Plot), Original bleibt in Props.
const shiftedData = useMemo<ChartData<TType> | undefined>(() => {
if (!baseData) return baseData;
if (type !== 'radar') return baseData;
// flache Kopie, Datensätze klonen und data +20
const cloned: any = {
...baseData,
datasets: (baseData.datasets ?? []).map((ds: any) => {
// Nur numerische Arrays anfassen
const d = Array.isArray(ds.data)
? ds.data.map((v: any) =>
typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v
)
: ds.data;
return { ...ds, data: d };
}),
};
return cloned;
}, [baseData, type]);
/* ---------- Radar Scale ---------- */
const radarScaleOpts = useMemo(() => {
if (type !== 'radar') return undefined;
const gridColor = 'rgba(255,255,255,0.10)'; // sichtbarer
const gridColor = 'rgba(255,255,255,0.10)';
const angleColor = 'rgba(255,255,255,0.12)';
const ticks: any = {
beginAtZero: true,
showLabelBackdrop: false,
color: 'rgba(255,255,255,0.6)', // Tick-Zahlen, falls eingeblendet
color: 'rgba(255,255,255,0.6)',
backdropColor: 'transparent',
...(radarHideTicks ? { display: false } : {}),
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
@ -176,27 +204,22 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const r: any = {
suggestedMin: 0,
grid: {
color: gridColor,
lineWidth: 1,
},
angleLines: {
display: true,
color: angleColor,
lineWidth: 1,
},
grid: { color: gridColor, lineWidth: 1 },
angleLines: { display: true, color: angleColor, lineWidth: 1 },
ticks,
pointLabels: { display: false }, // Icons/Labels zeichnen wir selbst
pointLabels: { display: false },
};
// WICHTIG: max anheben, damit +20 nicht abschneidet
if (typeof radarMax === 'number') {
r.max = radarMax;
r.suggestedMax = radarMax;
r.max = radarMax + RADAR_OFFSET;
r.suggestedMax = radarMax + RADAR_OFFSET;
}
return { r };
}, [type, radarHideTicks, radarStepSize, radarMax]);
/* ---------- Radar Icons Plugin ---------- */
const [radarPlugin] = useState<Plugin<'radar'>>(() => ({
id: 'radarIconsPlugin',
afterDatasetsDraw(chart) {
@ -207,67 +230,60 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const lbls = chart.data.labels as string[] | undefined;
if (!lbls?.length) return;
const icons = radarIcons ?? [];
const icons = props.radarIcons ?? [];
// --- Clip auf gesamte Canvas setzen, damit außerhalb der chartArea gemalt wird
ctx.save();
// einige Browser unterstützen resetTransform nicht, daher optional
(ctx as any).resetTransform?.();
ctx.beginPath();
ctx.rect(0, 0, chart.width, chart.height);
ctx.clip();
// Zentrum bestimmen
const ca = (chart as any).chartArea as { left:number; right:number; top:number; bottom:number } | undefined;
const cx0 = scale.xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width as number) / 2);
const cy0 = scale.yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2);
const half = radarIconSize / 2;
const gap = Math.max(4, radarIconLabelMargin);
const half = (props.radarIconSize ?? 40) / 2;
const gap = Math.max(4, props.radarIconLabelMargin ?? 6);
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.font = radarIconLabelFont;
ctx.fillStyle = radarIconLabelColor;
ctx.font = props.radarIconLabelFont ?? '12px Inter, system-ui, sans-serif';
ctx.fillStyle = props.radarIconLabelColor ?? '#ffffff';
for (let i = 0; i < lbls.length; i++) {
// Spitze auf dieser Achse (index, value)
const p = scale.getPointPositionForValue(i, scale.max);
const px = p.x as number;
const py = p.y as number;
// Einheitsvektor vom Zentrum zur Spitze
const dx = px - cx0;
const dy = py - cy0;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
// Iconzentrum leicht NACH AUSSEN verschieben
const cx = px + ux * (half + gap);
const cy = py + uy * (half + gap);
// Icon
const src = icons[i];
if (src) {
const img = getImage(src);
if (img.complete) {
ctx.drawImage(img, cx - half, cy - half, radarIconSize, radarIconSize);
ctx.drawImage(img, cx - half, cy - half, (props.radarIconSize ?? 40), (props.radarIconSize ?? 40));
} else {
img.onload = () => chart.draw();
}
}
// Label unter dem Icon (screen-vertikal)
if (radarIconLabels) {
ctx.fillText(String(lbls[i] ?? ''), cx, cy + half + radarIconLabelMargin);
if (props.radarIconLabels) {
ctx.fillText(String(lbls[i] ?? ''), cx, cy + half + (props.radarIconLabelMargin ?? 6));
}
}
ctx.restore(); // Clip/State wiederherstellen
ctx.restore();
},
}));
/* ---------- Optionen mergen + Tooltip anpassen ---------- */
const mergedOptions = useMemo<ChartOptions<TType>>(() => {
const base = {
responsive: true,
@ -275,38 +291,57 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
animation: { duration: 250 },
} as const;
// Start: base -> user options
const o: ChartOptions<TType> = {
...(base as unknown as ChartOptions<TType>),
...(options as ChartOptions<TType>),
};
if (type === 'radar') {
// Scales + Plugins zusammenführen
// Scales zusammenführen
(o as any).scales = {
...(radarScaleOpts ?? {}),
...(options?.scales as any),
};
// Tooltip: echten Wert (ohne Offset) anzeigen
const userTooltip = options?.plugins?.tooltip;
const userLabelCb = userTooltip?.callbacks?.label;
(o as any).plugins = {
legend: { display: false },
title: { display: false },
...(options?.plugins ?? {}),
...options?.plugins,
tooltip: {
...userTooltip,
callbacks: {
...userTooltip?.callbacks,
label: function (this: any, ctx: any) {
const shifted = Number(ctx.raw);
const original = Number.isFinite(shifted) ? shifted - RADAR_OFFSET : shifted;
const shown = Math.round(original); // ⬅️ hier auf ganze Zahlen runden
if (typeof userLabelCb === 'function') {
const clone = { ...ctx, raw: shown, parsed: { r: shown } };
return (userLabelCb as (this: any, c: any) => any).call(this, clone);
}
const dsLabel = ctx.dataset?.label ? `${ctx.dataset.label}: ` : '';
return `${dsLabel}${shown} %`;
},
},
},
};
// --- Layout-Padding für Icons/Labels am äußeren Ring --------------------
// Font-Px aus z.B. "12px Inter, system-ui, sans-serif" extrahieren
// Layout-Padding für Außenbeschriftungen (Icons/Labels)
const fontPx = (() => {
const m = /(\d+(?:\.\d+)?)px/i.exec(radarIconLabelFont ?? '');
return m ? parseFloat(m[1]) : 12;
})();
// Platzbedarf: Icon + (optional) Labelhöhe + kleiner Sicherheitsrand
const pad = Math.round(
(radarIconSize ?? 40) +
(radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) +
6
);
const currentPadding = (o as any).layout?.padding ?? {};
(o as any).layout = {
...(o as any).layout,
@ -317,11 +352,8 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
left: Math.max(pad, currentPadding.left ?? 0),
},
};
// ------------------------------------------------------------------------
} else if (options?.scales) {
(o as any).scales = {
...(options.scales as any),
};
(o as any).scales = { ...(options.scales as any) };
}
return o;
@ -335,6 +367,7 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
radarIconLabelMargin,
]);
/* ---------- Plugins mergen ---------- */
const mergedPlugins = useMemo<Plugin<TType>[]>(() => {
const list: Plugin<TType>[] = [];
if (plugins?.length) list.push(...plugins);
@ -342,16 +375,18 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
return list;
}, [plugins, type, radarPlugin]);
/* ---------- Config ---------- */
const config = useMemo(
() => ({
type,
data: (autoData ?? { labels: [], datasets: [] }) as ChartData<TType>,
data: (type === 'radar' ? (shiftedData ?? { labels: [], datasets: [] }) : (baseData ?? { labels: [], datasets: [] })) as ChartData<TType>,
options: mergedOptions,
plugins: mergedPlugins,
}),
[type, autoData, mergedOptions, mergedPlugins]
[type, baseData, shiftedData, mergedOptions, mergedPlugins]
);
/* ---------- Lifecycle ---------- */
useEffect(() => {
const mustRecreate =
redraw || !chartRef.current || prevTypeRef.current !== type;
@ -360,7 +395,6 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
if (mustRecreate) {
chartRef.current?.destroy();
// ⬇️ Cast auf any, um die extrem strengen Generics in Chart.js zu umgehen
chartRef.current = new ChartJS(canvasRef.current, config as any);
prevTypeRef.current = type;
onReady?.(chartRef.current);
@ -375,10 +409,10 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
useEffect(() => {
const c = chartRef.current;
if (!c || redraw || prevTypeRef.current !== type) return;
(c as any).data = (autoData ?? c.data);
(c as any).data = (type === 'radar' ? (shiftedData ?? c.data) : (baseData ?? c.data));
(c as any).options = mergedOptions;
c.update();
}, [autoData, mergedOptions, type, redraw]);
}, [baseData, shiftedData, mergedOptions, type, redraw]);
useImperativeHandle(
ref,

View File

@ -557,29 +557,46 @@ export default function MapVotePanel({ match }: Props) {
currentStep.teamId === rightTeamId &&
!state?.locked
/* -------- Sorted map pool -------- */
const sortedMapPool = useMemo(() => {
return [...(state?.mapPool ?? [])].sort((a, b) =>
(state?.mapVisuals?.[a]?.label ?? a)
.localeCompare(state?.mapVisuals?.[b]?.label ?? b, 'de', { sensitivity: 'base' })
)
}, [state?.mapPool, state?.mapVisuals])
// --- Gemeinsame Map-Liste (Quelle für Pool *und* Radar) --------------------
// -------- Team-Winrate (Radar) Daten --------
// 1) Aktive, echte Maps aus deinen MAP_OPTIONS (keine Lobby/Pseudo-Maps)
const activeMapKeys = useMemo(
() => MAP_OPTIONS
.filter(o => o.active && o.key.startsWith('de_'))
.map(o => o.key),
// aktive Keys aus MAP_OPTIONS (ohne Lobby)
const ALL_ACTIVE = useMemo(
() => MAP_OPTIONS.filter(o => o.active && o.key !== 'lobby_mapvote').map(o => o.key),
[]
)
);
const ALL_ACTIVE_SET = useMemo(() => new Set(ALL_ACTIVE), [ALL_ACTIVE]);
// Labels passend zu den Keys
const activeMapLabels = useMemo(
() => activeMapKeys.map(k => MAP_OPTIONS.find(o => o.key === k)?.label ?? k),
[activeMapKeys]
)
const labelOf = useCallback(
(k: string) =>
state?.mapVisuals?.[k]?.label ??
MAP_OPTIONS.find(o => o.key === k)?.label ??
k,
[state?.mapVisuals]
);
// 1) serverseitigen Pool auf aktive reduzieren
const poolFiltered = useMemo(
() => (state?.mapPool ?? []).filter(k => ALL_ACTIVE_SET.has(k)),
[state?.mapPool, ALL_ACTIVE_SET]
);
// 2) fehlende aktive Maps ergänzen (Union)
const poolWithMissing = useMemo(
() => [...poolFiltered, ...ALL_ACTIVE.filter(k => !poolFiltered.includes(k))],
[poolFiltered, ALL_ACTIVE]
);
// 3) gemeinsame, sortierte Liste (Label-Order; alternativ: eigene Reihenfolge)
const sortedMapPool = useMemo(
() => [...poolWithMissing].sort((a, b) =>
labelOf(a).localeCompare(labelOf(b), 'de', { sensitivity: 'base' })
),
[poolWithMissing, labelOf]
);
// 4) exakt diese Liste fürs Radar verwenden
const activeMapKeys = sortedMapPool;
const activeMapLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf]);
// Helper: Durchschnitt (nur finite Werte)
function avg(values: number[]) {
@ -631,8 +648,8 @@ export default function MapVotePanel({ match }: Props) {
const teamAverage = present.length ? avg(present) : 0
if (!cancelled) {
setterData(mapAverages.map(v => Math.round(v * 10) / 10)) // 1 Nachkommastelle
setterAvg(Math.round(teamAverage * 10) / 10)
setterData(mapAverages.map(v => Math.round(v)))
setterAvg(Math.round(teamAverage))
}
} catch {
if (!cancelled) {
@ -1038,7 +1055,7 @@ export default function MapVotePanel({ match }: Props) {
radarIconLabels={true}
radarIconLabelFont="12px Inter, system-ui, sans-serif"
radarIconLabelColor="#ffffff"
radarMax={120}
radarMax={100}
radarStepSize={20}
radarAddRingOffset={false}
/>

View File

@ -44,7 +44,7 @@ export default function MapVoteProfileCard({
className={[
'relative flex items-center gap-3 rounded-xl border dark:border-neutral-700 shadow-sm px-3 py-2',
'transition-colors duration-300 ease-in-out justify-between',
'bg-white/90 dark:bg-neutral-800/90 hover:bg-neutral-200/10',
'bg-white/90 dark:bg-neutral-800/90 dark:hover:bg-neutral-600/80 hover:bg-neutral-200/10',
isRight ? 'flex-row-reverse' : 'flex-row',
].join(' ')}
>

View File

@ -23,6 +23,7 @@ import { Team } from '../../../types/team'
import Alert from './Alert'
import Image from 'next/image'
import Link from 'next/link'
import Card from './Card'
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
@ -454,27 +455,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
}
}, [currentMapKey, allMaps, bestOf, isPickBanPhase]) // ← Dependencies
// Tabellen-Layout
const ColGroup = () => (
<colgroup>
<col style={{ width: '25%' }} /> {/* Spieler */}
<col style={{ width: '8.5%' }} /> {/* Rank */}
<col style={{ width: '7%' }} /> {/* Aim */}
<col style={{ width: '5%' }} /> {/* K */}
<col style={{ width: '5%' }} /> {/* A */}
<col style={{ width: '5%' }} /> {/* D */}
<col style={{ width: '4%' }} /> {/* 1K */}
<col style={{ width: '4%' }} /> {/* 2K */}
<col style={{ width: '4%' }} /> {/* 3K */}
<col style={{ width: '4%' }} /> {/* 4K */}
<col style={{ width: '4%' }} /> {/* 5K */}
<col style={{ width: '5%' }} /> {/* K/D */}
<col style={{ width: '5%' }} /> {/* ADR */}
<col style={{ width: '7%' }} /> {/* HS% */}
<col style={{ width: '7.5%' }} /> {/* Damage (↑) */}
</colgroup>
)
// Löschen
const handleDelete = async () => {
if (!confirm('Match wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return
@ -491,103 +471,131 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
alert('Löschen fehlgeschlagen.')
}
}
// Spieler-Tabelle
const renderTable = (players: MatchPlayer[]) => {
const renderTable = (
players: MatchPlayer[],
teamTitle: string,
showEdit: boolean,
onEditClick?: () => void
) => {
const sorted = [...players].sort(
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
)
return (
<Table>
<ColGroup />
<Table.Head>
<Table.Row>
{[
'Spieler', 'Rank', 'Aim', 'K', 'A', 'D',
'1K', '2K', '3K', '4K', '5K',
'K/D', 'ADR', 'HS%', 'Damage',
].map(h => (
<Table.Cell key={h} as="th">{h}</Table.Cell>
))}
</Table.Row>
</Table.Head>
<Card
title={teamTitle}
actions={showEdit ? (
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640" aria-hidden>
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
<span className="text-gray-300">
Du kannst die Aufstellung noch bis <strong>{formatDateTimeInTZ(endDate, userTZ)}</strong> bearbeiten.
</span>
</div>
<Table.Body>
{sorted.map((p) => {
const banned = isPlayerBanned(p);
const title = banned ? banTooltip(p) : undefined;
<Button
size="sm"
onClick={onEditClick}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Spieler bearbeiten
</Button>
</Alert>
) : undefined}
maxWidth="full"
noPadding
>
<Table>
<Table.Head>
<Table.Row>
{[
'Spieler', 'Rank', 'Aim', 'K', 'A', 'D',
'1K', '2K', '3K', '4K', '5K',
'K/D', 'ADR', 'HS%', 'Damage',
].map(h => (
<Table.Cell key={h} as="th">{h}</Table.Cell>
))}
</Table.Row>
</Table.Head>
return (
<Table.Row
key={p.user.steamId}
title={title}
>
<Table.Cell
className={`flex items-center ${banned ? 'bg-red-900/20' : undefined}`}
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
<img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name}
className={`mr-3 h-8 w-8 rounded-full`}
/>
<div className="text-base font-semibold flex items-center gap-2">
<span>{p.user.name ?? 'Unbekannt'}</span>
{banned && (
<span
className="ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-bold bg-red-600 text-white"
aria-label={hasVacBan(p) ? "Dieser Spieler hat einen VAC-Ban" : "Dieser Spieler ist gebannt"}
>
{hasVacBan(p) ? 'VAC' : 'BAN'}
</span>
)}
</div>
</Table.Cell>
<Table.Body>
{sorted.map((p) => {
const banned = isPlayerBanned(p);
const title = banned ? banTooltip(p) : undefined;
<Table.Cell>
<div className="flex items-center gap-[6px]">
{match.matchType === 'premier' ? (
<PremierRankBadge rank={p.stats?.rankNew ?? p.user?.premierRank ?? 0} />
) : match.matchType === 'community' ? (
<PremierRankBadge rank={p.user?.premierRank ?? p.stats?.rankNew ?? 0} />
) : (
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
)}
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span
className={`text-sm ${
p.stats.rankChange > 0 ? 'text-green-500'
: p.stats.rankChange < 0 ? 'text-red-500' : ''
}`}
>
{p.stats.rankChange > 0 ? '+' : ''}{p.stats.rankChange}
</span>
)}
</div>
</Table.Cell>
return (
<Table.Row key={p.user.steamId} title={title} className={`${banned ? 'bg-red-900/20' : ''}`}>
<Table.Cell
className={`flex items-center`}
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
<img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name}
className="mr-3 h-8 w-8 rounded-full"
/>
<div className="text-base font-semibold flex items-center gap-2">
<span>{p.user.name ?? 'Unbekannt'}</span>
{banned && (
<span
className="ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-bold bg-red-600 text-white"
aria-label={hasVacBan(p) ? 'Dieser Spieler hat einen VAC-Ban' : 'Dieser Spieler ist gebannt'}
>
{hasVacBan(p) ? 'VAC' : 'BAN'}
</span>
)}
</div>
</Table.Cell>
<Table.Cell>{Number.isFinite(Number(p.stats?.aim)) ? `${Number(p.stats?.aim).toFixed(0)} %` : '-'}</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.oneK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.twoK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.threeK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fourK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fiveK ?? '-'}</Table.Cell>
<Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)} %</Table.Cell>
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
<Table.Cell>
<div className="flex items-center gap-[6px]">
{match.matchType === 'premier' ? (
<PremierRankBadge rank={p.stats?.rankNew ?? p.user?.premierRank ?? 0} />
) : match.matchType === 'community' ? (
<PremierRankBadge rank={p.user?.premierRank ?? p.stats?.rankNew ?? 0} />
) : (
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
)}
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span className={`text-sm ${p.stats.rankChange > 0 ? 'text-green-500' : p.stats.rankChange < 0 ? 'text-red-500' : ''}`}>
{p.stats.rankChange > 0 ? '+' : ''}{p.stats.rankChange}
</span>
)}
</div>
</Table.Cell>
<Table.Cell>{Number.isFinite(Number(p.stats?.aim)) ? `${Number(p.stats?.aim).toFixed(0)} %` : '-'}</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.oneK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.twoK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.threeK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fourK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fiveK ?? '-'}</Table.Cell>
<Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)} %</Table.Cell>
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
</Table.Row>
)
})}
</Table.Body>
</Table>
</Card>
)
}
// Teamtitel bestimmen (gleich wie bisher im Header)
const teamATitle =
match.matchType === 'community' ? (match.teamA?.name ?? sideLabel('A')) : sideLabel('A');
const teamBTitle =
match.matchType === 'community' ? (match.teamB?.name ?? sideLabel('B')) : sideLabel('B');
/* ─────────────────── Render ─────────────────── */
return (
@ -795,98 +803,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
)}
{/* Teams / Tabellen */}
<div className="mt-4 space-y-10 pt-4">
{/* Team A */}
<div>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xl font-semibold">
{match.teamA?.logo && (
<span className="relative mr-2 inline-block h-8 w-8 align-middle">
<Image
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
alt="Teamlogo"
fill
sizes="96px"
quality={75}
priority={false}
/>
</span>
)}
{match.matchType === 'community'
? (match.teamA?.name ?? sideLabel('A'))
: sideLabel('A')}
</h2>
{showEditA && (
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
<span className="text-gray-300">
Du kannst die Aufstellung noch bis <strong>{formatDateTimeInTZ(endDate, userTZ)}</strong> bearbeiten.
</span>
</div>
<Button
size="sm"
onClick={() => setEditSide('A')}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Spieler bearbeiten
</Button>
</Alert>
)}
</div>
{renderTable(teamAPlayers)}
</div>
{/* Team A */}
<div className="mt-4">
{renderTable(teamAPlayers, teamATitle, showEditA, () => setEditSide('A'))}
</div>
{/* Team B */}
<div>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xl font-semibold">
{match.teamB?.logo && (
<span className="relative mr-2 inline-block h-8 w-8 align-middle">
<Image
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt="Teamlogo"
fill
sizes="96px"
quality={75}
priority={false}
/>
</span>
)}
{match.matchType === 'community'
? (match.teamB?.name ?? sideLabel('B'))
: sideLabel('B')}
</h2>
{showEditB && (
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
<span className="text-gray-300">
Du kannst die Aufstellung noch bis <strong>{formatDateTimeInTZ(endDate, userTZ)}</strong> bearbeiten.
</span>
</div>
<Button
size="sm"
onClick={() => setEditSide('B')}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Spieler bearbeiten
</Button>
</Alert>
)}
</div>
{renderTable(teamBPlayers)}
</div>
<div className="mt-4">
{renderTable(teamBPlayers, teamBTitle, showEditB, () => setEditSide('B'))}
</div>
{/* Echte Modals (außerhalb IIFE, State oben): */}

View File

@ -183,7 +183,7 @@ export default function MatchReadyOverlay({
let id: number | null = null
if (connecting) {
setShowConnectHelp(false)
id = window.setTimeout(() => setShowConnectHelp(true), 30_000)
id = window.setTimeout(() => setShowConnectHelp(true), 10_000)
}
return () => { if (id) window.clearTimeout(id) }
}, [connecting])
@ -489,7 +489,7 @@ export default function MatchReadyOverlay({
) : showWaitHint ? (
<div className="mt-[18px] mb-[6px] min-h-[100px] w-full flex flex-col items-center justify-center gap-2">
<div className="px-3 py-1 rounded bg-yellow-100/90 text-yellow-900 text-[14px] font-semibold">
Dein Team wartet auf dich!
Hey {session?.user.name}, dein Team wartet auf dich!
</div>
<a
href={effectiveConnectHref}
@ -514,7 +514,7 @@ export default function MatchReadyOverlay({
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
{connecting ? (
showConnectHelp ? (
<span className="inline-flex items-center gap-3 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10">
<span className="inline-flex flex-col items-center gap-2 px-3 py-2 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10">
<span className="text-[#f8e08e] font-semibold">Es lädt nicht?</span>
<a
href={effectiveConnectHref}

View File

@ -63,8 +63,8 @@ export default async function RootLayout({children, params}: Props) {
{/* Rechte Spalte füllt Höhe; wichtig: min-h-0, damit child scrollen darf */}
<div className="min-w-0 flex flex-col h-dvh min-h-0">
{/* Nur HIER scrollen */}
<main className="flex-1 min-w-0 min-h-0 overflow-auto overscroll-contain">
<div className="h-full min-h-0 box-border p-4 sm:p-6 overscroll-contain">{children}</div>
<main className="flex-1 min-w-0 min-h-0 overflow-auto">
<div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div>
</main>
<GameBannerSpacer className="hidden sm:block" />
</div>

View File

@ -4,7 +4,7 @@ import VoteClient from './VoteClient' // Client-Komponente
export default function VotePage() {
return (
<Card maxWidth="auto">
<Card maxWidth="auto" bodyScrollable>
<VoteClient />
</Card>
)

View File

@ -42,7 +42,7 @@ export default async function ProfileLayout({
</div>
{/* scrollender Content */}
<div className="flex-1 min-h-0 overflow-auto overscroll-contain py-6 px-3 space-y-6">
<div className="flex-1 min-h-0 py-6 px-3 space-y-6">
{children}
</div>
</div>

View File

@ -41,7 +41,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
</aside>
{/* rechte Spalte scrollt */}
<div ref={scrollRef} className="min-h-0 overflow-auto overscroll-contain p-4">
<div ref={scrollRef} className="min-h-0 p-4">
<main className="min-h-0">{children}</main>
</div>
</div>

View File

@ -251,70 +251,67 @@ async function ensureVote(matchId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA: {
include: {
leader: {
select: {
steamId: true, name: true, avatar: true, location: true,
premierRank: true, isAdmin: true,
}
}
}
},
teamB: {
include: {
leader: {
select: {
steamId: true, name: true, avatar: true, location: true,
premierRank: true, isAdmin: true,
}
}
}
},
// 👉 Spieler direkt am Match laden:
teamA: { include: { leader: { select: { steamId: true, name: true, avatar: true, location: true, premierRank: true, isAdmin: true } } } },
teamB: { include: { leader: { select: { steamId: true, name: true, avatar: true, location: true, premierRank: true, isAdmin: true } } } },
teamAUsers: true,
teamBUsers: true,
// optional zusätzlich:
players: { include: { user: true } }, // falls du MatchPlayer brauchst
players: { include: { user: true } },
mapVote: { include: { steps: true } },
},
})
});
if (!match) return { match: null, vote: null }
if (!match) return { match: null, vote: null };
// Bereits vorhanden?
if (match.mapVote) return { match, vote: match.mapVote }
// Aktive Maps lt. Konfiguration (ohne Lobby)
const CURRENT_ACTIVE = MAP_OPTIONS
.filter(m => m.active && m.key !== 'lobby_mapvote')
.map(m => m.key);
if (match.mapVote) {
let vote = match.mapVote;
// bereits gewählte Maps im Pool behalten (auch wenn jetzt inaktiv)
const chosen = vote.steps.map(s => s.map).filter(Boolean) as string[];
const desiredPool = Array.from(new Set([...CURRENT_ACTIVE, ...chosen]));
// Pool ggf. aktualisieren
if (
desiredPool.length !== vote.mapPool.length ||
desiredPool.some(k => !vote.mapPool.includes(k))
) {
vote = await prisma.mapVote.update({
where: { id: vote.id },
data: { mapPool: desiredPool },
include: { steps: true },
});
}
return { match, vote };
}
// Neu anlegen
const bestOf = match.matchType === 'community' ? 3 : 1
const mapPool = MAP_OPTIONS.filter(m => m.active).map(m => m.key)
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const bestOf = match.matchType === 'community' ? 3 : 1;
const mapPool = CURRENT_ACTIVE; // neu: ohne Lobby & nur aktuelle Aktive
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null });
const firstIsA = (typeof randomInt === 'function') ? randomInt(0, 2) === 0 : Math.random() < 0.5
const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id
const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id
const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId)
const firstIsA = (typeof randomInt === 'function') ? randomInt(0, 2) === 0 : Math.random() < 0.5;
const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id;
const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id;
const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId);
const created = await prisma.mapVote.create({
data: {
matchId : match.id,
matchId: match.id,
bestOf,
mapPool,
currentIdx: 0,
locked : false,
locked: false,
opensAt,
steps : {
create: stepsDef.map(s => ({
order : s.order,
action: s.action as MapVoteAction,
teamId: s.teamId,
})),
},
steps: { create: stepsDef.map(s => ({ order: s.order, action: s.action as MapVoteAction, teamId: s.teamId })) },
},
include: { steps: true },
})
});
return { match, vote: created }
return { match, vote: created };
}
function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | null }>) {

View File

@ -148,7 +148,7 @@ export async function GET(req: NextRequest, { params }: { params: { steamId: str
const it = byMap[k]
const denom = it.wins + it.losses + it.ties
const ratio = denom > 0 ? (it.wins + 0.5 * it.ties) / denom : 0
it.pct = Math.round(ratio * 1000) / 10 // 1 Nachkommastelle
it.pct = Math.round(ratio * 1000) // Keine Nachkommastelle
}
// Sortierung: erst MAP_OPTIONS-Reihenfolge, dann Label

View File

@ -121,8 +121,6 @@ export async function parseAndStoreDemoTask(
try { if (allPlayers.length) bans = await getPlayerBans(allPlayers.map(p => p.steamId)); }
catch (e) { log.warn(`⚠️ Steam Ban Fetch fehlgeschlagen: ${String(e)}`); }
console.log(bans);
const relativePath = path.relative(process.cwd(), actualDemoPath);
// Match anlegen

View File

@ -33,7 +33,6 @@ export async function refreshUserBansTask(
const users = await prisma.user.findMany(query);
if (users.length === 0) {
log.debug?.("[refreshUserBansTask] Keine fälligen Nutzer.");
return 0;
}