updated
This commit is contained in:
parent
72a0ca015f
commit
c6dd921400
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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(' ')}
|
||||
>
|
||||
|
||||
@ -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): */}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -4,7 +4,7 @@ import VoteClient from './VoteClient' // Client-Komponente
|
||||
|
||||
export default function VotePage() {
|
||||
return (
|
||||
<Card maxWidth="auto">
|
||||
<Card maxWidth="auto" bodyScrollable>
|
||||
<VoteClient />
|
||||
</Card>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }>) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user