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

View File

@ -62,7 +62,7 @@ export type ChartType =
| 'scatter'; | 'scatter';
export type ChartHandle = { export type ChartHandle = {
chart: ChartJS | null; // <-- ungenauer, aber kompatibel chart: ChartJS | null;
update: (mutator?: (c: ChartJS) => void) => void; update: (mutator?: (c: ChartJS) => void) => void;
}; };
@ -107,6 +107,8 @@ type BaseProps<TType extends ChartJSType = ChartJSType> = {
radarAddRingOffset?: boolean; radarAddRingOffset?: boolean;
}; };
const RADAR_OFFSET = 20;
const imgCache = new Map<string, HTMLImageElement>(); const imgCache = new Map<string, HTMLImageElement>();
function getImage(src: string): HTMLImageElement { function getImage(src: string): HTMLImageElement {
let img = imgCache.get(src); let img = imgCache.get(src);
@ -129,6 +131,9 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
datasets, datasets,
options, options,
plugins, plugins,
} = props;
const {
className, className,
style, style,
height, height,
@ -149,26 +154,49 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
} = props; } = props;
const canvasRef = useRef<HTMLCanvasElement | null>(null); 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 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 (data) return data;
if (!labels || !datasets) return undefined; if (!labels || !datasets) return undefined;
return { labels, datasets: datasets as any } as ChartData<TType>; return { labels, datasets: datasets as any } as ChartData<TType>;
}, [data, labels, datasets]); }, [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(() => { const radarScaleOpts = useMemo(() => {
if (type !== 'radar') return undefined; 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 angleColor = 'rgba(255,255,255,0.12)';
const ticks: any = { const ticks: any = {
beginAtZero: true, beginAtZero: true,
showLabelBackdrop: false, showLabelBackdrop: false,
color: 'rgba(255,255,255,0.6)', // Tick-Zahlen, falls eingeblendet color: 'rgba(255,255,255,0.6)',
backdropColor: 'transparent', backdropColor: 'transparent',
...(radarHideTicks ? { display: false } : {}), ...(radarHideTicks ? { display: false } : {}),
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}), ...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
@ -176,27 +204,22 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const r: any = { const r: any = {
suggestedMin: 0, suggestedMin: 0,
grid: { grid: { color: gridColor, lineWidth: 1 },
color: gridColor, angleLines: { display: true, color: angleColor, lineWidth: 1 },
lineWidth: 1,
},
angleLines: {
display: true,
color: angleColor,
lineWidth: 1,
},
ticks, ticks,
pointLabels: { display: false }, // Icons/Labels zeichnen wir selbst pointLabels: { display: false },
}; };
// WICHTIG: max anheben, damit +20 nicht abschneidet
if (typeof radarMax === 'number') { if (typeof radarMax === 'number') {
r.max = radarMax; r.max = radarMax + RADAR_OFFSET;
r.suggestedMax = radarMax; r.suggestedMax = radarMax + RADAR_OFFSET;
} }
return { r }; return { r };
}, [type, radarHideTicks, radarStepSize, radarMax]); }, [type, radarHideTicks, radarStepSize, radarMax]);
/* ---------- Radar Icons Plugin ---------- */
const [radarPlugin] = useState<Plugin<'radar'>>(() => ({ const [radarPlugin] = useState<Plugin<'radar'>>(() => ({
id: 'radarIconsPlugin', id: 'radarIconsPlugin',
afterDatasetsDraw(chart) { afterDatasetsDraw(chart) {
@ -207,67 +230,60 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const lbls = chart.data.labels as string[] | undefined; const lbls = chart.data.labels as string[] | undefined;
if (!lbls?.length) return; 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(); ctx.save();
// einige Browser unterstützen resetTransform nicht, daher optional
(ctx as any).resetTransform?.(); (ctx as any).resetTransform?.();
ctx.beginPath(); ctx.beginPath();
ctx.rect(0, 0, chart.width, chart.height); ctx.rect(0, 0, chart.width, chart.height);
ctx.clip(); ctx.clip();
// Zentrum bestimmen
const ca = (chart as any).chartArea as { left:number; right:number; top:number; bottom:number } | undefined; 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 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 cy0 = scale.yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2);
const half = radarIconSize / 2; const half = (props.radarIconSize ?? 40) / 2;
const gap = Math.max(4, radarIconLabelMargin); const gap = Math.max(4, props.radarIconLabelMargin ?? 6);
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.font = radarIconLabelFont; ctx.font = props.radarIconLabelFont ?? '12px Inter, system-ui, sans-serif';
ctx.fillStyle = radarIconLabelColor; ctx.fillStyle = props.radarIconLabelColor ?? '#ffffff';
for (let i = 0; i < lbls.length; i++) { for (let i = 0; i < lbls.length; i++) {
// Spitze auf dieser Achse (index, value)
const p = scale.getPointPositionForValue(i, scale.max); const p = scale.getPointPositionForValue(i, scale.max);
const px = p.x as number; const px = p.x as number;
const py = p.y as number; const py = p.y as number;
// Einheitsvektor vom Zentrum zur Spitze
const dx = px - cx0; const dx = px - cx0;
const dy = py - cy0; const dy = py - cy0;
const len = Math.hypot(dx, dy) || 1; const len = Math.hypot(dx, dy) || 1;
const ux = dx / len; const ux = dx / len;
const uy = dy / len; const uy = dy / len;
// Iconzentrum leicht NACH AUSSEN verschieben
const cx = px + ux * (half + gap); const cx = px + ux * (half + gap);
const cy = py + uy * (half + gap); const cy = py + uy * (half + gap);
// Icon
const src = icons[i]; const src = icons[i];
if (src) { if (src) {
const img = getImage(src); const img = getImage(src);
if (img.complete) { 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 { } else {
img.onload = () => chart.draw(); img.onload = () => chart.draw();
} }
} }
// Label unter dem Icon (screen-vertikal) if (props.radarIconLabels) {
if (radarIconLabels) { ctx.fillText(String(lbls[i] ?? ''), cx, cy + half + (props.radarIconLabelMargin ?? 6));
ctx.fillText(String(lbls[i] ?? ''), cx, cy + half + radarIconLabelMargin);
} }
} }
ctx.restore(); // Clip/State wiederherstellen ctx.restore();
}, },
})); }));
/* ---------- Optionen mergen + Tooltip anpassen ---------- */
const mergedOptions = useMemo<ChartOptions<TType>>(() => { const mergedOptions = useMemo<ChartOptions<TType>>(() => {
const base = { const base = {
responsive: true, responsive: true,
@ -275,38 +291,57 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
animation: { duration: 250 }, animation: { duration: 250 },
} as const; } as const;
// Start: base -> user options
const o: ChartOptions<TType> = { const o: ChartOptions<TType> = {
...(base as unknown as ChartOptions<TType>), ...(base as unknown as ChartOptions<TType>),
...(options as ChartOptions<TType>), ...(options as ChartOptions<TType>),
}; };
if (type === 'radar') { if (type === 'radar') {
// Scales + Plugins zusammenführen // Scales zusammenführen
(o as any).scales = { (o as any).scales = {
...(radarScaleOpts ?? {}), ...(radarScaleOpts ?? {}),
...(options?.scales as any), ...(options?.scales as any),
}; };
// Tooltip: echten Wert (ohne Offset) anzeigen
const userTooltip = options?.plugins?.tooltip;
const userLabelCb = userTooltip?.callbacks?.label;
(o as any).plugins = { (o as any).plugins = {
legend: { display: false }, legend: { display: false },
title: { 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 -------------------- // Layout-Padding für Außenbeschriftungen (Icons/Labels)
// Font-Px aus z.B. "12px Inter, system-ui, sans-serif" extrahieren
const fontPx = (() => { const fontPx = (() => {
const m = /(\d+(?:\.\d+)?)px/i.exec(radarIconLabelFont ?? ''); const m = /(\d+(?:\.\d+)?)px/i.exec(radarIconLabelFont ?? '');
return m ? parseFloat(m[1]) : 12; return m ? parseFloat(m[1]) : 12;
})(); })();
// Platzbedarf: Icon + (optional) Labelhöhe + kleiner Sicherheitsrand
const pad = Math.round( const pad = Math.round(
(radarIconSize ?? 40) + (radarIconSize ?? 40) +
(radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) + (radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) +
6 6
); );
const currentPadding = (o as any).layout?.padding ?? {}; const currentPadding = (o as any).layout?.padding ?? {};
(o as any).layout = { (o as any).layout = {
...(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), left: Math.max(pad, currentPadding.left ?? 0),
}, },
}; };
// ------------------------------------------------------------------------
} else if (options?.scales) { } else if (options?.scales) {
(o as any).scales = { (o as any).scales = { ...(options.scales as any) };
...(options.scales as any),
};
} }
return o; return o;
@ -335,6 +367,7 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
radarIconLabelMargin, radarIconLabelMargin,
]); ]);
/* ---------- Plugins mergen ---------- */
const mergedPlugins = useMemo<Plugin<TType>[]>(() => { const mergedPlugins = useMemo<Plugin<TType>[]>(() => {
const list: Plugin<TType>[] = []; const list: Plugin<TType>[] = [];
if (plugins?.length) list.push(...plugins); if (plugins?.length) list.push(...plugins);
@ -342,16 +375,18 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
return list; return list;
}, [plugins, type, radarPlugin]); }, [plugins, type, radarPlugin]);
/* ---------- Config ---------- */
const config = useMemo( const config = useMemo(
() => ({ () => ({
type, type,
data: (autoData ?? { labels: [], datasets: [] }) as ChartData<TType>, data: (type === 'radar' ? (shiftedData ?? { labels: [], datasets: [] }) : (baseData ?? { labels: [], datasets: [] })) as ChartData<TType>,
options: mergedOptions, options: mergedOptions,
plugins: mergedPlugins, plugins: mergedPlugins,
}), }),
[type, autoData, mergedOptions, mergedPlugins] [type, baseData, shiftedData, mergedOptions, mergedPlugins]
); );
/* ---------- Lifecycle ---------- */
useEffect(() => { useEffect(() => {
const mustRecreate = const mustRecreate =
redraw || !chartRef.current || prevTypeRef.current !== type; redraw || !chartRef.current || prevTypeRef.current !== type;
@ -360,7 +395,6 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
if (mustRecreate) { if (mustRecreate) {
chartRef.current?.destroy(); 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); chartRef.current = new ChartJS(canvasRef.current, config as any);
prevTypeRef.current = type; prevTypeRef.current = type;
onReady?.(chartRef.current); onReady?.(chartRef.current);
@ -375,10 +409,10 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
useEffect(() => { useEffect(() => {
const c = chartRef.current; const c = chartRef.current;
if (!c || redraw || prevTypeRef.current !== type) return; 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 as any).options = mergedOptions;
c.update(); c.update();
}, [autoData, mergedOptions, type, redraw]); }, [baseData, shiftedData, mergedOptions, type, redraw]);
useImperativeHandle( useImperativeHandle(
ref, ref,

View File

@ -557,29 +557,46 @@ export default function MapVotePanel({ match }: Props) {
currentStep.teamId === rightTeamId && currentStep.teamId === rightTeamId &&
!state?.locked !state?.locked
/* -------- Sorted map pool -------- */ // --- Gemeinsame Map-Liste (Quelle für Pool *und* Radar) --------------------
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])
// -------- Team-Winrate (Radar) Daten -------- // aktive Keys aus MAP_OPTIONS (ohne Lobby)
const ALL_ACTIVE = useMemo(
// 1) Aktive, echte Maps aus deinen MAP_OPTIONS (keine Lobby/Pseudo-Maps) () => MAP_OPTIONS.filter(o => o.active && o.key !== 'lobby_mapvote').map(o => o.key),
const activeMapKeys = useMemo(
() => MAP_OPTIONS
.filter(o => o.active && o.key.startsWith('de_'))
.map(o => o.key),
[] []
) );
const ALL_ACTIVE_SET = useMemo(() => new Set(ALL_ACTIVE), [ALL_ACTIVE]);
// Labels passend zu den Keys const labelOf = useCallback(
const activeMapLabels = useMemo( (k: string) =>
() => activeMapKeys.map(k => MAP_OPTIONS.find(o => o.key === k)?.label ?? k), state?.mapVisuals?.[k]?.label ??
[activeMapKeys] 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) // Helper: Durchschnitt (nur finite Werte)
function avg(values: number[]) { function avg(values: number[]) {
@ -631,8 +648,8 @@ export default function MapVotePanel({ match }: Props) {
const teamAverage = present.length ? avg(present) : 0 const teamAverage = present.length ? avg(present) : 0
if (!cancelled) { if (!cancelled) {
setterData(mapAverages.map(v => Math.round(v * 10) / 10)) // 1 Nachkommastelle setterData(mapAverages.map(v => Math.round(v)))
setterAvg(Math.round(teamAverage * 10) / 10) setterAvg(Math.round(teamAverage))
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
@ -1038,7 +1055,7 @@ export default function MapVotePanel({ match }: Props) {
radarIconLabels={true} radarIconLabels={true}
radarIconLabelFont="12px Inter, system-ui, sans-serif" radarIconLabelFont="12px Inter, system-ui, sans-serif"
radarIconLabelColor="#ffffff" radarIconLabelColor="#ffffff"
radarMax={120} radarMax={100}
radarStepSize={20} radarStepSize={20}
radarAddRingOffset={false} radarAddRingOffset={false}
/> />

View File

@ -44,7 +44,7 @@ export default function MapVoteProfileCard({
className={[ className={[
'relative flex items-center gap-3 rounded-xl border dark:border-neutral-700 shadow-sm px-3 py-2', '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', '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', isRight ? 'flex-row-reverse' : 'flex-row',
].join(' ')} ].join(' ')}
> >

View File

@ -23,6 +23,7 @@ import { Team } from '../../../types/team'
import Alert from './Alert' import Alert from './Alert'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import Card from './Card'
type TeamWithPlayers = Team & { players?: MatchPlayer[] } type TeamWithPlayers = Team & { players?: MatchPlayer[] }
@ -454,27 +455,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
} }
}, [currentMapKey, allMaps, bestOf, isPickBanPhase]) // ← Dependencies }, [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 // Löschen
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm('Match wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return if (!confirm('Match wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return
@ -493,14 +473,43 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
} }
// Spieler-Tabelle // Spieler-Tabelle
const renderTable = (players: MatchPlayer[]) => { const renderTable = (
players: MatchPlayer[],
teamTitle: string,
showEdit: boolean,
onEditClick?: () => void
) => {
const sorted = [...players].sort( const sorted = [...players].sort(
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0), (a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
) )
return ( return (
<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>
<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>
<ColGroup />
<Table.Head> <Table.Head>
<Table.Row> <Table.Row>
{[ {[
@ -519,26 +528,23 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const title = banned ? banTooltip(p) : undefined; const title = banned ? banTooltip(p) : undefined;
return ( return (
<Table.Row <Table.Row key={p.user.steamId} title={title} className={`${banned ? 'bg-red-900/20' : ''}`}>
key={p.user.steamId}
title={title}
>
<Table.Cell <Table.Cell
className={`flex items-center ${banned ? 'bg-red-900/20' : undefined}`} className={`flex items-center`}
hoverable hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)} onClick={() => router.push(`/profile/${p.user.steamId}`)}
> >
<img <img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'} src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name} alt={p.user.name}
className={`mr-3 h-8 w-8 rounded-full`} className="mr-3 h-8 w-8 rounded-full"
/> />
<div className="text-base font-semibold flex items-center gap-2"> <div className="text-base font-semibold flex items-center gap-2">
<span>{p.user.name ?? 'Unbekannt'}</span> <span>{p.user.name ?? 'Unbekannt'}</span>
{banned && ( {banned && (
<span <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" 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"} aria-label={hasVacBan(p) ? 'Dieser Spieler hat einen VAC-Ban' : 'Dieser Spieler ist gebannt'}
> >
{hasVacBan(p) ? 'VAC' : 'BAN'} {hasVacBan(p) ? 'VAC' : 'BAN'}
</span> </span>
@ -556,12 +562,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<CompRankBadge rank={p.stats?.rankNew ?? 0} /> <CompRankBadge rank={p.stats?.rankNew ?? 0} />
)} )}
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && ( {match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span <span className={`text-sm ${p.stats.rankChange > 0 ? 'text-green-500' : p.stats.rankChange < 0 ? 'text-red-500' : ''}`}>
className={`text-sm ${
p.stats.rankChange > 0 ? 'text-green-500'
: p.stats.rankChange < 0 ? 'text-red-500' : ''
}`}
>
{p.stats.rankChange > 0 ? '+' : ''}{p.stats.rankChange} {p.stats.rankChange > 0 ? '+' : ''}{p.stats.rankChange}
</span> </span>
)} )}
@ -582,13 +583,20 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)} %</Table.Cell> <Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)} %</Table.Cell>
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
</Table.Row> </Table.Row>
); )
})} })}
</Table.Body> </Table.Body>
</Table> </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 ─────────────────── */ /* ─────────────────── Render ─────────────────── */
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -795,98 +803,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
)} )}
{/* Teams / Tabellen */} {/* Teams / Tabellen */}
<div className="mt-4 space-y-10 pt-4">
{/* Team A */} {/* Team A */}
<div> <div className="mt-4">
<div className="mb-2 flex items-center justify-between"> {renderTable(teamAPlayers, teamATitle, showEditA, () => setEditSide('A'))}
<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> </div>
{/* Team B */} {/* Team B */}
<div> <div className="mt-4">
<div className="mb-2 flex items-center justify-between"> {renderTable(teamBPlayers, teamBTitle, showEditB, () => setEditSide('B'))}
<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> </div>
{/* Echte Modals (außerhalb IIFE, State oben): */} {/* Echte Modals (außerhalb IIFE, State oben): */}

View File

@ -183,7 +183,7 @@ export default function MatchReadyOverlay({
let id: number | null = null let id: number | null = null
if (connecting) { if (connecting) {
setShowConnectHelp(false) setShowConnectHelp(false)
id = window.setTimeout(() => setShowConnectHelp(true), 30_000) id = window.setTimeout(() => setShowConnectHelp(true), 10_000)
} }
return () => { if (id) window.clearTimeout(id) } return () => { if (id) window.clearTimeout(id) }
}, [connecting]) }, [connecting])
@ -489,7 +489,7 @@ export default function MatchReadyOverlay({
) : showWaitHint ? ( ) : showWaitHint ? (
<div className="mt-[18px] mb-[6px] min-h-[100px] w-full flex flex-col items-center justify-center gap-2"> <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"> <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> </div>
<a <a
href={effectiveConnectHref} href={effectiveConnectHref}
@ -514,7 +514,7 @@ export default function MatchReadyOverlay({
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]"> <div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
{connecting ? ( {connecting ? (
showConnectHelp ? ( 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> <span className="text-[#f8e08e] font-semibold">Es lädt nicht?</span>
<a <a
href={effectiveConnectHref} 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 */} {/* 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"> <div className="min-w-0 flex flex-col h-dvh min-h-0">
{/* Nur HIER scrollen */} {/* Nur HIER scrollen */}
<main className="flex-1 min-w-0 min-h-0 overflow-auto overscroll-contain"> <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 overscroll-contain">{children}</div> <div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div>
</main> </main>
<GameBannerSpacer className="hidden sm:block" /> <GameBannerSpacer className="hidden sm:block" />
</div> </div>

View File

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

View File

@ -42,7 +42,7 @@ export default async function ProfileLayout({
</div> </div>
{/* scrollender Content */} {/* 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} {children}
</div> </div>
</div> </div>

View File

@ -41,7 +41,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
</aside> </aside>
{/* rechte Spalte scrollt */} {/* 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> <main className="min-h-0">{children}</main>
</div> </div>
</div> </div>

View File

@ -251,49 +251,52 @@ async function ensureVote(matchId: string) {
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id: matchId }, where: { id: matchId },
include: { include: {
teamA: { teamA: { include: { leader: { select: { steamId: true, name: true, avatar: true, location: true, premierRank: true, isAdmin: true } } } },
include: { teamB: { include: { leader: { select: { steamId: true, name: true, avatar: true, location: true, premierRank: true, isAdmin: true } } } },
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:
teamAUsers: true, teamAUsers: true,
teamBUsers: true, teamBUsers: true,
// optional zusätzlich: players: { include: { user: true } },
players: { include: { user: true } }, // falls du MatchPlayer brauchst
mapVote: { include: { steps: true } }, mapVote: { include: { steps: true } },
}, },
}) });
if (!match) return { match: null, vote: null } if (!match) return { match: null, vote: null };
// Bereits vorhanden? // Aktive Maps lt. Konfiguration (ohne Lobby)
if (match.mapVote) return { match, vote: match.mapVote } 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 // Neu anlegen
const bestOf = match.matchType === 'community' ? 3 : 1 const bestOf = match.matchType === 'community' ? 3 : 1;
const mapPool = MAP_OPTIONS.filter(m => m.active).map(m => m.key) const mapPool = CURRENT_ACTIVE; // neu: ohne Lobby & nur aktuelle Aktive
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null }) 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 firstIsA = (typeof randomInt === 'function') ? randomInt(0, 2) === 0 : Math.random() < 0.5;
const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id;
const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id;
const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId) const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId);
const created = await prisma.mapVote.create({ const created = await prisma.mapVote.create({
data: { data: {
@ -303,18 +306,12 @@ async function ensureVote(matchId: string) {
currentIdx: 0, currentIdx: 0,
locked: false, locked: false,
opensAt, opensAt,
steps : { steps: { create: stepsDef.map(s => ({ order: s.order, action: s.action as MapVoteAction, teamId: s.teamId })) },
create: stepsDef.map(s => ({
order : s.order,
action: s.action as MapVoteAction,
teamId: s.teamId,
})),
},
}, },
include: { steps: true }, include: { steps: true },
}) });
return { match, vote: created } return { match, vote: created };
} }
function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | null }>) { 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 it = byMap[k]
const denom = it.wins + it.losses + it.ties const denom = it.wins + it.losses + it.ties
const ratio = denom > 0 ? (it.wins + 0.5 * it.ties) / denom : 0 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 // 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)); } try { if (allPlayers.length) bans = await getPlayerBans(allPlayers.map(p => p.steamId)); }
catch (e) { log.warn(`⚠️ Steam Ban Fetch fehlgeschlagen: ${String(e)}`); } catch (e) { log.warn(`⚠️ Steam Ban Fetch fehlgeschlagen: ${String(e)}`); }
console.log(bans);
const relativePath = path.relative(process.cwd(), actualDemoPath); const relativePath = path.relative(process.cwd(), actualDemoPath);
// Match anlegen // Match anlegen

View File

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