updated
This commit is contained in:
parent
72a0ca015f
commit
c6dd921400
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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): */}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 }>) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user