updated for build

This commit is contained in:
Linrador 2025-10-14 15:30:11 +02:00
parent 4b3a8ae323
commit 5a3faaf1fe
100 changed files with 4361 additions and 3216 deletions

View File

@ -9,12 +9,6 @@ import ServerView from '../../components/admin/server/ServerView'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// Wichtig: Promises wie in .next/types
type PageProps = {
params?: Promise<{ locale?: string }>
searchParams?: Promise<Record<string, string | string[] | undefined>>
}
async function ensureConfig() { async function ensureConfig() {
return prisma.serverConfig.upsert({ return prisma.serverConfig.upsert({
where: { id: 'default' }, where: { id: 'default' },
@ -29,16 +23,20 @@ async function ensureConfig() {
}) })
} }
export default async function AdminServerPage(_props: PageProps) { export default async function AdminServerPage() {
// Falls du locale brauchst:
// const { locale } = (await _props.params) ?? {}
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const me = session?.user as any | undefined
if (!me?.steamId || !me?.isAdmin) { type AdminUser = { steamId: string; isAdmin: boolean }
const isAdminUser = (u: unknown): u is AdminUser => {
const r = u as Record<string, unknown> | null | undefined
return !!r && typeof r.steamId === 'string' && typeof r.isAdmin === 'boolean'
}
const meUnknown = session?.user
if (!isAdminUser(meUnknown)) {
redirect('/') redirect('/')
} }
const me = meUnknown // ab hier getypt: AdminUser
const [cfg, meUser] = await Promise.all([ const [cfg, meUser] = await Promise.all([
ensureConfig(), ensureConfig(),
@ -73,7 +71,7 @@ export default async function AdminServerPage(_props: PageProps) {
if (clientApiKey) { if (clientApiKey) {
await tx.user.update({ await tx.user.update({
where: { steamId: me?.steamId }, where: { steamId: me.steamId },
data: { pterodactylClientApiKey: clientApiKey }, data: { pterodactylClientApiKey: clientApiKey },
}) })
} }

View File

@ -1,7 +1,7 @@
// /src/app/(admin)/admin/teams/[teamId]/TeamAdminClient.tsx // /src/app/(admin)/admin/teams/[teamId]/TeamAdminClient.tsx
'use client' 'use client'
import { useCallback, useEffect, useState, useRef } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import LoadingSpinner from '../../../components/LoadingSpinner' import LoadingSpinner from '../../../components/LoadingSpinner'
import TeamMemberView from '../../../components/TeamMemberView' import TeamMemberView from '../../../components/TeamMemberView'
@ -21,7 +21,6 @@ export default function TeamAdminClient({ teamId }: Props) {
const [showLeaveModal, setShowLeaveModal] = useState(false) const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false) const [showInviteModal, setShowInviteModal] = useState(false)
const fetchTeam = useCallback(async () => { const fetchTeam = useCallback(async () => {
const result = await reloadTeam(teamId) const result = await reloadTeam(teamId)
if (result) setTeam(result) if (result) setTeam(result)
@ -32,19 +31,18 @@ export default function TeamAdminClient({ teamId }: Props) {
if (teamId) fetchTeam() if (teamId) fetchTeam()
}, [teamId, fetchTeam]) }, [teamId, fetchTeam])
// 👇 WICHTIG: subscribe by steamId (passt zu deinem SSE-Server)
useEffect(() => { useEffect(() => {
const steamId = session?.user?.steamId const steamId = session?.user?.steamId
if (!steamId) return if (!steamId) return
// ggf. .env nutzen: z. B. NEXT_PUBLIC_SSE_URL=http://localhost:3001
const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001' const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001'
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}` const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`
let es: EventSource | null = new EventSource(url, { withCredentials: false }) let es: EventSource | null = new EventSource(url, { withCredentials: false })
const onTeamUpdated = (ev: MessageEvent) => { // Listener als EventListener typisieren
const onTeamUpdated: EventListener = (ev) => {
try { try {
const msg = JSON.parse(ev.data) const msg = JSON.parse((ev as MessageEvent).data as string)
if (msg.teamId === teamId) { if (msg.teamId === teamId) {
fetchTeam() fetchTeam()
} }
@ -56,11 +54,9 @@ export default function TeamAdminClient({ teamId }: Props) {
es.addEventListener('team-updated', onTeamUpdated) es.addEventListener('team-updated', onTeamUpdated)
es.onerror = () => { es.onerror = () => {
// sanftes Reconnect
es?.close() es?.close()
es = null es = null
setTimeout(() => { setTimeout(() => {
// neuer EventSource
const next = new EventSource(url, { withCredentials: false }) const next = new EventSource(url, { withCredentials: false })
next.addEventListener('team-updated', onTeamUpdated) next.addEventListener('team-updated', onTeamUpdated)
next.onerror = () => { next.close() } next.onerror = () => { next.close() }
@ -69,7 +65,7 @@ export default function TeamAdminClient({ teamId }: Props) {
} }
return () => { return () => {
es?.removeEventListener('team-updated', onTeamUpdated as any) es?.removeEventListener('team-updated', onTeamUpdated)
es?.close() es?.close()
} }
}, [session?.user?.steamId, teamId, fetchTeam]) }, [session?.user?.steamId, teamId, fetchTeam])

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/Button.tsx
'use client' 'use client'
import { ReactNode, forwardRef, useState, useRef, useEffect, ButtonHTMLAttributes } from 'react' import { ReactNode, forwardRef, useState, useRef, useEffect, ButtonHTMLAttributes } from 'react'
@ -40,7 +42,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
ref ref
) { ) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [direction, setDirection] = useState<'up' | 'down'>('down') const [, setDirection] = useState<'up' | 'down'>('down')
const localRef = useRef<HTMLButtonElement>(null) const localRef = useRef<HTMLButtonElement>(null)
const buttonRef = (ref as React.RefObject<HTMLButtonElement>) || localRef const buttonRef = (ref as React.RefObject<HTMLButtonElement>) || localRef
@ -159,7 +161,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
setDirection(spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? 'up' : 'down') setDirection(spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? 'up' : 'down')
}) })
} }
}, [open, dropDirection]) }, [buttonRef, open, dropDirection])
const toggle = (event: React.MouseEvent<HTMLButtonElement>) => { const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
const next = !open const next = !open

View File

@ -1,4 +1,4 @@
// components/Chart.tsx // /src/app/[locale]/components/Chart.tsx
'use client'; 'use client';
import React, { import React, {
@ -16,6 +16,8 @@ import {
type ChartData, type ChartData,
type ChartOptions, type ChartOptions,
type Plugin, type Plugin,
type ChartConfiguration,
type RadialLinearScaleOptions,
CategoryScale, CategoryScale,
LinearScale, LinearScale,
RadialLinearScale, RadialLinearScale,
@ -120,7 +122,7 @@ function getImage(src: string): HTMLImageElement {
return img; return img;
} }
function _Chart<TType extends ChartJSType = ChartJSType>( function ChartInner<TType extends ChartJSType = ChartJSType>(
props: BaseProps<TType>, props: BaseProps<TType>,
ref: React.Ref<ChartHandle> ref: React.Ref<ChartHandle>
) { ) {
@ -141,16 +143,15 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
onReady, onReady,
ariaLabel, ariaLabel,
radarIcons, // ⚠️ bewusst NICHT destrukturieren: radarIcons, radarIconLabelColor, radarAddRingOffset
// werden innerhalb von plugin/mergedOptions über `props.*` genutzt so vermeiden wir
// “defined but never used”-Warnungen.
radarIconSize = 40, radarIconSize = 40,
radarIconLabels = false, radarIconLabels = false,
radarIconLabelFont = '12px Inter, system-ui, sans-serif', radarIconLabelFont = '12px Inter, system-ui, sans-serif',
radarIconLabelColor = '#ffffff',
radarIconLabelMargin = 6, radarIconLabelMargin = 6,
radarHideTicks = false,
radarMax, radarMax,
radarStepSize, radarStepSize,
radarAddRingOffset = false,
} = props; } = props;
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
@ -161,7 +162,10 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const baseData = useMemo<ChartData<TType> | undefined>(() => { 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>;
// Datensätze in das erwartete Typfeld casten (ohne `any`)
const dsTyped = datasets as unknown as NonNullable<ChartData<TType>['datasets']>;
return { labels, datasets: dsTyped } as ChartData<TType>;
}, [data, labels, datasets]); }, [data, labels, datasets]);
// ▼ Für RADAR: Daten intern um +20 verschieben (Plot), Original bleibt in Props. // ▼ Für RADAR: Daten intern um +20 verschieben (Plot), Original bleibt in Props.
@ -169,62 +173,91 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
if (!baseData) return baseData; if (!baseData) return baseData;
if (type !== 'radar') return baseData; if (type !== 'radar') return baseData;
// flache Kopie, Datensätze klonen und data +20 // datasets generisch transformieren (ohne `any`)
const cloned: any = { const dsArray =
...baseData, (baseData.datasets ?? []) as unknown as Array<Record<string, unknown>>;
datasets: (baseData.datasets ?? []).map((ds: any) => { const dsShifted = dsArray.map((ds) => {
// Nur numerische Arrays anfassen const d = (ds.data as unknown[] | undefined)?.map((v) =>
const d = Array.isArray(ds.data)
? ds.data.map((v: any) =>
typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v
) );
: ds.data; return { ...ds, data: d } as Record<string, unknown>;
});
return { ...ds, data: d }; return {
}), labels: baseData.labels as ChartData<TType>['labels'],
}; datasets: dsShifted as unknown as NonNullable<ChartData<TType>['datasets']>,
return cloned; } as ChartData<TType>;
}, [baseData, type]); }, [baseData, type]);
/* ---------- Radar Scale ---------- */ // ---------- 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)'; 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: NonNullable<RadialLinearScaleOptions['ticks']> = {
beginAtZero: true, display: true,
showLabelBackdrop: false,
color: 'rgba(255,255,255,0.6)', color: 'rgba(255,255,255,0.6)',
font: { size: 12 },
padding: 0,
backdropColor: 'transparent', backdropColor: 'transparent',
...(radarHideTicks ? { display: false } : {}), backdropPadding: 0,
showLabelBackdrop: false,
textStrokeColor: 'transparent',
textStrokeWidth: 0,
z: 0,
major: { enabled: false },
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}), ...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
callback: (value) => String(value),
}; };
const r: any = { const pointLabels: NonNullable<RadialLinearScaleOptions['pointLabels']> = {
suggestedMin: 0, display: false,
grid: { color: gridColor, lineWidth: 1 }, color: '#ffffff',
angleLines: { display: true, color: angleColor, lineWidth: 1 }, font: { size: 12 },
padding: 0,
backdropColor: 'transparent',
backdropPadding: 0,
borderRadius: 0,
centerPointLabels: false,
};
// ⬇︎ HIER: r als RadialLinearScaleOptions typisieren
const r: RadialLinearScaleOptions = {
display: true,
alignToPixels: false,
backgroundColor: 'transparent',
reverse: false,
ticks, ticks,
pointLabels: { display: false }, grid: { color: gridColor, lineWidth: 1 },
angleLines: {
display: true,
color: angleColor,
lineWidth: 1,
borderDash: [],
borderDashOffset: 0,
},
pointLabels,
suggestedMin: 0,
}; };
// WICHTIG: max anheben, damit +20 nicht abschneidet // ⬇︎ ohne any cast
if (typeof radarMax === 'number') { if (typeof radarMax === 'number') {
r.max = radarMax + RADAR_OFFSET; r.max = radarMax + RADAR_OFFSET;
r.suggestedMax = radarMax + RADAR_OFFSET; r.suggestedMax = radarMax + RADAR_OFFSET;
} }
return { r }; return { r };
}, [type, radarHideTicks, radarStepSize, radarMax]); }, [type, radarStepSize, radarMax]);
/* ---------- Radar Icons Plugin ---------- */ /* ---------- Radar Icons Plugin ---------- */
const [radarPlugin] = useState<Plugin<'radar'>>(() => ({ const [radarPlugin] = useState<Plugin<'radar'>>(() => ({
id: 'radarIconsPlugin', id: 'radarIconsPlugin',
afterDatasetsDraw(chart) { afterDatasetsDraw(chart) {
const ctx = chart.ctx as CanvasRenderingContext2D; const ctx = chart.ctx as CanvasRenderingContext2D;
const scale: any = (chart as any).scales?.r; const maybeScales = (chart as unknown as { scales?: { r?: { max: number; getPointPositionForValue: (i: number, v: number) => { x: number; y: number } } } }).scales;
const scale = maybeScales?.r;
if (!scale) return; if (!scale) return;
const lbls = chart.data.labels as string[] | undefined; const lbls = chart.data.labels as string[] | undefined;
@ -233,14 +266,14 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const icons = props.radarIcons ?? []; const icons = props.radarIcons ?? [];
ctx.save(); ctx.save();
(ctx as any).resetTransform?.(); (ctx as unknown as { resetTransform?: () => void }).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();
const ca = (chart as any).chartArea as { left:number; right:number; top:number; bottom:number } | undefined; const ca = (chart as unknown as { chartArea?: { left: number; right: number; top: number; bottom: number } }).chartArea;
const cx0 = scale.xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width as number) / 2); const cx0 = (scale as unknown as { xCenter?: number }).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 as unknown as { yCenter?: number }).yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2);
const half = (props.radarIconSize ?? 40) / 2; const half = (props.radarIconSize ?? 40) / 2;
const gap = Math.max(4, props.radarIconLabelMargin ?? 6); const gap = Math.max(4, props.radarIconLabelMargin ?? 6);
@ -251,9 +284,9 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
ctx.fillStyle = props.radarIconLabelColor ?? '#ffffff'; ctx.fillStyle = props.radarIconLabelColor ?? '#ffffff';
for (let i = 0; i < lbls.length; i++) { for (let i = 0; i < lbls.length; i++) {
const p = scale.getPointPositionForValue(i, scale.max); const p = scale.getPointPositionForValue(i, (scale as unknown as { max: number }).max);
const px = p.x as number; const px = p.x;
const py = p.y as number; const py = p.y;
const dx = px - cx0; const dx = px - cx0;
const dy = py - cy0; const dy = py - cy0;
@ -298,16 +331,18 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
if (type === 'radar') { if (type === 'radar') {
// Scales zusammenführen // Scales zusammenführen
(o as any).scales = { (o as unknown as { scales?: Record<string, unknown> }).scales = {
...(radarScaleOpts ?? {}), ...(radarScaleOpts ?? {}),
...(options?.scales as any), ...(options?.scales as unknown as Record<string, unknown>),
}; };
// Tooltip: echten Wert (ohne Offset) anzeigen // Tooltip: echten Wert (ohne Offset) anzeigen
const userTooltip = options?.plugins?.tooltip; const userTooltip = options?.plugins?.tooltip;
const userLabelCb = userTooltip?.callbacks?.label; const userLabelCb = userTooltip?.callbacks?.label as
| ((this: unknown, c: unknown) => unknown)
| undefined;
(o as any).plugins = { (o as unknown as { plugins?: Record<string, unknown> }).plugins = {
legend: { display: false }, legend: { display: false },
title: { display: false }, title: { display: false },
...options?.plugins, ...options?.plugins,
@ -315,14 +350,15 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
...userTooltip, ...userTooltip,
callbacks: { callbacks: {
...userTooltip?.callbacks, ...userTooltip?.callbacks,
label: function (this: any, ctx: any) { label: function (this: unknown, rawCtx: unknown) {
const shifted = Number(ctx.raw); const ctx = rawCtx as { raw?: unknown; dataset?: { label?: string } };
const shifted = Number((ctx as { raw?: unknown }).raw);
const original = Number.isFinite(shifted) ? shifted - RADAR_OFFSET : shifted; const original = Number.isFinite(shifted) ? shifted - RADAR_OFFSET : shifted;
const shown = Math.round(original); // ⬅️ hier auf ganze Zahlen runden const shown = Math.round(original);
if (typeof userLabelCb === 'function') { if (typeof userLabelCb === 'function') {
const clone = { ...ctx, raw: shown, parsed: { r: shown } }; const clone = { ...(ctx as Record<string, unknown>), raw: shown, parsed: { r: shown } };
return (userLabelCb as (this: any, c: any) => any).call(this, clone); return userLabelCb.call(this, clone);
} }
const dsLabel = ctx.dataset?.label ? `${ctx.dataset.label}: ` : ''; const dsLabel = ctx.dataset?.label ? `${ctx.dataset.label}: ` : '';
@ -330,7 +366,7 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
}, },
}, },
}, },
}; } as Record<string, unknown>;
// Layout-Padding für Außenbeschriftungen (Icons/Labels) // Layout-Padding für Außenbeschriftungen (Icons/Labels)
const fontPx = (() => { const fontPx = (() => {
@ -342,18 +378,18 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
(radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) + (radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) +
6 6
); );
const currentPadding = (o as any).layout?.padding ?? {}; const currentPadding = (o as unknown as { layout?: { padding?: Record<string, number> } }).layout?.padding ?? {};
(o as any).layout = { (o as unknown as { layout?: { padding?: Record<string, number> } }).layout = {
...(o as any).layout, ...(o as unknown as { layout?: Record<string, unknown> }).layout,
padding: { padding: {
top: Math.max(pad, currentPadding.top ?? 0), top: Math.max(pad, (currentPadding as Record<string, number>).top ?? 0),
right: Math.max(pad, currentPadding.right ?? 0), right: Math.max(pad, (currentPadding as Record<string, number>).right ?? 0),
bottom: Math.max(pad, currentPadding.bottom ?? 0), bottom: Math.max(pad, (currentPadding as Record<string, number>).bottom ?? 0),
left: Math.max(pad, currentPadding.left ?? 0), left: Math.max(pad, (currentPadding as Record<string, number>).left ?? 0),
}, },
}; };
} else if (options?.scales) { } else if (options?.scales) {
(o as any).scales = { ...(options.scales as any) }; (o as unknown as { scales?: Record<string, unknown> }).scales = { ...(options.scales as unknown as Record<string, unknown>) };
} }
return o; return o;
@ -371,7 +407,7 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
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);
if (type === 'radar') list.push(radarPlugin as any); if (type === 'radar') list.push(radarPlugin as unknown as Plugin<TType>);
return list; return list;
}, [plugins, type, radarPlugin]); }, [plugins, type, radarPlugin]);
@ -379,7 +415,10 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const config = useMemo( const config = useMemo(
() => ({ () => ({
type, type,
data: (type === 'radar' ? (shiftedData ?? { labels: [], datasets: [] }) : (baseData ?? { 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,
}), }),
@ -395,7 +434,8 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
if (mustRecreate) { if (mustRecreate) {
chartRef.current?.destroy(); chartRef.current?.destroy();
chartRef.current = new ChartJS(canvasRef.current, config as any); const cfg = config as unknown as ChartConfiguration;
chartRef.current = new ChartJS(canvasRef.current, cfg);
prevTypeRef.current = type; prevTypeRef.current = type;
onReady?.(chartRef.current); onReady?.(chartRef.current);
return () => { return () => {
@ -403,14 +443,16 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
chartRef.current = null; chartRef.current = null;
}; };
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, type, redraw, onReady]); }, [config, type, redraw, onReady]);
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 = (type === 'radar' ? (shiftedData ?? c.data) : (baseData ?? c.data));
(c as any).options = mergedOptions; (c as unknown as { data: unknown }).data =
(type === 'radar' ? (shiftedData ?? (c as unknown as { data: unknown }).data) : (baseData ?? (c as unknown as { data: unknown }).data));
(c as unknown as { options: unknown }).options = mergedOptions;
c.update(); c.update();
}, [baseData, shiftedData, mergedOptions, type, redraw]); }, [baseData, shiftedData, mergedOptions, type, redraw]);
@ -443,8 +485,8 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
); );
} }
const Chart = forwardRef(_Chart) as <TType extends ChartJSType = ChartJSType>( const Chart = forwardRef(ChartInner) as <TType extends ChartJSType = ChartJSType>(
p: BaseProps<TType> & { ref?: React.Ref<ChartHandle> } p: BaseProps<TType> & { ref?: React.Ref<ChartHandle> }
) => ReturnType<typeof _Chart>; ) => ReturnType<typeof ChartInner>;
export default Chart; export default Chart;

View File

@ -1,15 +1,21 @@
// /src/app/[locale]/components/ComboBox.tsx
'use client' 'use client'
import { useState } from 'react'
type ComboItem = { id: string; label: string } type ComboItem = { id: string; label: string }
type ComboBoxProps = { type ComboBoxProps = {
value: string // ausgewählte ID value: string
items: ComboItem[] // { id, label } items: ComboItem[]
onSelect: (id: string) => void onSelect: (id: string) => void
} }
export default function ComboBox({ value, items, onSelect }: ComboBoxProps) { export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
const [isOpen, setIsOpen] = useState(false)
const selected = items.find(i => i.id === value) const selected = items.find(i => i.id === value)
const listboxId = 'combo-listbox'
return ( return (
<div id="hs-combobox-basic-usage" className="relative" data-hs-combo-box=""> <div id="hs-combobox-basic-usage" className="relative" data-hs-combo-box="">
@ -18,16 +24,21 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
className="py-2.5 sm:py-3 ps-4 pe-9 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" className="py-2.5 sm:py-3 ps-4 pe-9 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"
type="text" type="text"
role="combobox" role="combobox"
aria-expanded="false" aria-haspopup="listbox"
aria-controls={listboxId}
aria-expanded={isOpen}
value={selected?.label ?? ''} value={selected?.label ?? ''}
data-hs-combo-box-input="" data-hs-combo-box-input=""
readOnly readOnly
/> />
<div <button
type="button"
className="absolute top-1/2 end-3 -translate-y-1/2" className="absolute top-1/2 end-3 -translate-y-1/2"
aria-expanded="false" aria-expanded={isOpen}
role="button" aria-controls={listboxId}
aria-label="Auswahl öffnen"
data-hs-combo-box-toggle="" data-hs-combo-box-toggle=""
onClick={() => setIsOpen(o => !o)}
> >
<svg <svg
className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500" className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500"
@ -40,24 +51,28 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
<path d="m7 15 5 5 5-5" /> <path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" /> <path d="m7 9 5-5 5 5" />
</svg> </svg>
</div> </button>
</div> </div>
<div <div
id={listboxId}
className="absolute z-50 w-full max-h-72 p-1 mt-1 bg-white border border-gray-200 rounded-lg overflow-y-auto dark:bg-neutral-900 dark:border-neutral-700" className="absolute z-50 w-full max-h-72 p-1 mt-1 bg-white border border-gray-200 rounded-lg overflow-y-auto dark:bg-neutral-900 dark:border-neutral-700"
style={{ display: 'none' }} style={{ display: isOpen ? 'block' : 'none' }}
role="listbox" role="listbox"
data-hs-combo-box-output="" data-hs-combo-box-output=""
> >
{items.map((item) => ( {items.map((item) => {
const selectedOpt = item.id === value
return (
<div <div
key={item.id} key={item.id}
className="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800" className="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800"
role="option" role="option"
aria-selected={selectedOpt}
tabIndex={0} tabIndex={0}
data-hs-combo-box-output-item="" data-hs-combo-box-output-item=""
data-hs-combo-box-item-stored-data={JSON.stringify({ id: item.id, name: item.label })} data-hs-combo-box-item-stored-data={JSON.stringify({ id: item.id, name: item.label })}
onClick={() => onSelect(item.id)} onClick={() => { onSelect(item.id); setIsOpen(false) }}
> >
<div className="flex justify-between items-center w-full"> <div className="flex justify-between items-center w-full">
<span <span
@ -66,7 +81,7 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
> >
{item.label} {item.label}
</span> </span>
{item.id === value && ( {selectedOpt && (
<span className="hidden hs-combo-box-selected:block"> <span className="hidden hs-combo-box-selected:block">
<svg <svg
className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500" className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500"
@ -86,7 +101,8 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
)} )}
</div> </div>
</div> </div>
))} )
})}
</div> </div>
</div> </div>
) )

View File

@ -4,12 +4,9 @@
import { useEffect, useState, useCallback, useMemo } from 'react' import { useEffect, useState, useCallback, useMemo } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl' import { useTranslations, useLocale } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Switch from '../components/Switch' import Switch from '../components/Switch'
import Button from './Button' import Button from './Button'
import Modal from './Modal' import Modal from './Modal'
@ -22,12 +19,33 @@ type Props = { matchType?: string }
const getTeamLogo = (logo?: string | null) => const getTeamLogo = (logo?: string | null) =>
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp' logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
const toDateKey = (d: Date) => d.toISOString().slice(0, 10)
const weekdayDE = new Intl.DateTimeFormat('de-DE', { weekday: 'long' })
const weekdayEN = new Intl.DateTimeFormat('en-GB', { weekday: 'long' })
type TeamOption = { id: string; name: string; logo?: string | null } type TeamOption = { id: string; name: string; logo?: string | null }
type UnknownRec = Record<string, unknown>;
function isRecord(v: unknown): v is UnknownRec {
return !!v && typeof v === 'object';
}
type PlayerLike = {
user?: { steamId?: string | null } | null;
steamId?: string | null;
};
type TeamMaybeWithPlayers = {
id?: string | null;
players?: PlayerLike[] | null;
};
function toStringOrNull(v: unknown): string | null {
return typeof v === 'string' ? v : null;
}
function firstString(...vals: unknown[]): string {
for (const v of vals) if (typeof v === 'string') return v;
return '';
}
/** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */ /** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */
function combineLocalDateTime(dateStr: string, timeStr: string) { function combineLocalDateTime(dateStr: string, timeStr: string) {
const [y, m, d] = dateStr.split('-').map(Number) const [y, m, d] = dateStr.split('-').map(Number)
@ -64,14 +82,6 @@ type ZonedParts = {
year: number; month: number; day: number; hour: number; minute: number; year: number; month: number; day: number; hour: number; minute: number;
}; };
function getUserTimeZone(sessionTz?: string): string {
return (
sessionTz ||
Intl.DateTimeFormat().resolvedOptions().timeZone ||
'Europe/Berlin'
);
}
function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts { function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts {
const d = typeof date === 'string' ? new Date(date) : date; const d = typeof date === 'string' ? new Date(date) : date;
const parts = new Intl.DateTimeFormat(locale, { const parts = new Intl.DateTimeFormat(locale, {
@ -126,44 +136,42 @@ function isBanStatusFlagged(b?: BanStatus | null): boolean {
/** Liefert Info, ob Match gebannte Spieler enthält (zählt beide Seiten) */ /** Liefert Info, ob Match gebannte Spieler enthält (zählt beide Seiten) */
function matchBanInfo(m: Match): { hasBan: boolean; count: number; tooltip: string } { function matchBanInfo(m: Match): { hasBan: boolean; count: number; tooltip: string } {
// a) neues Shape (teamA.players / teamB.players) const teamA = (m.teamA as unknown as TeamMaybeWithPlayers);
const playersA = (m as any)?.teamA?.players ?? [] const teamB = (m.teamB as unknown as TeamMaybeWithPlayers);
const playersB = (m as any)?.teamB?.players ?? []
// b) Fallback: flaches players-Array (falls API alt) const playersA = Array.isArray(teamA?.players) ? teamA.players! : [];
const flat = (m as any)?.players ?? [] const playersB = Array.isArray(teamB?.players) ? teamB.players! : [];
const all = Array.isArray(playersA) || Array.isArray(playersB) // Fallback: flaches players-Array (ältere API)
? [...(playersA ?? []), ...(playersB ?? [])] const flat = (m as unknown as { players?: PlayerLike[] | null }).players ?? [];
: Array.isArray(flat) ? flat : []
let count = 0 const all: PlayerLike[] =
const lines: string[] = [] playersA.length || playersB.length ? [...playersA, ...playersB] : Array.isArray(flat) ? flat : [];
let count = 0;
const lines: string[] = [];
for (const p of all) { for (const p of all) {
const user = p?.user ?? p // (Fallback falls p schon der User ist) const user = (p?.user as unknown as { name?: string | null; banStatus?: BanStatus | null }) ?? {};
const name = user?.name ?? 'Unbekannt' const name = user?.name ?? 'Unbekannt';
const b: BanStatus | undefined = user?.banStatus const b = user?.banStatus ?? null;
if (isBanStatusFlagged(b)) { if (isBanStatusFlagged(b)) {
count++ count++;
const parts: string[] = [] const parts: string[] = [];
if (b?.vacBanned) parts.push('VAC aktiv') if (b?.vacBanned) parts.push('VAC aktiv');
if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`) if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`);
if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`) if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`);
if (b?.communityBanned) parts.push('Community') if (b?.communityBanned) parts.push('Community');
if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`) if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`);
if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`) if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`);
lines.push(`${name}: ${parts.join(' · ')}`) lines.push(`${name}: ${parts.join(' · ')}`);
} }
} }
return { hasBan: count > 0, count, tooltip: lines.join('\n') } return { hasBan: count > 0, count, tooltip: lines.join('\n') };
} }
export default function CommunityMatchList({ matchType }: Props) { export default function CommunityMatchList({ matchType }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const router = useRouter()
const pathname = usePathname()
const locale = useLocale() const locale = useLocale()
const userTZ = useUserTimeZone([session?.user?.steamId]) const userTZ = useUserTimeZone([session?.user?.steamId])
const weekdayFmt = useMemo(() => const weekdayFmt = useMemo(() =>
@ -211,26 +219,36 @@ export default function CommunityMatchList({ matchType }: Props) {
const mySteamId = session?.user?.steamId const mySteamId = session?.user?.steamId
const isOwnMatch = useCallback((m: any) => { const isOwnMatch = useCallback((m: Match) => {
if (!mySteamId) return false if (!mySteamId) return false;
// a) Neues Shape: teamA.players / teamB.players -> p.user.steamId const teamA = (m.teamA as unknown as TeamMaybeWithPlayers);
const inTeamA = m?.teamA?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false const teamB = (m.teamB as unknown as TeamMaybeWithPlayers);
const inTeamB = m?.teamB?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false
if (inTeamA || inTeamB) return true
// b) Manchmal flaches players-Array (falls noch vorhanden) const inTeamA =
const inFlat = m?.players?.some((p: any) => Array.isArray(teamA?.players) &&
p?.user?.steamId === mySteamId || p?.steamId === mySteamId teamA.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
) ?? false
if (inFlat) return true
// c) Fallback (nur wenn du es noch möchtest): Team-Mitgliedschaft const inTeamB =
Array.isArray(teamB?.players) &&
teamB.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
if (inTeamA || inTeamB) return true;
// Fallback: flaches players-Array
const flatPlayers = (m as unknown as { players?: PlayerLike[] | null }).players ?? [];
const inFlat =
Array.isArray(flatPlayers) &&
flatPlayers.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
if (inFlat) return true;
// Optionaler Fallback: Team-Mitgliedschaft
const byTeamMembership = const byTeamMembership =
!!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team) !!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team);
return byTeamMembership return byTeamMembership;
}, [mySteamId, session?.user?.team]) }, [mySteamId, session?.user?.team]);
useEffect(() => { useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000) const id = setInterval(() => setNow(Date.now()), 1000)
@ -305,23 +323,23 @@ export default function CommunityMatchList({ matchType }: Props) {
credentials: 'same-origin', // wichtig: Cookies mitnehmen credentials: 'same-origin', // wichtig: Cookies mitnehmen
signal: ctrl.signal, signal: ctrl.signal,
}) })
const json = await res.json().catch(() => ({} as any)) const json: unknown = await res.json().catch(() => ({}));
const raw: unknown =
(isRecord(json) && Array.isArray(json.teams)) ? json.teams :
(isRecord(json) && Array.isArray(json.data)) ? json.data :
(isRecord(json) && Array.isArray(json.items)) ? json.items :
Array.isArray(json) ? json : [];
// ➜ egal ob {teams: [...]}, {data: [...]}, {items: [...]} oder direkt [...] const arr: UnknownRec[] = Array.isArray(raw) ? (raw as UnknownRec[]) : [];
const raw =
Array.isArray(json?.teams) ? json.teams :
Array.isArray(json?.data) ? json.data :
Array.isArray(json?.items) ? json.items :
Array.isArray(json) ? json :
[]
const opts: TeamOption[] = raw const opts: TeamOption[] = arr
.map((t: any) => ({ .map((r) => {
id: t.id ?? t._id ?? t.teamId ?? t.uuid ?? '', const id = firstString(r.id, r._id, r.teamId, r.uuid);
name: t.name ?? t.title ?? t.displayName ?? t.tag ?? 'Unbenanntes Team', const name = firstString(r.name, r.title, r.displayName, r.tag) || 'Unbenanntes Team';
logo: t.logo ?? t.logoUrl ?? t.image ?? null, const logo = toStringOrNull(r.logo ?? r.logoUrl ?? r.image) ?? null;
})) return { id, name, logo };
.filter((t: TeamOption) => !!t.id && !!t.name) })
.filter((t) => !!t.id && !!t.name);
if (!ignore) setTeams(opts) if (!ignore) setTeams(opts)
} catch (e) { } catch (e) {

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/CreateTeamButton.tsx
'use client' 'use client'
import { useState, forwardRef } from 'react' import { useState, forwardRef } from 'react'
@ -6,10 +8,22 @@ import Modal from './Modal'
import Button from './Button' import Button from './Button'
type CreateTeamButtonProps = { type CreateTeamButtonProps = {
/** Optional: Parent kann damit eine Liste refreshen */
setRefetchKey?: (key: string) => void setRefetchKey?: (key: string) => void
} }
type HSOverlayAPI = {
close: (el: Element | null) => void
};
type WindowWithHSOverlay = Window & {
HSOverlay?: HSOverlayAPI;
};
type CreateTeamResponse = {
team: { name: string };
message?: string;
};
const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>( const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
({ setRefetchKey }, ref) => { ({ setRefetchKey }, ref) => {
const { data: session } = useSession() const { data: session } = useSession()
@ -23,8 +37,9 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
const closeCreateModalAndCleanup = () => { const closeCreateModalAndCleanup = () => {
const modalEl = document.getElementById('modal-create-team') const modalEl = document.getElementById('modal-create-team')
if (modalEl && (window as any).HSOverlay?.close) { const win = window as WindowWithHSOverlay;
;(window as any).HSOverlay.close(modalEl) if (modalEl && win.HSOverlay?.close) {
win.HSOverlay.close(modalEl);
} }
setShowModal(false) setShowModal(false)
@ -56,7 +71,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
body: JSON.stringify({ teamname, leader: session?.user?.steamId }), body: JSON.stringify({ teamname, leader: session?.user?.steamId }),
}) })
const result = await res.json() const result: CreateTeamResponse = await res.json();
if (!res.ok) throw new Error(result.message || 'Fehler beim Erstellen') if (!res.ok) throw new Error(result.message || 'Fehler beim Erstellen')
setStatus('success') setStatus('success')
@ -71,9 +86,10 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
setRefetchKey?.(Date.now().toString()) setRefetchKey?.(Date.now().toString())
}) })
}, 800) }, 800)
} catch (err: any) { } catch (err: unknown) {
setStatus('error') setStatus('error');
setMessage(err.message || 'Fehler beim Erstellen des Teams') const msg = err instanceof Error ? err.message : 'Fehler beim Erstellen des Teams';
setMessage(msg);
} }
} }

View File

@ -52,7 +52,7 @@ export default function DatePickerWithTime({ value, onChange }: DatePickerWithTi
useEffect(() => { useEffect(() => {
const newDate = new Date(year, month, value.getDate(), hour, minute); const newDate = new Date(year, month, value.getDate(), hour, minute);
onChange(newDate); onChange(newDate);
}, [hour, minute, year, month]); }, [onChange, value, hour, minute, year, month]);
useEffect(() => { useEffect(() => {
if (showPicker && buttonRef.current) { if (showPicker && buttonRef.current) {

View File

@ -1,22 +1,38 @@
// /src/app/[locale]/components/EditButton.tsx
'use client' 'use client'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Link from 'next/link' import Link from 'next/link'
import type { Match as FullMatch } from '@/types/match'
export default function EditButton({ match }: { match: any }) { type Leaderish = string | { steamId?: string | null } | null | undefined
type TeamLeaderView = { leader?: Leaderish } | null | undefined
// Wir brauchen nur id, teamA, teamB und jeweils den leader
type MatchForEditButton = Pick<FullMatch, 'id' | 'teamA' | 'teamB'> & {
teamA: TeamLeaderView
teamB: TeamLeaderView
}
function leaderIdOf(l: Leaderish): string | null {
if (!l) return null
if (typeof l === 'string') return l
return typeof l.steamId === 'string' ? l.steamId : null
}
export default function EditButton({ match }: { match: MatchForEditButton }) {
const { data: session } = useSession() const { data: session } = useSession()
const me = session?.user?.steamId ?? null
const isLeader = const leaderA = leaderIdOf(match.teamA?.leader)
session?.user?.steamId && const leaderB = leaderIdOf(match.teamB?.leader)
(session.user.steamId === match.teamA.leader || const isLeader = !!me && (me === leaderA || me === leaderB)
session.user.steamId === match.teamB.leader)
if (!isLeader) return null if (!isLeader) return null
return ( return (
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<Link <Link
href={`/matches/${match.id}/edit`} href={`/matches/${encodeURIComponent(String(match.id))}/edit`}
className="inline-block px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 transition" className="inline-block px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 transition"
> >
Match bearbeiten Match bearbeiten

View File

@ -1,4 +1,4 @@
// /src/app/components/EditMatchMetaModal.tsx // /src/app/[locale]/components/EditMatchMetaModal.tsx
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@ -13,6 +13,14 @@ type ZonedParts = {
year: number; month: number; day: number; hour: number; minute: number; year: number; month: number; day: number; hour: number; minute: number;
}; };
type UnknownRec = Record<string, unknown>;
const isRecord = (v: unknown): v is UnknownRec => !!v && typeof v === 'object';
const isTeamOption = (v: unknown): v is TeamOption =>
isRecord(v) &&
typeof v.id === 'string' &&
typeof v.name === 'string' &&
(v.logo == null || typeof v.logo === 'string');
type Props = { type Props = {
show: boolean show: boolean
onClose: () => void onClose: () => void
@ -78,27 +86,16 @@ export default function EditMatchMetaModal({
defaultTeamAName, defaultTeamAName,
defaultTeamBName, defaultTeamBName,
defaultDateISO, defaultDateISO,
// defaultMap,
defaultVoteLeadMinutes = 60,
onSaved, onSaved,
defaultBestOf = 3, defaultBestOf = 3,
}: Props) { }: Props) {
/* ───────── Utils ───────── */ /* ───────── Utils ───────── */
const normalizeBestOf = (bo: unknown): 3 | 5 => (Number(bo) === 5 ? 5 : 3) const normalizeBestOf = (bo: unknown): 3 | 5 => (Number(bo) === 5 ? 5 : 3)
const toDatetimeLocal = (iso?: string | null) => {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours()
)}:${pad(d.getMinutes())}`
}
/* ───────── Local state ───────── */ /* ───────── Local state ───────── */
const [title, setTitle] = useState(defaultTitle ?? '') const [title, setTitle] = useState(defaultTitle ?? '')
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '') const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '') const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
const userTZ = getUserTimeZone(); const userTZ = getUserTimeZone();
const initDT = isoToLocalDateTimeStrings(defaultDateISO, userTZ); const initDT = isoToLocalDateTimeStrings(defaultDateISO, userTZ);
@ -150,11 +147,12 @@ export default function EditMatchMetaModal({
;(async () => { ;(async () => {
try { try {
const res = await fetch('/api/teams', { cache: 'no-store' }) const res = await fetch('/api/teams', { cache: 'no-store' })
const data = res.ok ? await res.json() : [] const dataUnknown: unknown = res.ok ? await res.json() : []
if (!alive) return if (!alive) return
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? []) const listUnknown: unknown = Array.isArray(dataUnknown) ? dataUnknown : (isRecord(dataUnknown) ? dataUnknown.teams : []);
setTeams((list ?? []).filter((t: any) => t?.id && t?.name)) const list = Array.isArray(listUnknown) ? listUnknown.filter(isTeamOption) : [];
} catch (e: any) { setTeams(list)
} catch (e: unknown) {
if (!alive) return if (!alive) return
console.error('[EditMatchMetaModal] load teams failed:', e) console.error('[EditMatchMetaModal] load teams failed:', e)
setTeams([]) setTeams([])
@ -197,10 +195,7 @@ export default function EditMatchMetaModal({
const dt = isoToLocalDateTimeStrings(j?.matchDate ?? j?.demoDate ?? null, userTZ); const dt = isoToLocalDateTimeStrings(j?.matchDate ?? j?.demoDate ?? null, userTZ);
setMatchDateStr(dt.dateStr); setMatchDateStr(dt.dateStr);
setMatchTimeStr(dt.timeStr); setMatchTimeStr(dt.timeStr);
const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes)) const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes)) ? Number(j.mapVote.leadMinutes) : 60;
? Number(j.mapVote.leadMinutes)
: 60;
setVoteLead(leadMin);
// Vote-Open = MatchStart - leadMin // Vote-Open = MatchStart - leadMin
const matchISO = combineLocalDateTime(dt.dateStr, dt.timeStr); const matchISO = combineLocalDateTime(dt.dateStr, dt.timeStr);
@ -213,10 +208,10 @@ export default function EditMatchMetaModal({
setBestOf(boFromMeta) setBestOf(boFromMeta)
setMetaBestOf(boFromMeta) setMetaBestOf(boFromMeta)
setSaved(false) setSaved(false)
} catch (e: any) { } catch (e: unknown) {
if (!alive) return if (!alive) return
console.error('[EditMatchMetaModal] reload meta failed:', e) console.error('[EditMatchMetaModal] reload meta failed:', e)
setError(e?.message || 'Konnte aktuelle Match-Metadaten nicht laden.') setError(e instanceof Error ? e.message : 'Konnte aktuelle Match-Metadaten nicht laden.')
} finally { } finally {
if (alive) { if (alive) {
setLoadingMeta(false) setLoadingMeta(false)
@ -229,7 +224,7 @@ export default function EditMatchMetaModal({
alive = false alive = false
metaFetchedRef.current = false metaFetchedRef.current = false
} }
}, [show, matchId]) }, [show, matchId, userTZ])
/* ───────── Optionen für Selects ───────── */ /* ───────── Optionen für Selects ───────── */
const teamOptionsA = useMemo( const teamOptionsA = useMemo(
@ -241,9 +236,6 @@ export default function EditMatchMetaModal({
[teams, teamAId] [teams, teamAId]
) )
/* ───────── Hinweis-Flag nur vs. /meta ───────── */
const showBoChangedHint = metaBestOf !== null && bestOf !== metaBestOf
/* ───────── Validation ───────── */ /* ───────── Validation ───────── */
const canSave = useMemo(() => { const canSave = useMemo(() => {
if (saving || loadingMeta) return false if (saving || loadingMeta) return false
@ -256,7 +248,7 @@ export default function EditMatchMetaModal({
if (new Date(openISO).getTime() > new Date(matchISO).getTime()) return false if (new Date(openISO).getTime() > new Date(matchISO).getTime()) return false
if (teamAId && teamBId && teamAId === teamBId) return false if (teamAId && teamBId && teamAId === teamBId) return false
return true return true
}, [saving, loadingMeta, teamAId, teamBId]) }, [saving, loadingMeta, teamAId, teamBId, matchDateStr, matchTimeStr, voteOpenDateStr, voteOpenTimeStr])
/* ───────── Save ───────── */ /* ───────── Save ───────── */
const handleSave = async () => { const handleSave = async () => {
@ -298,9 +290,9 @@ export default function EditMatchMetaModal({
setSaved(true) setSaved(true)
onClose() onClose()
setTimeout(() => onSaved?.(), 0) setTimeout(() => onSaved?.(), 0)
} catch (e: any) { } catch (e: unknown) {
console.error('[EditMatchMetaModal] save error:', e) console.error('[EditMatchMetaModal] save error:', e)
setError(e?.message || 'Speichern fehlgeschlagen') setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally { } finally {
setSaving(false) setSaving(false)
} }

View File

@ -1,18 +1,10 @@
/* ------------------------------------------------------------------ // /src/app/[locale]/components/EditMatchPlayersModal.tsx
/app/components/EditMatchPlayersModal.tsx
zeigt ALLE Spieler des gewählten Teams & nutzt DroppableZone-IDs
"active" / "inactive" analog zur TeamMemberView.
------------------------------------------------------------------- */
'use client' 'use client'
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { import { DndContext, closestCenter, DragOverlay, type DragStartEvent, type DragEndEvent } from '@dnd-kit/core'
DndContext, closestCenter, DragOverlay, import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
} from '@dnd-kit/core'
import {
SortableContext, verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import SortableMiniCard from '../components/SortableMiniCard' import SortableMiniCard from '../components/SortableMiniCard'
@ -123,14 +115,14 @@ export default function EditMatchPlayersModal (props: Props) {
setLoading(false) setLoading(false)
} }
})() })()
}, [show, team?.id]) }, [show, team?.id, myInit, otherInitSet])
/* ---- DragnDrop-Handler -------------------------------- */ /* ---- DragnDrop-Handler -------------------------------- */
const onDragStart = ({ active }: any) => { const onDragStart = ({ active }: DragStartEvent) => {
setDragItem(players.find(p => p.steamId === active.id) ?? null) setDragItem(players.find(p => p.steamId === active.id) ?? null)
} }
const onDragEnd = ({ active, over }: any) => { const onDragEnd = ({ active, over }: DragEndEvent) => {
setDragItem(null) setDragItem(null)
if (!over) return if (!over) return

View File

@ -1,11 +1,11 @@
// /src/app/components/FaceitStat.tsx // /src/app/[locale]/components/FaceitStat.tsx
'use client' 'use client'
import React from 'react' import React from 'react'
import FaceitLevelImage from './FaceitLevelBadge' import FaceitLevelImage from './FaceitLevelBadge'
import FaceitElo from './FaceitElo' import FaceitElo from './FaceitElo'
export default function FaceitStat({ export default function FaceitStat({
level,
elo, elo,
size = 'md', size = 'md',
}: { }: {

View File

@ -1,3 +1,5 @@
// //src/app/[locale]/components/GameBanner.tsx
'use client' 'use client'
import React, {useEffect, useMemo, useRef, useState} from 'react' import React, {useEffect, useMemo, useRef, useState} from 'react'
@ -25,11 +27,9 @@ type Variant = 'connected' | 'disconnected'
/** ✅ NEU: Props, alle optional als Overrides */ /** ✅ NEU: Props, alle optional als Overrides */
type GameBannerProps = { type GameBannerProps = {
variant?: Variant variant?: Variant
/** true => Banner anzeigen (überschreibt interne Sichtbarkeitslogik), false => nie anzeigen */
visible?: boolean visible?: boolean
zIndex?: number zIndex?: number
inline?: boolean inline?: boolean
serverLabel?: string
mapKey?: string mapKey?: string
mapLabel?: string mapLabel?: string
bgUrl?: string bgUrl?: string
@ -38,15 +38,59 @@ type GameBannerProps = {
score?: string score?: string
connectedCount?: number connectedCount?: number
totalExpected?: number totalExpected?: number
missingCount?: number
onReconnect?: () => void onReconnect?: () => void
onDisconnect?: () => void onDisconnect?: () => void
} }
type PlayerLike = {
steamId?: string | number | null
steam_id?: string | number | null
id?: string | number | null
}
type PlayersMsg = {
type: 'players'
players: Array<PlayerLike | string | number>
}
type PlayerJoinMsg = {
type: 'player_join'
player: PlayerLike | string | number
}
type PlayerLeaveMsg = {
type: 'player_leave'
steamId?: string | number | null
steam_id?: string | number | null
id?: string | number | null
}
type ScoreMsg = {
type: 'score'
team1?: number
team2?: number
ct?: number
t?: number
}
type ScoreWrapMsg = {
score: { team1?: number; team2?: number; ct?: number; t?: number }
}
type TelemetryMsg =
| PlayersMsg
| PlayerJoinMsg
| PlayerLeaveMsg
| ScoreMsg
| ScoreWrapMsg
| Record<string, unknown>
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json()) const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
/* ---------- helpers ---------- */ /* ---------- helpers ---------- */
const hashStr = (s: string) => { let h = 5381; for (let i=0;i<s.length;i++) h=((h<<5)+h)+s.charCodeAt(i); return h|0 } const hashStr = (s: string) => { let h = 5381; for (let i=0;i<s.length;i++) h=((h<<5)+h)+s.charCodeAt(i); return h|0 }
const pickMapImageFromOptions = (mapKey?: string) => { const pickMapImageFromOptions = (mapKey?: string) => {
if (!mapKey) return null if (!mapKey) return null
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase()) const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
@ -54,13 +98,35 @@ const pickMapImageFromOptions = (mapKey?: string) => {
const idx = Math.abs(hashStr(mapKey)) % opt.images.length const idx = Math.abs(hashStr(mapKey)) % opt.images.length
return opt.images[idx] ?? null return opt.images[idx] ?? null
} }
const pickMapIcon = (mapKey?: string) => { const pickMapIcon = (mapKey?: string) => {
if (!mapKey) return null if (!mapKey) return null
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase()) const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
return opt?.icon ?? null return opt?.icon ?? null
} }
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String)) const sidOf = (p: PlayerLike | string | number | null | undefined): string => {
if (p == null) return ''
if (typeof p === 'string' || typeof p === 'number') return String(p)
const raw = p.steamId ?? p.steam_id ?? p.id
return raw == null ? '' : String(raw)
}
const toSet = (arr: Iterable<unknown>) => new Set(Array.from(arr, v => String(v)))
const parseWsData = (data: unknown): TelemetryMsg | null => {
try {
if (typeof data === 'string') return JSON.parse(data) as TelemetryMsg
if (data && typeof data === 'object') return data as TelemetryMsg
} catch { /* ignore */ }
return null
}
const toFiniteOrNull = (v: unknown): number | null => {
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) { function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
const h = (host ?? '').trim() || '127.0.0.1' const h = (host ?? '').trim() || '127.0.0.1'
const p = (port ?? '').trim() || '8081' const p = (port ?? '').trim() || '8081'
@ -80,7 +146,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
visible: visibleProp, visible: visibleProp,
zIndex: zIndexProp, zIndex: zIndexProp,
inline = false, inline = false,
serverLabel, // aktuell nicht gerendert, nur angenommen
mapKey: mapKeyProp, mapKey: mapKeyProp,
mapLabel: mapLabelProp, mapLabel: mapLabelProp,
bgUrl: bgUrlProp, bgUrl: bgUrlProp,
@ -89,7 +154,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
score: scoreProp, score: scoreProp,
connectedCount: connectedCountProp, connectedCount: connectedCountProp,
totalExpected: totalExpectedProp, totalExpected: totalExpectedProp,
missingCount: _missingCountProp, // aktuell nicht gerendert, nur angenommen
onReconnect, onReconnect,
onDisconnect, onDisconnect,
} = props } = props
@ -178,38 +242,45 @@ export default function GameBanner(props: GameBannerProps = {}) {
} }
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
let msg: any = null const msg = parseWsData(ev.data)
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return if (!msg) return
if (msg.type === 'players' && Array.isArray(msg.players)) { if ('type' in msg && msg.type === 'players' && Array.isArray((msg as PlayersMsg).players)) {
const ids = msg.players.map(sidOf).filter(Boolean) const ids = (msg as PlayersMsg).players.map(sidOf).filter(Boolean)
setTelemetrySet(toSet(ids)) setTelemetrySet(toSet(ids))
return return
} }
if (msg.type === 'player_join' && msg.player) {
const sid = sidOf(msg.player); if (!sid) return if ('type' in msg && msg.type === 'player_join' && 'player' in (msg as PlayerJoinMsg)) {
const sid = sidOf((msg as PlayerJoinMsg).player)
if (!sid) return
setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next }) setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next })
return return
} }
if (msg.type === 'player_leave') {
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? ''); if (!sid) return if ('type' in msg && msg.type === 'player_leave') {
const m = msg as PlayerLeaveMsg
const sid = sidOf({ steamId: m.steamId, steam_id: m.steam_id, id: m.id })
if (!sid) return
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next }) setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
return return
} }
if (msg.type === 'score') {
const a = Number(msg.team1 ?? msg.ct) if ('type' in msg && msg.type === 'score') {
const b = Number(msg.team2 ?? msg.t) const m = msg as ScoreMsg
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null }) const a = toFiniteOrNull(m.team1 ?? m.ct)
const b = toFiniteOrNull(m.team2 ?? m.t)
setScore({ a, b })
return return
} }
if (msg.score) {
const a = Number(msg.score.team1 ?? msg.score.ct) if ('score' in msg) {
const b = Number(msg.score.team2 ?? msg.score.t) const m = (msg as ScoreWrapMsg).score
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null }) const a = toFiniteOrNull(m?.team1 ?? m?.ct)
const b = toFiniteOrNull(m?.team2 ?? m?.t)
setScore({ a, b })
return return
} }
// Phase ignorieren
} }
} }
@ -230,7 +301,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
// Prop kann Sichtbarkeit erzwingen/abschalten // Prop kann Sichtbarkeit erzwingen/abschalten
const canShowInternal = meIsParticipant && notExpired const canShowInternal = meIsParticipant && notExpired
if (visibleProp === false) return null
const canShow = visibleProp === true ? true : canShowInternal const canShow = visibleProp === true ? true : canShowInternal
// Connected Count + Variant // Connected Count + Variant

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/GameBannerController.tsx
'use client' 'use client'
import {useEffect, useMemo, useRef, useState} from 'react' import {useEffect, useMemo, useRef, useState} from 'react'
@ -16,6 +18,43 @@ type LiveCfg = {
updatedAt?: string updatedAt?: string
} }
type PlayerLike = {
steamId?: string | number | null
steam_id?: string | number | null
id?: string | number | null
}
type PlayersMsg = {
type: 'players'
players: Array<PlayerLike | string | number>
}
type PlayerJoinMsg = {
type: 'player_join'
player: PlayerLike | string | number
}
type PlayerLeaveMsg = {
type: 'player_leave'
steamId?: string | number | null
steam_id?: string | number | null
id?: string | number | null
}
type ScoreMsg = {
type: 'score'
team1?: number
team2?: number
ct?: number
t?: number
}
type ScoreWrapMsg = {
score: { team1?: number; team2?: number; ct?: number; t?: number }
}
type TelemetryMsg = PlayersMsg | PlayerJoinMsg | PlayerLeaveMsg | ScoreMsg | ScoreWrapMsg | Record<string, unknown>
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json()) const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
/* ---------- WS helpers ---------- */ /* ---------- WS helpers ---------- */
@ -31,8 +70,26 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string)
return `${proto}://${h}${portPart}${pa}` return `${proto}://${h}${portPart}${pa}`
} }
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '') const sidOf = (p: PlayerLike | string | number | null | undefined): string => {
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String)) if (p == null) return ''
if (typeof p === 'string' || typeof p === 'number') return String(p)
const raw = p.steamId ?? p.steam_id ?? p.id
return raw == null ? '' : String(raw)
}
const toSet = (arr: Iterable<unknown>) => new Set(Array.from(arr, v => String(v)))
function parseWsData(data: unknown): TelemetryMsg | null {
try {
if (typeof data === 'string') return JSON.parse(data) as TelemetryMsg
if (data && typeof data === 'object') return data as TelemetryMsg
} catch { /* ignore */ }
return null
}
const toFiniteOrNull = (v: unknown): number | null => {
const n = Number(v)
return Number.isFinite(n) ? n : null
}
export default function GameBannerController() { export default function GameBannerController() {
const { data: session } = useSession() const { data: session } = useSession()
@ -153,44 +210,45 @@ export default function GameBannerController() {
} }
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
let msg: any = null const msg = parseWsData(ev.data)
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return if (!msg) return
if (msg.type === 'players' && Array.isArray(msg.players)) { if ('type' in msg && msg.type === 'players' && Array.isArray((msg as PlayersMsg).players)) {
const ids = msg.players.map(sidOf).filter(Boolean) const ids = (msg as PlayersMsg).players.map(sidOf).filter(Boolean)
setTelemetrySet(toSet(ids)) setTelemetrySet(toSet(ids))
return return
} }
if (msg.type === 'player_join' && msg.player) { if ('type' in msg && msg.type === 'player_join' && 'player' in (msg as PlayerJoinMsg)) {
const sid = sidOf(msg.player) const sid = sidOf((msg as PlayerJoinMsg).player)
if (!sid) return if (!sid) return
setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next }) setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next })
return return
} }
if (msg.type === 'player_leave') { if ('type' in msg && msg.type === 'player_leave') {
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '') const m = msg as PlayerLeaveMsg
const sid = sidOf({ steamId: m.steamId, steam_id: m.steam_id, id: m.id })
if (!sid) return if (!sid) return
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next }) setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
return return
} }
if (msg.type === 'score') { if ('type' in msg && msg.type === 'score') {
const a = Number(msg.team1 ?? msg.ct) const m = msg as ScoreMsg
const b = Number(msg.team2 ?? msg.t) const a = toFiniteOrNull(m.team1 ?? m.ct)
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null }) const b = toFiniteOrNull(m.team2 ?? m.t)
return setScore({ a, b })
}
if (msg.score) {
const a = Number(msg.score.team1 ?? msg.score.ct)
const b = Number(msg.score.team2 ?? msg.score.t)
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
return return
} }
// Phase wird absichtlich ignoriert if ('score' in msg) {
const m = (msg as ScoreWrapMsg).score
const a = toFiniteOrNull(m?.team1 ?? m?.ct)
const b = toFiniteOrNull(m?.team2 ?? m?.t)
setScore({ a, b })
return
}
} }
} }

View File

@ -1,4 +1,4 @@
// InvitePlayersModal.tsx // /src/app/[locale]/components/InvitePlayersModal.tsx
'use client' 'use client'
@ -22,6 +22,50 @@ type Props = {
directAdd?: boolean directAdd?: boolean
} }
type UnknownRec = Record<string, unknown>;
type ApiResultItem = { steamId: string; ok: boolean };
function parseResultsFromJson(json: unknown): ApiResultItem[] | null {
if (!isRecord(json)) return null;
if (Array.isArray(json.results)) {
const out: ApiResultItem[] = [];
for (const r of json.results) {
const steamIdVal = isRecord(r) ? r.steamId : undefined;
const okVal = isRecord(r) ? r.ok : undefined;
const steamId =
typeof steamIdVal === 'string' ? steamIdVal
: typeof steamIdVal === 'number' ? String(steamIdVal)
: null;
const ok = typeof okVal === 'boolean' ? okVal : false;
if (steamId) out.push({ steamId, ok });
}
return out;
}
if (Array.isArray(json.invitationIds)) {
const ids: string[] = (json.invitationIds as unknown[]).map(v =>
typeof v === 'string' ? v : String(v)
);
return ids.map(steamId => ({ steamId, ok: true }));
}
return null;
}
function isRecord(v: unknown): v is UnknownRec {
return !!v && typeof v === 'object';
}
function getTeamLeaderSteamId(team: Team | null | undefined): string | null {
if (!team) return null;
const leaderUnknown = (team as unknown as { leader?: unknown }).leader;
if (typeof leaderUnknown === 'string') return leaderUnknown;
if (isRecord(leaderUnknown) && typeof leaderUnknown.steamId === 'string') {
return leaderUnknown.steamId;
}
return null;
}
export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) { export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const steamId = session?.user?.steamId const steamId = session?.user?.steamId
@ -39,7 +83,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const [onlyFree, setOnlyFree] = useState(false) const [onlyFree, setOnlyFree] = useState(false)
// Sanftes UI-Update // Sanftes UI-Update
const [isPending, startTransition] = useTransition() const [, startTransition] = useTransition()
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
@ -100,48 +144,50 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
async function fetchUsers(opts: { resetLayout: boolean }) { async function fetchUsers(opts: { resetLayout: boolean }) {
try { try {
abortRef.current?.abort() abortRef.current?.abort();
const ctrl = new AbortController() const ctrl = new AbortController();
abortRef.current = ctrl abortRef.current = ctrl;
// Höhe einfrieren if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight);
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight)
// Start Fetch setIsFetching(true);
setIsFetching(true)
// 🔽 Spinner NICHT sofort zeigen erst nach kurzer Verzögerung if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current);
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current)
spinnerShowTimer.current = window.setTimeout(() => { spinnerShowTimer.current = window.setTimeout(() => {
setSpinnerVisible(true) setSpinnerVisible(true);
spinnerShownAt.current = Date.now() spinnerShownAt.current = Date.now();
}, SPINNER_DELAY_MS) }, SPINNER_DELAY_MS);
const qs = new URLSearchParams({ teamId: team.id }) const qs = new URLSearchParams({ teamId: team.id });
if (onlyFree) qs.set('onlyFree', 'true') if (onlyFree) qs.set('onlyFree', 'true');
const res = await fetch(`/api/team/available-users?${qs.toString()}`, { const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
signal: ctrl.signal, signal: ctrl.signal,
cache: 'no-store', cache: 'no-store',
}) });
if (!res.ok) throw new Error('load failed') if (!res.ok) throw new Error('load failed');
const dataUnknown: unknown = await res.json();
const users = (isRecord(dataUnknown) && Array.isArray(dataUnknown.users))
? (dataUnknown.users as Player[])
: [];
const data = await res.json()
startTransition(() => { startTransition(() => {
setAllUsers(data.users || []) setAllUsers(users);
setKnownUsers(prev => { setKnownUsers(prev => {
const next = { ...prev } const next = { ...prev };
for (const u of (data.users || [])) next[u.steamId] = u for (const u of users) next[u.steamId] = u;
return next return next;
}) });
if (opts.resetLayout) { if (opts.resetLayout) {
setSelectedIds([]) setSelectedIds([]);
setInvitedIds([]) setInvitedIds([]);
setIsSuccess(false) setIsSuccess(false);
} }
}) });
} catch (e: any) { } catch (e: unknown) {
if (e?.name !== 'AbortError') console.error('Fehler beim Laden der Benutzer:', e) if (isRecord(e) && e.name === 'AbortError') return;
console.error('Fehler beim Laden der Benutzer:', e);
} finally { } finally {
setIsFetching(false) setIsFetching(false)
abortRef.current = null abortRef.current = null
@ -194,67 +240,60 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
} }
const handleInvite = async () => { const handleInvite = async () => {
if (isInviting) return if (isInviting) return;
if (selectedIds.length === 0 || !steamId) return if (selectedIds.length === 0 || !steamId) return;
const ids = [...selectedIds] const ids = [...selectedIds];
try { try {
setIsInviting(true) setIsInviting(true);
const url = directAdd ? '/api/team/add-players' : '/api/team/invite' const url = directAdd ? '/api/team/add-players' : '/api/team/invite';
const body = directAdd const body = directAdd
? { teamId: team.id, steamIds: ids } ? { teamId: team.id, steamIds: ids }
: { teamId: team.id, userIds: ids, invitedBy: steamId } : { teamId: team.id, userIds: ids, invitedBy: steamId };
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) });
let json: any = null let jsonUnknown: unknown = null;
try { json = await res.clone().json() } catch {} try { jsonUnknown = await res.clone().json(); } catch { /* ignore */ }
let results: { steamId: string; ok: boolean }[] = [] let results: ApiResultItem[] | null = parseResultsFromJson(jsonUnknown);
if (directAdd) { // Fallback, falls API keine detailierten results liefert
if (json?.results && Array.isArray(json.results)) { if (!results) {
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok })) results = ids.map(id => ({ steamId: id, ok: res.ok }));
} else {
results = ids.map(id => ({ steamId: id, ok: res.ok }))
}
} else if (json?.results && Array.isArray(json.results)) {
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
} else if (Array.isArray(json?.invitationIds)) {
const okSet = new Set<string>(json.invitationIds)
results = ids.map(id => ({ steamId: id, ok: okSet.has(id) }))
} else {
results = ids.map(id => ({ steamId: id, ok: false }))
} }
const nextStatus: Record<string, InviteStatus> = {} const nextStatus: Record<string, InviteStatus> = {};
let okCount = 0 let okCount = 0;
for (const r of results) { for (const r of results) {
const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed' const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed';
nextStatus[r.steamId] = st nextStatus[r.steamId] = st;
if (r.ok) okCount++ if (r.ok) okCount++;
} }
setInvitedStatus(prev => ({ ...prev, ...nextStatus })) setInvitedStatus(prev => ({ ...prev, ...nextStatus }));
setInvitedIds(ids) setInvitedIds(ids);
setSentCount(okCount) setSentCount(okCount);
setIsSuccess(true) setIsSuccess(true);
setSelectedIds([]) setSelectedIds([]);
if (okCount > 0) onSuccess() if (okCount > 0) onSuccess();
} catch (err) { } catch (err: unknown) {
console.error('Fehler beim Einladen:', err) console.error('Fehler beim Einladen:', err);
setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(selectedIds.map(id => [id, 'failed'])) })) setInvitedStatus(prev => ({
setInvitedIds(selectedIds) ...prev,
setSentCount(0) ...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus])),
setIsSuccess(true) }));
setInvitedIds(selectedIds);
setSentCount(0);
setIsSuccess(true);
} finally { } finally {
setIsInviting(false) setIsInviting(false);
}
} }
};
useEffect(() => { setCurrentPage(1) }, [searchTerm]) useEffect(() => { setCurrentPage(1) }, [searchTerm])
@ -407,7 +446,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
onSelect={handleSelect} onSelect={handleSelect}
draggable={false} draggable={false}
currentUserSteamId={steamId!} currentUserSteamId={steamId!}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader} teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions hideActions
rank={user.premierRank} rank={user.premierRank}
/> />
@ -480,12 +519,13 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
avatar={user.avatar} avatar={user.avatar}
location={user.location} location={user.location}
selected={false} selected={false}
onSelect={handleSelect}
draggable={false} draggable={false}
onSelect={handleSelect}
currentUserSteamId={steamId!} currentUserSteamId={steamId!}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader} teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions hideActions
rank={user.premierRank} rank={user.premierRank}
invitedStatus={invitedStatus[user.steamId]}
/> />
</motion.div> </motion.div>
))} ))}
@ -511,7 +551,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
selected={false} selected={false}
draggable={false} draggable={false}
currentUserSteamId={steamId!} currentUserSteamId={steamId!}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader} teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions hideActions
rank={user.premierRank} rank={user.premierRank}
invitedStatus={invitedStatus[user.steamId]} invitedStatus={invitedStatus[user.steamId]}

View File

@ -19,7 +19,7 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
const steamId = session?.user?.steamId const steamId = session?.user?.steamId
const [newLeaderId, setNewLeaderId] = useState<string>('') const [newLeaderId, setNewLeaderId] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false) const [, setIsSubmitting] = useState(false)
useEffect(() => { useEffect(() => {
if (show && team.leader?.steamId) { if (show && team.leader?.steamId) {

View File

@ -1,4 +1,4 @@
// /src/app/components/MapVoteBanner.tsx // /src/app/[locale]/components/MapVoteBanner.tsx
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
@ -8,12 +8,52 @@ import { useSSEStore } from '@/lib/useSSEStore'
import type { MapVoteState } from '../../../types/mapvote' import type { MapVoteState } from '../../../types/mapvote'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
type TeamLite = { id?: string | null; name?: string | null; leader?: { steamId?: string | null } | null };
type MatchLite = { id: string; bestOf?: number | null; matchDate?: string | null; demoDate?: string | null; teamA?: TeamLite | null; teamB?: TeamLite | null };
type Props = { type Props = {
match: any match: MatchLite;
initialNow: number initialNow: number;
matchBaseTs: number | null matchBaseTs: number | null;
sseOpensAtTs?: number | null sseOpensAtTs?: number | null;
sseLeadMinutes?: number | null sseLeadMinutes?: number | null;
};
type ReloadEventType =
| 'map-vote-updated' | 'map-vote-reset' | 'map-vote-locked' | 'map-vote-unlocked'
| 'match-updated' | 'match-lineup-updated';
type SSEPayload = {
matchId?: string;
leadMinutes?: number;
opensAt?: string | number | Date;
};
type TMapvote = ReturnType<typeof useTranslations<'mapvote'>>;
const RELOAD_TYPES = new Set<ReloadEventType>([
'map-vote-updated','map-vote-reset','map-vote-locked','map-vote-unlocked',
'match-updated','match-lineup-updated',
]);
type UnknownRec = Record<string, unknown>;
function isObject(v: unknown): v is UnknownRec {
return !!v && typeof v === 'object';
}
function unwrapEvent(e: unknown): { type?: string; payload: SSEPayload } {
const type =
isObject(e) && typeof (e as UnknownRec)['type'] === 'string'
? ((e as UnknownRec)['type'] as string)
: undefined;
const rawPayload =
isObject(e) && 'payload' in e ? (e as UnknownRec)['payload'] : e;
const payload = isObject(rawPayload) ? (rawPayload as SSEPayload) : ({} as SSEPayload);
return { type, payload };
} }
function formatCountdown(ms: number) { function formatCountdown(ms: number) {
@ -25,18 +65,28 @@ function formatCountdown(ms: number) {
const pad = (n:number)=>String(n).padStart(2,'0') const pad = (n:number)=>String(n).padStart(2,'0')
return `${h}:${pad(m)}:${pad(s)}` return `${h}:${pad(m)}:${pad(s)}`
} }
function formatLead(minutes: number) {
if (!Number.isFinite(minutes) || minutes <= 0) return 'zum Matchbeginn' function formatLead(minutes: number, tMapvote: TMapvote) {
const h = Math.floor(minutes / 60) if (!Number.isFinite(minutes) || minutes <= 0) return tMapvote('to-match-start');
const m = minutes % 60 const h = Math.floor(minutes / 60);
if (h > 0 && m > 0) return `${h}h ${m}min` const m = minutes % 60;
if (h > 0) return `${h}h` if (h > 0 && m > 0) return `${h}h ${m}min`;
return `${m}min` if (h > 0) return `${h}h`;
return `${m}min`;
}
function isAbortError(err: unknown): boolean {
if (typeof DOMException !== 'undefined' && err instanceof DOMException) {
return err.name === 'AbortError';
}
return isObject(err) && typeof err.name === 'string' && err.name === 'AbortError';
} }
export default function MapVoteBanner({ export default function MapVoteBanner({
match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes, match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes,
}: Props) { }: Props) {
const tMapvote = useTranslations<'mapvote'>('mapvote');
const router = useRouter() const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
@ -52,25 +102,29 @@ export default function MapVoteBanner({
const [now, setNow] = useState(initialNow) const [now, setNow] = useState(initialNow)
useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, []) useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, [])
// Übersetzungen
const tCommon = useTranslations('common')
const load = useCallback(async () => { const load = useCallback(async () => {
const ac = new AbortController();
try { try {
setError(null) setError(null);
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' }) const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store', signal: ac.signal });
if (!r.ok) { if (!r.ok) {
const j = await r.json().catch(() => ({})) let message = 'Laden fehlgeschlagen';
throw new Error(j?.message || 'Laden fehlgeschlagen') const parsed = (await r.json().catch(() => null)) as unknown;
if (isObject(parsed) && typeof parsed.message === 'string') {
message = parsed.message;
} }
const json = await r.json() throw new Error(message);
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
setState(json)
} catch (e: any) {
setState(null)
setError(e?.message ?? 'Unbekannter Fehler')
} }
}, [match.id]) const json: MapVoteState = await r.json();
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)');
setState(json);
} catch (e: unknown) {
if (isAbortError(e)) return;
setState(null);
setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
}
return () => ac.abort();
}, [match.id]);
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load]) useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
@ -78,60 +132,70 @@ export default function MapVoteBanner({
const matchDateTs = useMemo(() => (typeof matchBaseTs === 'number' ? matchBaseTs : null), [matchBaseTs]) const matchDateTs = useMemo(() => (typeof matchBaseTs === 'number' ? matchBaseTs : null), [matchBaseTs])
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return;
const { type } = lastEvent as any const { type, payload } = unwrapEvent(lastEvent);
const evt = (lastEvent as any).payload ?? lastEvent if (payload.matchId !== match.id) return;
if (evt?.matchId !== match.id) return if (!type || !RELOAD_TYPES.has(type as ReloadEventType)) return;
const RELOAD_TYPES = new Set([ const rawLead = payload.leadMinutes;
'map-vote-updated','map-vote-reset','map-vote-locked','map-vote-unlocked', const parsedLead = Number.isFinite(rawLead) ? Number(rawLead) : undefined;
'match-updated','match-lineup-updated', const nextOpensAtISO = payload.opensAt
]) ? (typeof payload.opensAt === 'string'
if (!RELOAD_TYPES.has(type)) return ? payload.opensAt
: new Date(payload.opensAt).toISOString())
const rawLead = evt?.leadMinutes : undefined;
const parsedLead = (rawLead !== undefined && rawLead !== null) ? Number(rawLead) : undefined
const nextOpensAtISO =
evt?.opensAt
? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString())
: undefined
if (nextOpensAtISO) { if (nextOpensAtISO) {
setOpensAtOverride(new Date(nextOpensAtISO).getTime()) setOpensAtOverride(new Date(nextOpensAtISO).getTime());
} else if (Number.isFinite(parsedLead) && matchDateTs != null) { } else if (parsedLead !== undefined && matchDateTs != null) {
setOpensAtOverride(matchDateTs - (parsedLead as number) * 60_000) setOpensAtOverride(matchDateTs - parsedLead * 60_000);
} }
if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number) if (parsedLead !== undefined) setLeadOverride(parsedLead);
if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) { if (nextOpensAtISO !== undefined || parsedLead !== undefined) {
setState(prev => ({ setState(prev => {
...(prev ?? {} as any), if (!prev) return prev; // bleibt null, bis ein kompletter State geladen ist
...(nextOpensAtISO !== undefined ? { opensAt: nextOpensAtISO } : {}), const patch: Partial<MapVoteState> = {};
...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}), if (nextOpensAtISO !== undefined) patch.opensAt = nextOpensAtISO;
}) as any) if (parsedLead !== undefined) patch.leadMinutes = parsedLead;
return { ...prev, ...patch }; // => MapVoteState
});
} else { } else {
load() load();
} }
}, [lastEvent, match.id, matchDateTs, load]) }, [lastEvent, match.id, matchDateTs, load]);
const stateOpensAt = state?.opensAt;
const stateLeadMinutes = state?.leadMinutes;
const opensAt = useMemo(() => { const opensAt = useMemo(() => {
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs if (typeof sseOpensAtTs === 'number') return sseOpensAtTs;
if (opensAtOverride != null) return opensAtOverride if (opensAtOverride != null) return opensAtOverride;
if (state?.opensAt) return new Date(state.opensAt).getTime() if (stateOpensAt) return new Date(stateOpensAt).getTime();
if (matchDateTs == null) return new Date(initialNow).getTime() if (matchDateTs == null) return new Date(initialNow).getTime();
const lead = (typeof sseLeadMinutes === 'number') const lead =
typeof sseLeadMinutes === 'number'
? sseLeadMinutes ? sseLeadMinutes
: (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? (state!.leadMinutes as number) : 60)) : (leadOverride ?? (Number.isFinite(stateLeadMinutes) ? (stateLeadMinutes as number) : 60));
return matchDateTs - lead * 60_000 return matchDateTs - lead * 60_000;
}, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes]) }, [
sseOpensAtTs,
opensAtOverride,
stateOpensAt,
matchDateTs,
initialNow,
sseLeadMinutes,
leadOverride,
stateLeadMinutes,
]);
const leadMinutes = useMemo(() => { const leadMinutes = useMemo(() => {
if (matchDateTs != null && opensAt != null) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000)) if (matchDateTs != null && opensAt != null) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000));
if (typeof sseLeadMinutes === 'number') return sseLeadMinutes if (typeof sseLeadMinutes === 'number') return sseLeadMinutes;
if (leadOverride != null) return leadOverride if (leadOverride != null) return leadOverride;
if (Number.isFinite(state?.leadMinutes)) return state!.leadMinutes as number if (Number.isFinite(stateLeadMinutes)) return stateLeadMinutes as number;
return 60 return 60;
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes]) }, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, stateLeadMinutes]);
const isOpen = mounted && now >= opensAt const isOpen = mounted && now >= opensAt
const msToOpen = Math.max(opensAt - now, 0) const msToOpen = Math.max(opensAt - now, 0)
@ -185,7 +249,7 @@ export default function MapVoteBanner({
onClick={gotoFullPage} onClick={gotoFullPage}
onKeyDown={(e) => e.key === 'Enter' && gotoFullPage()} onKeyDown={(e) => e.key === 'Enter' && gotoFullPage()}
className={`group relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ${ringClass}`} className={`group relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ${ringClass}`}
aria-label="Map-Vote öffnen" aria-label={`Mapvote ${tMapvote("open-small")}`}
> >
{(isVotingOpen || isLocked) && ( {(isVotingOpen || isLocked) && (
<> <>
@ -205,12 +269,12 @@ export default function MapVoteBanner({
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-neutral-100">Map-Vote</div> <div className="font-medium text-gray-900 dark:text-neutral-100">Map-Vote</div>
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate"> <div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
Modus: BO{match.bestOf ?? state?.bestOf ?? 3} {tMapvote("mode")}: BO{match.bestOf ?? state?.bestOf ?? 3}
{isEnded {isEnded
? ' • Auswahl fixiert' ? ' • Auswahl fixiert'
: isLive : isLive
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft') ? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
: ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`} : ` • startet ${formatLead(leadMinutes, tMapvote)} vor Matchbeginn`}
</div> </div>
{error && <div className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</div>} {error && <div className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</div>}
</div> </div>
@ -219,16 +283,16 @@ export default function MapVoteBanner({
<div className="shrink-0"> <div className="shrink-0">
{isEnded ? ( {isEnded ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200"> <span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200">
Voting abgeschlossen {tMapvote("completed")}
</span> </span>
) : isLive ? ( ) : isLive ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-100"> <span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-100">
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'} {iCanAct ? tMapvote("vote-now") : `Mapvote ${tMapvote("open")}`}
</span> </span>
) : ( ) : (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-100 text-neutral-800 dark:bg-neutral-700/40 dark:text-neutral-200" <span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-100 text-neutral-800 dark:bg-neutral-700/40 dark:text-neutral-200"
suppressHydrationWarning> suppressHydrationWarning>
Öffnet in {mounted ? formatCountdown(msToOpen) : '::'} {tMapvote('opens-in')} {mounted ? formatCountdown(msToOpen) : '::'} {formatLead(leadMinutes, tMapvote)}
</span> </span>
)} )}
</div> </div>

View File

@ -1,4 +1,4 @@
// MapVotePanel.tsx // /src/app/[locale]/components/MapVotePanel.tsx
'use client' 'use client'
@ -19,11 +19,47 @@ import { MAP_OPTIONS } from '@/lib/mapOptions'
import { Tabs } from './Tabs' import { Tabs } from './Tabs'
import Chart from './Chart' import Chart from './Chart'
import { MAPVOTE_REFRESH } from '@/lib/sseEvents' import { MAPVOTE_REFRESH } from '@/lib/sseEvents'
import Image from 'next/image'
/* =================== Utilities & constants =================== */ /* =================== Utilities & constants =================== */
type Props = { match: Match } type Props = { match: Match }
// --- Safe type guards (no-any helpers) --------------------------------------
function isRecord(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object';
}
function isString(v: unknown): v is string { return typeof v === 'string'; }
// --- Match-ready typings -----------------------------------------------------
type FirstMap = { label?: string; bg?: string };
type MatchReadyPayload = { matchId: string; firstMap?: FirstMap };
type MatchReadyEvent = { type: 'match-ready'; payload: MatchReadyPayload };
type MapvoteRefreshEvent =
typeof MAPVOTE_REFRESH extends Set<infer U> ? U : never;
function isRefreshEvent(t: unknown): t is MapvoteRefreshEvent {
return typeof t === 'string' && MAPVOTE_REFRESH.has(t as MapvoteRefreshEvent);
}
function isMatchReadyEvent(e: unknown): e is MatchReadyEvent {
if (!isRecord(e)) return false;
if ((e as { type?: unknown }).type !== 'match-ready') return false;
const p = (e as { payload?: unknown }).payload;
return isRecord(p) && isString((p as { matchId?: unknown }).matchId);
}
// Unwrap ohne any
function unwrapEvent(e: unknown): Record<string, unknown> {
if (!isRecord(e)) return {};
const p = (e as Record<string, unknown>).payload;
if (isRecord(p) && isRecord((p as Record<string, unknown>).payload)) return (p as Record<string, unknown>).payload as Record<string, unknown>;
if (isRecord(p)) return p as Record<string, unknown>;
return e as Record<string, unknown>;
}
const HOLD_MS = 1200 const HOLD_MS = 1200
const COMPLETE_THRESHOLD = 1.0 const COMPLETE_THRESHOLD = 1.0
@ -47,6 +83,10 @@ const winrateCache = new Map<string, Record<string, number>>()
type BatchByPlayer = Record<string, Record<string, number>> // steamId -> { mapKey -> pct 0..100 } type BatchByPlayer = Record<string, Record<string, number>> // steamId -> { mapKey -> pct 0..100 }
type WinrateApi = {
byPlayer?: Record<string, { byMap?: Record<string, { pct?: number | string }> }>;
};
/** Liest /api/user/winrate und normiert auf 0..100 (Float), NICHT ×10 */ /** Liest /api/user/winrate und normiert auf 0..100 (Float), NICHT ×10 */
async function fetchWinratesBatch( async function fetchWinratesBatch(
steamIds: string[], steamIds: string[],
@ -56,36 +96,43 @@ async function fetchWinratesBatch(
if (!ids.length) return {}; if (!ids.length) return {};
const q = new URLSearchParams(); const q = new URLSearchParams();
// steamIds als CSV in EINEM Param
q.set('steamIds', ids.join(',')); q.set('steamIds', ids.join(','));
// types als wiederholte Parameter
(opts?.types ?? []).forEach(t => q.append('types', t)); (opts?.types ?? []).forEach(t => q.append('types', t));
if (opts?.onlyActive === false) q.append('onlyActive', 'false'); if (opts?.onlyActive === false) q.append('onlyActive', 'false');
const r = await fetch(`/api/user/winrate?${q.toString()}`, { cache: 'no-store' }); const r = await fetch(`/api/user/winrate?${q.toString()}`, { cache: 'no-store' });
if (!r.ok) return {}; if (!r.ok) return {};
const json = await r.json().catch(() => null); const json = (await r.json().catch(() => null)) as WinrateApi | null;
const out: BatchByPlayer = {};
const byPlayer = json?.byPlayer ?? {}; const byPlayer = json?.byPlayer ?? {};
for (const [steamId, block] of Object.entries<any>(byPlayer)) { const out: BatchByPlayer = {};
for (const [steamId, block] of Object.entries(byPlayer)) {
const maps = block?.byMap ?? {}; const maps = block?.byMap ?? {};
const normalized: Record<string, number> = {}; const normalized: Record<string, number> = {};
for (const [mapKey, agg] of Object.entries<any>(maps)) { for (const [mapKey, agg] of Object.entries(maps)) {
const pctX10 = Number(agg?.pct); const pctX10 = Number(agg?.pct);
if (Number.isFinite(pctX10)) normalized[mapKey] = pctX10 / 10; if (Number.isFinite(pctX10)) normalized[mapKey] = pctX10 / 10;
} }
out[steamId] = normalized; out[steamId] = normalized;
} }
for (const id of Object.keys(out)) winrateCache.set(id, out[id]); Object.keys(out).forEach(id => winrateCache.set(id, out[id]));
return out; return out;
} }
/* =================== Component =================== */ /* =================== Component =================== */
// Ergänze Match um optionale Felder, die hier verwendet werden
type MatchLike = Match & {
players?: MatchPlayer[];
teamA?: { id?: string; name?: string; logo?: string | null; leader?: { steamId?: string | null }; players?: MatchPlayer[] };
teamB?: { id?: string; name?: string; logo?: string | null; leader?: { steamId?: string | null }; players?: MatchPlayer[] };
teamAUsers?: { steamId: string }[];
teamBUsers?: { steamId: string }[];
};
type StepAction = 'ban' | 'pick' | 'decider';
export default function MapVotePanel({ match }: Props) { export default function MapVotePanel({ match }: Props) {
/* -------- External stores / env -------- */ /* -------- External stores / env -------- */
const router = useRouter() const router = useRouter()
@ -100,7 +147,6 @@ export default function MapVotePanel({ match }: Props) {
const [adminEditMode, setAdminEditMode] = useState(false) const [adminEditMode, setAdminEditMode] = useState(false)
const [overlayShownOnce, setOverlayShownOnce] = useState(false) const [overlayShownOnce, setOverlayShownOnce] = useState(false)
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null) const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
const [tab, setTab] = useState<'pool' | 'winrate'>('pool') const [tab, setTab] = useState<'pool' | 'winrate'>('pool')
/* -------- Timers / open window -------- */ /* -------- Timers / open window -------- */
@ -154,18 +200,18 @@ export default function MapVotePanel({ match }: Props) {
}, [overlayOpen, overlayIsForThisMatch]) }, [overlayOpen, overlayIsForThisMatch])
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!isMatchReadyEvent(lastEvent)) return;
if (lastEvent.type !== 'match-ready') return if (lastEvent.payload.matchId !== match.id) return;
if (lastEvent.payload?.matchId !== match.id) return
const fm = lastEvent.payload.firstMap; // FirstMap | undefined
const fm = lastEvent.payload?.firstMap ?? {}
showWithDelay({ showWithDelay({
matchId: match.id, matchId: match.id,
mapLabel: fm?.label ?? 'Erste Map', mapLabel: fm?.label ?? 'Erste Map',
mapBg: fm?.bg ?? '/assets/img/maps/lobby_mapveto_png.webp', mapBg: fm?.bg ?? '/assets/img/maps/lobby_mapveto_png.webp',
nextHref: `/match-details/${match.id}/radar`, nextHref: `/match-details/${match.id}/radar`,
}, 3000) }, 3000);
}, [lastEvent, match.id, showWithDelay]) }, [lastEvent, match.id, showWithDelay]);
/* -------- Data load (initial + SSE refresh) -------- */ /* -------- Data load (initial + SSE refresh) -------- */
const load = useCallback(async () => { const load = useCallback(async () => {
@ -174,15 +220,15 @@ export default function MapVotePanel({ match }: Props) {
try { try {
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' }) const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
if (!r.ok) { if (!r.ok) {
const j = await r.json().catch(() => ({})) const j = await r.json().catch(() => ({} as { message?: string }))
throw new Error(j?.message || 'Laden fehlgeschlagen') throw new Error(j?.message || 'Laden fehlgeschlagen')
} }
const json = await r.json() const json = await r.json()
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)') if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
setState(json) setState(json)
} catch (e: any) { } catch (e: unknown) {
setState(null) setState(null)
setError(e?.message ?? 'Unbekannter Fehler') setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@ -226,9 +272,14 @@ export default function MapVotePanel({ match }: Props) {
) )
const decisionByMap = useMemo(() => { const decisionByMap = useMemo(() => {
const map = new Map<string, { action: 'ban' | 'pick' | 'decider'; teamId: string | null }>() const map = new Map<string, { action: StepAction; teamId: string | null }>()
for (const s of state?.steps ?? []) { for (const s of state?.steps ?? []) {
if (s.map) map.set(s.map, { action: s.action as any, teamId: s.teamId ?? null }) if (s.map) {
const a = s.action
if (a === 'ban' || a === 'pick' || a === 'decider') {
map.set(s.map, { action: a, teamId: s.teamId ?? null })
}
}
} }
return map return map
}, [state?.steps]) }, [state?.steps])
@ -249,24 +300,24 @@ export default function MapVotePanel({ match }: Props) {
body: JSON.stringify({ adminEdit: enabled }), body: JSON.stringify({ adminEdit: enabled }),
}) })
if (!r.ok) { if (!r.ok) {
const j = await r.json().catch(() => ({})) const j = await r.json().catch(() => ({} as { message?: string }))
throw new Error(j?.message || 'Konnte Admin-Edit nicht setzen') throw new Error(j?.message || 'Konnte Admin-Edit nicht setzen')
} }
return r.json() return r.json()
} }
const handlePickOrBan = async (map: string) => { const handlePickOrBan = useCallback(async (map: string) => {
if (!isMyTurn || !currentStep) return if (!isMyTurn || !currentStep) return;
try { try {
const r = await fetch(`/api/matches/${match.id}/mapvote`, { const r = await fetch(`/api/matches/${match.id}/mapvote`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ map }), body: JSON.stringify({ map }),
}) });
if (!r.ok) { if (!r.ok) {
const j = await r.json().catch(() => ({})) const j = await r.json().catch(() => ({} as { message?: string }));
alert(j.message ?? 'Aktion fehlgeschlagen') alert(j.message ?? 'Aktion fehlgeschlagen');
return return;
} }
setState(prev => setState(prev =>
prev prev
@ -277,11 +328,11 @@ export default function MapVotePanel({ match }: Props) {
), ),
} }
: prev : prev
) );
} catch { } catch {
alert('Netzwerkfehler') alert('Netzwerkfehler');
}
} }
}, [isMyTurn, currentStep, match.id, setState]);
/* -------- Press-and-hold logic -------- */ /* -------- Press-and-hold logic -------- */
const rafRef = useRef<number | null>(null) const rafRef = useRef<number | null>(null)
@ -369,17 +420,22 @@ export default function MapVotePanel({ match }: Props) {
}, [state?.steps]) }, [state?.steps])
/* -------- Players & ranks -------- */ /* -------- Players & ranks -------- */
const m = match as MatchLike
// Hilfstyp für optionale Team-Id an MatchPlayer
type MaybeTeam = { team?: { id?: string | null } };
const playersA = useMemo<MatchPlayer[]>(() => { const playersA = useMemo<MatchPlayer[]>(() => {
const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined const teamPlayers = m.teamA?.players
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
const all = (match as any).players as MatchPlayer[] | undefined const all = m.players
const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined const teamAUsers = m.teamAUsers
if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) { if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) {
const setA = new Set(teamAUsers.map(u => u.steamId)) const setA = new Set(teamAUsers.map(u => u.steamId))
return all.filter(p => setA.has(p.user.steamId)) return all.filter(p => setA.has(p.user.steamId))
} }
if (Array.isArray(all) && match.teamA?.id) { if (Array.isArray(all) && m.teamA?.id) {
return all.filter(p => (p as any).team?.id === match.teamA?.id) return all.filter(p => (p as unknown as MaybeTeam).team?.id === m.teamA?.id)
} }
const votePlayers = state?.teams?.teamA?.players as const votePlayers = state?.teams?.teamA?.players as
| Array<{ steamId: string; name?: string | null; avatar?: string | null }> | Array<{ steamId: string; name?: string | null; avatar?: string | null }>
@ -395,19 +451,19 @@ export default function MapVotePanel({ match }: Props) {
})) }))
} }
return [] return []
}, [match, state?.teams?.teamA?.players]) }, [m, state?.teams?.teamA?.players])
const playersB = useMemo<MatchPlayer[]>(() => { const playersB = useMemo<MatchPlayer[]>(() => {
const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined const teamPlayers = m.teamB?.players
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
const all = (match as any).players as MatchPlayer[] | undefined const all = m.players
const teamBUsers = (match as any).teamBUsers as { steamId: string }[] | undefined const teamBUsers = m.teamBUsers
if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) { if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) {
const setB = new Set(teamBUsers.map(u => u.steamId)) const setB = new Set(teamBUsers.map(u => u.steamId))
return all.filter(p => setB.has(p.user.steamId)) return all.filter(p => setB.has(p.user.steamId))
} }
if (Array.isArray(all) && match.teamB?.id) { if (Array.isArray(all) && m.teamB?.id) {
return all.filter(p => (p as any).team?.id === match.teamB?.id) return all.filter(p => (p as unknown as MaybeTeam).team?.id === m.teamB?.id)
} }
const votePlayers = state?.teams?.teamB?.players as const votePlayers = state?.teams?.teamB?.players as
| Array<{ steamId: string; name?: string | null; avatar?: string | null }> | Array<{ steamId: string; name?: string | null; avatar?: string | null }>
@ -423,14 +479,14 @@ export default function MapVotePanel({ match }: Props) {
})) }))
} }
return [] return []
}, [match, state?.teams?.teamB?.players]) }, [m, state?.teams?.teamB?.players])
const teamAPlayersForRank = useMemo( const teamAPlayersForRank = useMemo(
() => playersA.map(p => ({ premierRank: p.user.premierRank ?? 0 })) as any, () => playersA.map(p => ({ premierRank: p.user.premierRank ?? 0 })),
[playersA] [playersA]
) )
const teamBPlayersForRank = useMemo( const teamBPlayersForRank = useMemo(
() => playersB.map(p => ({ premierRank: p.user.premierRank ?? 0 })) as any, () => playersB.map(p => ({ premierRank: p.user.premierRank ?? 0 })),
[playersB] [playersB]
) )
@ -490,10 +546,6 @@ export default function MapVotePanel({ match }: Props) {
[state, playersA, playersB, setState] [state, playersA, playersB, setState]
); );
const amInTeamA = !!mySteamId && playersA.some(p => p.user?.steamId === mySteamId)
const amInTeamB = !!mySteamId && playersB.some(p => p.user?.steamId === mySteamId)
const myTeamId = amInTeamA ? match.teamA?.id : amInTeamB ? match.teamB?.id : null
// Links/Rechts anhand des eigenen Teams // Links/Rechts anhand des eigenen Teams
let teamLeftKey: 'teamA' | 'teamB' = 'teamA' let teamLeftKey: 'teamA' | 'teamB' = 'teamA'
let teamRightKey: 'teamA' | 'teamB' = 'teamB' let teamRightKey: 'teamA' | 'teamB' = 'teamB'
@ -502,22 +554,15 @@ export default function MapVotePanel({ match }: Props) {
teamRightKey = 'teamA' teamRightKey = 'teamA'
} }
// Farben an Radar anlehnen const teamLeft = teamLeftKey === 'teamA' ? m.teamA : m.teamB
const LEFT_RING = 'ring-2 ring-green-500/70 shadow-[0_10px_30px_rgba(34,197,94,0.25)]'; const teamRight = teamRightKey === 'teamA' ? m.teamA : m.teamB
const RIGHT_RING = 'ring-2 ring-red-500/70 shadow-[0_10px_30px_rgba(239,68,68,0.25)]';
const BASE_PANEL = 'relative rounded-lg p-2 transition-all duration-300 ease-out';
const INACTIVE_FADE = 'opacity-75 grayscale-[5%]';
const teamLeft = (match as any)[teamLeftKey]
const teamRight = (match as any)[teamRightKey]
const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB
const playersRight = teamRightKey === 'teamA' ? playersA : playersB const playersRight = teamRightKey === 'teamA' ? playersA : playersB
const rankLeft = teamLeftKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank const rankLeft = teamLeftKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank
const rankRight = teamRightKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank const rankRight = teamRightKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank
const leftTeamId = state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id const leftTeamId = state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id ?? null
const rightTeamId = state?.teams?.[teamRightKey]?.id ?? teamRight?.id const rightTeamId = state?.teams?.[teamRightKey]?.id ?? teamRight?.id ?? null
const leftIsActiveTurn = const leftIsActiveTurn =
!!currentStep?.teamId && !!currentStep?.teamId &&
@ -568,28 +613,15 @@ export default function MapVotePanel({ match }: Props) {
// 4) exakt diese Liste fürs Radar verwenden // 4) exakt diese Liste fürs Radar verwenden
const activeMapKeys = sortedMapPool; const activeMapKeys = sortedMapPool;
const activeMapLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf]);
// Helper: Durchschnitt (nur finite Werte) // Labels nur aus keys ableiten → stabil solange keys gleich bleiben
function avg(values: number[]) { const radarLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf])
const valid = values.filter(v => Number.isFinite(v))
if (!valid.length) return 0
return valid.reduce((a, b) => a + b, 0) / valid.length
}
// 2) State für Radar-Daten je Team + Team-Ø // 2) State für Radar-Daten je Team
const [teamRadarLeft, setTeamRadarLeft] = useState<number[]>(activeMapKeys.map(() => 0)) const [teamRadarLeft, setTeamRadarLeft] = useState<number[]>(activeMapKeys.map(() => 0))
const [teamRadarRight, setTeamRadarRight] = useState<number[]>(activeMapKeys.map(() => 0)) const [teamRadarRight, setTeamRadarRight] = useState<number[]>(activeMapKeys.map(() => 0))
const lastFetchSigRef = useRef<string>(''); const lastFetchSigRef = useRef<string>('');
// Hilfs-Memos: eindeutige Id-Liste + Schlüssel
const allSteamIds = useMemo(() => {
const ids = new Set<string>();
playersLeft.forEach(p => ids.add(p.user.steamId));
playersRight.forEach(p => ids.add(p.user.steamId));
return Array.from(ids);
}, [playersLeft, playersRight]);
// stabile Id-Menge über beide Teams (reihenfolgeunabhängig) // stabile Id-Menge über beide Teams (reihenfolgeunabhängig)
const idsKey = useMemo(() => { const idsKey = useMemo(() => {
const s = new Set<string>(); const s = new Set<string>();
@ -604,12 +636,6 @@ export default function MapVotePanel({ match }: Props) {
[activeMapKeys] [activeMapKeys]
) )
// Labels nur aus keys ableiten → stabil solange mapsKey gleich bleibt
const radarLabels = useMemo(() => {
// nur MAP_OPTIONS / state.mapVisuals lesen, Ergebnis aber von mapsKey abhängig
return activeMapKeys.map(labelOf)
}, [mapsKey, labelOf, activeMapKeys])
// Datasets-Array: nur neu, wenn sich Werte oder Namen ändern // Datasets-Array: nur neu, wenn sich Werte oder Namen ändern
const radarDatasets = useMemo(() => ([ const radarDatasets = useMemo(() => ([
{ {
@ -631,98 +657,63 @@ export default function MapVotePanel({ match }: Props) {
// Icons ebenfalls stabilisieren // Icons ebenfalls stabilisieren
const radarIcons = useMemo( const radarIcons = useMemo(
() => activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`), () => activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`),
[mapsKey] // reicht, solange keys unverändert [activeMapKeys]
) )
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return;
const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e const evt = unwrapEvent(lastEvent);
const evt = unwrap(lastEvent) const type = (isRecord(lastEvent) && isString((lastEvent as Record<string, unknown>).type))
const type = lastEvent.type ?? evt?.type ? (lastEvent as Record<'type', string>).type
: (isString((evt as Record<string, unknown>).type) ? (evt as Record<'type', string>).type : undefined);
const evtMatchId: string | null = evt?.matchId ?? null const evtMatchId = isString((evt as Record<string, unknown>).matchId) ? (evt as Record<'matchId', string>).matchId : null;
const evtTeamId : string | null = evt?.teamId ?? null const evtTeamId = isString((evt as Record<string, unknown>).teamId) ? (evt as Record<'teamId', string>).teamId : null;
const actionType: string | null = evt?.actionType ?? null const actionType = isString((evt as Record<string, unknown>).actionType) ? (evt as Record<'actionType', string>).actionType : null;
const actionData: string | null = evt?.actionData ?? null // z.B. newLeaderSteamId const actionData = isString((evt as Record<string, unknown>).actionData) ? (evt as Record<'actionData', string>).actionData : null;
// 1) Relevanz wie bisher const isForThisMatchByMatchId = !!evtMatchId && evtMatchId === match.id;
const isForThisMatchByMatchId = const isForThisMatchByTeamId = !!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id);
!!evtMatchId && evtMatchId === match.id
const isForThisMatchByTeamId =
!!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id)
// 2) Notifications abdecken (changed + self)
const isLeaderChangeNotification = const isLeaderChangeNotification =
type === 'notification' && (actionType === 'team-leader-changed' || actionType === 'team-leader-self') type === 'notification' && (actionType === 'team-leader-changed' || actionType === 'team-leader-self');
// 3) Gehört der neue Leader zu unseren Teams? const byNewLeaderId = isLeaderChangeNotification && !!actionData && teamSteamIds.has(actionData);
const byNewLeaderId = const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId;
isLeaderChangeNotification && !!actionData && teamSteamIds.has(actionData)
// 4) Relevanz
const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId
// 5) Offensichtliche Leader-Änderungen -> hart refreshen, auch ohne Relevanzbeweis
const forceRefresh = const forceRefresh =
type === 'team-leader-changed' || type === 'team-leader-changed' || type === 'team-updated' || isLeaderChangeNotification;
type === 'team-updated' ||
isLeaderChangeNotification
if (!isRelevant && !forceRefresh) return if (!isRelevant && !forceRefresh) return;
// map-vote-updated: opensAt-Override wie gehabt ...
if (type === 'map-vote-updated') {
const { opensAt, leadMinutes } = evt ?? {}
if (opensAt) {
const ts = new Date(opensAt).getTime()
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts)
setState(prev => (prev ? { ...prev, opensAt } : prev))
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) {
const ts = matchBaseTs - Number(leadMinutes) * 60_000
setOpensAtOverrideTs(ts)
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev))
}
}
// --- Leader-Events zuerst lokal anwenden (ohne Reload) ----------------------
const isLeaderChangeEvent =
type === 'team-leader-changed' ||
(type === 'notification' &&
(actionType === 'team-leader-changed' || actionType === 'team-leader-self'));
if (isLeaderChangeEvent) {
applyLeaderChange(evtTeamId ?? null, actionData ?? null);
return; // kein load()
}
// --- map-vote-/sonstige Events wie gehabt -----------------------------------
let shouldRefresh = MAPVOTE_REFRESH.has(type);
// Optional: harte Reloads weiter einschränken, falls gewünscht
// (z.B. kein Reload für "team-updated", wenn dich nur Leader interessiert)
// if (type === 'team-updated') shouldRefresh = false;
if (type === 'map-vote-updated') { if (type === 'map-vote-updated') {
const { opensAt, leadMinutes } = evt ?? {}; const opensAt = isString((evt as Record<string, unknown>).opensAt) ? (evt as Record<'opensAt', string>).opensAt : undefined;
const leadMinRaw = typeof (evt as Record<string, unknown>).leadMinutes === 'number' ? (evt as Record<'leadMinutes', number>).leadMinutes : undefined;
if (opensAt) { if (opensAt) {
const ts = new Date(opensAt).getTime(); const ts = new Date(opensAt).getTime();
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts); if (Number.isFinite(ts)) setOpensAtOverrideTs(ts);
setState(prev => (prev ? { ...prev, opensAt } : prev)); setState(prev => (prev ? { ...prev, opensAt } : prev));
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) { } else if (Number.isFinite(leadMinRaw ?? NaN) && matchBaseTs != null) {
const ts = matchBaseTs - Number(leadMinutes) * 60_000; const ts = matchBaseTs - (leadMinRaw as number) * 60_000;
setOpensAtOverrideTs(ts); setOpensAtOverrideTs(ts);
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev)); setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev));
} }
} }
const isLeaderChangeEvent =
type === 'team-leader-changed' || isLeaderChangeNotification;
if (isLeaderChangeEvent) {
applyLeaderChange(evtTeamId ?? null, actionData ?? null);
return;
}
const shouldRefresh = isRefreshEvent(type);
if (shouldRefresh) { if (shouldRefresh) {
load(); load();
} }
}, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, load, matchBaseTs, teamSteamIds]) }, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, load, matchBaseTs, teamSteamIds, applyLeaderChange]);
// Effect NUR an stabile Keys + Tab hängen // Effect NUR an stabile Keys + Tab hängen
useEffect(() => { useEffect(() => {
@ -766,7 +757,7 @@ export default function MapVotePanel({ match }: Props) {
})(); })();
return () => { aborted = true; }; return () => { aborted = true; };
}, [tab, idsKey, mapsKey]); }, [tab, idsKey, mapsKey, activeMapKeys, playersLeft, playersRight]);
/* =================== Render =================== */ /* =================== Render =================== */
@ -805,9 +796,9 @@ export default function MapVotePanel({ match }: Props) {
try { try {
await postAdminEdit(next) await postAdminEdit(next)
await load() await load()
} catch (e: any) { } catch (e: unknown) {
setAdminEditMode(v => !v) setAdminEditMode(v => !v)
alert(e?.message ?? 'Fehler beim Umschalten des Admin-Edits') alert(e instanceof Error ? e.message : 'Fehler beim Umschalten des Admin-Edits')
} }
}} }}
> >
@ -825,7 +816,7 @@ export default function MapVotePanel({ match }: Props) {
try { try {
const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' }) const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' })
if (!r.ok) { if (!r.ok) {
const j = await r.json().catch(() => ({})) const j = await r.json().catch(() => ({} as { message?: string }))
alert(j.message ?? 'Reset fehlgeschlagen') alert(j.message ?? 'Reset fehlgeschlagen')
return return
} }
@ -866,7 +857,9 @@ export default function MapVotePanel({ match }: Props) {
🔒 Admin-Edit aktiv Voting pausiert 🔒 Admin-Edit aktiv Voting pausiert
{(() => { {(() => {
const all: Array<{ steamId: string; name?: string | null }> = [] const all: Array<{ steamId: string; name?: string | null }> = []
const pushMaybe = (x: any) => { if (x?.steamId) all.push({ steamId: x.steamId, name: x.name }) } const pushMaybe = (x: { steamId?: string | null; name?: string | null } | null | undefined) => {
if (x?.steamId) all.push({ steamId: x.steamId, name: x.name })
}
pushMaybe(state?.teams?.teamA?.leader) pushMaybe(state?.teams?.teamA?.leader)
pushMaybe(state?.teams?.teamB?.leader) pushMaybe(state?.teams?.teamB?.leader)
;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe) ;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe)
@ -967,7 +960,7 @@ export default function MapVotePanel({ match }: Props) {
].join(' ')} ].join(' ')}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img <Image
src={getTeamLogo(teamLeft?.logo)} src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'} alt={teamLeft?.name ?? 'Team'}
className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain"
@ -991,7 +984,6 @@ export default function MapVotePanel({ match }: Props) {
matchType={match.matchType} matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)} onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId} isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId}
isActiveTurn={leftIsActiveTurn}
/> />
))} ))}
</div> </div>
@ -1027,17 +1019,6 @@ export default function MapVotePanel({ match }: Props) {
const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}` const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
// DECIDER-Chooser (letztes Ban davor)
const steps = state?.steps ?? []
const decIdx = steps.findIndex(s => s.action === 'decider')
let deciderChooserTeamId: string | null = null
if (decIdx >= 0) {
for (let i = decIdx - 1; i >= 0; i--) {
const s = steps[i]
if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break }
}
}
const effectiveTeamId = const effectiveTeamId =
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
@ -1056,7 +1037,7 @@ export default function MapVotePanel({ match }: Props) {
return ( return (
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2"> <li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
{pickedByLeft ? ( {pickedByLeft ? (
<img <Image
src={getTeamLogo(teamLeft?.logo)} src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'} alt={teamLeft?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
@ -1076,9 +1057,9 @@ export default function MapVotePanel({ match }: Props) {
onMouseDown={() => onHoldStart(map, isAvailable)} onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)} onMouseUp={() => cancelOrSubmitIfComplete(map)}
onMouseLeave={() => cancelOrSubmitIfComplete(map)} onMouseLeave={() => cancelOrSubmitIfComplete(map)}
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }} onTouchStart={onTouchStart(map, isAvailable)}
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }} onTouchEnd={onTouchEnd(map)}
onTouchCancel={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }} onTouchCancel={onTouchEnd(map)}
> >
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} /> <div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
{showProgress && ( {showProgress && (
@ -1117,7 +1098,7 @@ export default function MapVotePanel({ match }: Props) {
</Button> </Button>
{pickedByRight ? ( {pickedByRight ? (
<img <Image
src={getTeamLogo(teamRight?.logo)} src={getTeamLogo(teamRight?.logo)}
alt={teamRight?.name ?? 'Team'} alt={teamRight?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
@ -1166,7 +1147,7 @@ export default function MapVotePanel({ match }: Props) {
<div className="min-w-0 text-right"> <div className="min-w-0 text-right">
<div className="font-bold text-lg truncate">{teamRight?.name ?? 'Team'}</div> <div className="font-bold text-lg truncate">{teamRight?.name ?? 'Team'}</div>
</div> </div>
<img <Image
src={getTeamLogo(teamRight?.logo)} src={getTeamLogo(teamRight?.logo)}
alt={teamRight?.name ?? 'Team'} alt={teamRight?.name ?? 'Team'}
className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain"
@ -1186,7 +1167,6 @@ export default function MapVotePanel({ match }: Props) {
matchType={match.matchType} matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)} onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId} isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId}
isActiveTurn={rightIsActiveTurn}
/> />
))} ))}
</div> </div>
@ -1279,7 +1259,7 @@ export default function MapVotePanel({ match }: Props) {
> >
{/* Hintergrundbild */} {/* Hintergrundbild */}
{bg && ( {bg && (
<img <Image
src={bg} src={bg}
alt={label} alt={label}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
@ -1289,7 +1269,7 @@ export default function MapVotePanel({ match }: Props) {
{/* Team-Logo in der Ecke (Picker) */} {/* Team-Logo in der Ecke (Picker) */}
{cornerLogo && ( {cornerLogo && (
<img <Image
src={cornerLogo} src={cornerLogo}
alt="Picker-Team" alt="Picker-Team"
className={`absolute ${cornerPos} w-6 h-6 rounded-full object-contain bg-white/90 border border-white/70 shadow-sm`} className={`absolute ${cornerPos} w-6 h-6 rounded-full object-contain bg-white/90 border border-white/70 shadow-sm`}
@ -1319,7 +1299,7 @@ export default function MapVotePanel({ match }: Props) {
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 z-20"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 z-20">
{mapLogo ? ( {mapLogo ? (
<> <>
<img <Image
src={mapLogo} src={mapLogo}
alt={label} alt={label}
className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg" className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg"

View File

@ -14,7 +14,6 @@ type Props = {
rank?: number rank?: number
matchType?: 'premier' | 'competitive' | string matchType?: 'premier' | 'competitive' | string
isLeader?: boolean isLeader?: boolean
isActiveTurn?: boolean
onClick?: () => void onClick?: () => void
} }
@ -25,7 +24,6 @@ export default function MapVoteProfileCard({
avatar, avatar,
rank = 0, rank = 0,
isLeader = false, isLeader = false,
isActiveTurn = false,
onClick, onClick,
}: Props) { }: Props) {
const isRight = side === 'B' const isRight = side === 'B'

View File

@ -5,8 +5,6 @@
import { useState, useEffect, useMemo, useRef } from 'react' import { useState, useEffect, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Table from './Table' import Table from './Table'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
import CompRankBadge from './CompRankBadge' import CompRankBadge from './CompRankBadge'
@ -34,17 +32,6 @@ const KPR_CAP = 1.2
const APR_CAP = 0.6 const APR_CAP = 0.6
const clamp01 = (v: number) => Math.max(0, Math.min(1, v)) const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
type ApiStats = {
stats: Array<{
date: string
kills: number
deaths: number
assists?: number | null
totalDamage?: number | null
rounds?: number | null
}>
}
type PrefetchedFaceit = { type PrefetchedFaceit = {
level: number|null level: number|null
elo: number|null elo: number|null
@ -214,21 +201,22 @@ function hasVacBan(p: MatchPlayer): boolean {
return !!(b?.vacBanned || (b?.numberOfVACBans ?? 0) > 0) return !!(b?.vacBanned || (b?.numberOfVACBans ?? 0) > 0)
} }
type VoteAction = 'BAN' | 'PICK' | 'DECIDER'
type VoteStep = { order: number; action: VoteAction; map?: string | null }
// 1) Normalisieren: String → "de_mirage" // 1) Normalisieren: String → "de_mirage"
const norm = (m?: unknown): string => { const norm = (m?: unknown): string => {
if (!m) return '' if (!m) return ''
if (typeof m === 'string') return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '') if (typeof m === 'string') {
if (typeof m === 'object') { return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
}
if (typeof m === 'object' && m) {
const o = m as Record<string, unknown> const o = m as Record<string, unknown>
// häufige Felder, die den Mapkey tragen: const candidates: unknown[] = [
const cand = o['key'], o['map'], o['name'], o['id'], o['value'], o['slug']
o.key ?? o.map ?? o.name ?? o.id ?? (o as any)?.value ?? (o as any)?.slug ]
return typeof cand === 'string' for (const c of candidates) {
? cand.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '') if (typeof c === 'string') {
: '' return c.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
}
}
} }
return '' return ''
} }
@ -247,26 +235,32 @@ const mapLabelFromKey = (key?: string) => {
function extractSeriesMaps(match: Match, bestOf: number): string[] { function extractSeriesMaps(match: Match, bestOf: number): string[] {
const n = Math.max(1, bestOf) const n = Math.max(1, bestOf)
// a) klassische Steps (nur PICK/DECIDER) // a) Steps (nur PICK/DECIDER)
const fromSteps: unknown[] = const stepsRaw = Array.isArray(match.mapVote?.steps) ? match.mapVote!.steps as unknown[] : []
(match.mapVote?.steps ?? []) type StepRec = { action?: unknown; order?: unknown; map?: unknown }
.filter((s: any) => s && (s.action === 'PICK' || s.action === 'DECIDER')) const steps = stepsRaw
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0)) .map(s => (typeof s === 'object' && s ? s as StepRec : {}))
.map((s: any) => s.map) .filter(s => s.action === 'PICK' || s.action === 'DECIDER')
.sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0))
.map(s => s.map)
// b) häufige Ergebnis-Felder // b) Ergebnis-Felder
const mv: any = match.mapVote ?? {} const mv = match.mapVote as unknown as Record<string, unknown> | undefined
const candidates: unknown[] = [ const mvResult = (mv?.['result'] && typeof mv['result'] === 'object') ? mv['result'] as Record<string, unknown> : undefined
mv.result?.maps, const mvFinal = (mv?.['final'] && typeof mv['final'] === 'object') ? mv['final'] as Record<string, unknown> : undefined
mv.result?.picks, // [{map: '...'}] / [{key:'...'}]
mv.result?.series, // ['de_mirage', ...] oder [{key:...}] const getArr = (v: unknown): unknown[] => Array.isArray(v) ? v : []
mv.final?.maps, const picks = getArr(mvResult?.['picks']).map(x => (typeof x === 'object' && x ? (x as Record<string, unknown>)['map'] ?? (x as Record<string, unknown>)['key'] : x))
(match as any)?.series?.maps, const series = getArr(mvResult?.['series'])
(match as any)?.maps, const resMaps = getArr(mvResult?.['maps'])
].flat().filter(Boolean) const finMaps = getArr(mvFinal?.['maps'])
const more: unknown[] = [
...resMaps, ...picks, ...series, ...finMaps
]
// c) flach ziehen + normalisieren + entduplizieren (stabile Reihenfolge) // c) flach ziehen + normalisieren + entduplizieren (stabile Reihenfolge)
const chain = [...fromSteps, ...candidates] const chain = [...steps, ...more]
.map(norm) .map(norm)
.filter(Boolean) .filter(Boolean)
@ -388,14 +382,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const [playerFaceits, setPlayerFaceits] = useState<Record<string, PrefetchedFaceit | null>>({}) const [playerFaceits, setPlayerFaceits] = useState<Record<string, PrefetchedFaceit | null>>({})
// ⬇️ bestOf nur im State halten: community → 3 (oder was du magst), sonst 1 // ⬇️ bestOf nur im State halten: community → 3 (oder was du magst), sonst 1
const [bestOf, setBestOf] = useState<1 | 3 | 5>(() => const [bestOf] = useState<1 | 3 | 5>(() =>
match.matchType === 'community' ? 3 : 1 match.matchType === 'community' ? 3 : 1
) )
// Alle Maps der Serie (BO3/BO5) abhängig von bestOf-State // Alle Maps der Serie (BO3/BO5) abhängig von bestOf-State
const allMaps = useMemo( const allMaps = useMemo(
() => extractSeriesMaps(match, bestOf), () => extractSeriesMaps(match, bestOf),
[match.mapVote?.steps, (match as any)?.mapVote?.result?.maps, match.map, bestOf] [match, bestOf]
) )
const [activeMapIdx, setActiveMapIdx] = useState(0) const [activeMapIdx, setActiveMapIdx] = useState(0)
@ -427,6 +421,15 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? [] const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? [] const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null
const getString = (v: unknown): string | undefined =>
typeof v === 'string' ? v : undefined
const getNumber = (v: unknown): number | undefined =>
typeof v === 'number' ? v : undefined
// → Welche Seite ist "mein Team"? // → Welche Seite ist "mein Team"?
const mySteamId = session?.user?.steamId const mySteamId = session?.user?.steamId
const mySide: 'A' | 'B' | null = mySteamId const mySide: 'A' | 'B' | null = mySteamId
@ -472,8 +475,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
})() })()
return () => { cancelled = true } return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [match.teamA?.players, match.teamB?.players, playerFaceits, playerSummaries])
}, [match.teamA?.players, match.teamB?.players])
// beim mount user-tz aus DB laden // beim mount user-tz aus DB laden
useEffect(() => { useEffect(() => {
@ -487,7 +489,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const sp = new URLSearchParams(window.location.search) const sp = new URLSearchParams(window.location.search)
const m = Number(sp.get('m')) const m = Number(sp.get('m'))
if (Number.isFinite(m) && m >= 0 && m < allMaps.length) setActiveMapIdx(m) if (Number.isFinite(m) && m >= 0 && m < allMaps.length) setActiveMapIdx(m)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allMaps.length]) }, [allMaps.length])
const setActive = (idx: number) => { const setActive = (idx: number) => {
@ -519,7 +520,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const fromVote = extractSeriesMaps(match, bestOf) const fromVote = extractSeriesMaps(match, bestOf)
const n = Math.max(1, bestOf) const n = Math.max(1, bestOf)
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({length: n - fromVote.length}, () => '')] return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({length: n - fromVote.length}, () => '')]
}, [bestOf, match.mapVote?.steps?.length]) }, [match, bestOf])
// Ticker für Mapvote-Zeitfenster // Ticker für Mapvote-Zeitfenster
useEffect(() => { useEffect(() => {
@ -541,7 +542,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
? leadOverride ? leadOverride
: (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60) : (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
return matchBaseTs - lead * 60_000 return matchBaseTs - lead * 60_000
}, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride]) }, [opensAtOverride, match.mapVote, matchBaseTs, leadOverride])
const sseOpensAtTs = voteOpensAtTs const sseOpensAtTs = voteOpensAtTs
const sseLeadMinutes = leadOverride const sseLeadMinutes = leadOverride
@ -554,26 +555,41 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
// SSE-Listener (nur relevante Events) // SSE-Listener (nur relevante Events)
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
const outer = lastEvent as any
const maybeInner = outer?.payload
const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner)
? maybeInner
: outer
const type = base?.type const outer = lastEvent as unknown
const evt = base?.payload ?? base
if (!evt?.matchId || evt.matchId !== match.id) return
const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}` // evtl. „doppelt“ verschachteltes Event-Format abrollen
const maybeInner = isRecord(outer) && 'payload' in outer ? (outer as Record<string, unknown>).payload : undefined
const base = isRecord(maybeInner) && 'type' in maybeInner && 'payload' in maybeInner
? (maybeInner as Record<string, unknown>)
: (isRecord(outer) ? (outer as Record<string, unknown>) : {})
// Typ & Payload sicher lesen
const type = getString(base.type)
const payloadRaw = 'payload' in base ? (base as Record<string, unknown>).payload : base
const evt = isRecord(payloadRaw) ? payloadRaw : {}
const evtMatchId = getString(evt.matchId)
if (!evtMatchId || evtMatchId !== match.id) return
const opensAtRaw = evt.opensAt
const leadMinutesRaw = evt.leadMinutes
const key = `${type ?? ''}|${evtMatchId}|${opensAtRaw ?? ''}|${
Number.isFinite(leadMinutesRaw as number) ? leadMinutesRaw : ''
}`
if (key === lastHandledKeyRef.current) return if (key === lastHandledKeyRef.current) return
lastHandledKeyRef.current = key lastHandledKeyRef.current = key
if (type === 'map-vote-updated') { if (type === 'map-vote-updated') {
if (evt?.opensAt) setOpensAtOverride(new Date(evt.opensAt).getTime()) const opensAt = opensAtRaw
if (Number.isFinite(evt?.leadMinutes)) { if (opensAt) setOpensAtOverride(new Date(opensAt as string | number | Date).getTime())
const lead = Number(evt.leadMinutes)
const leadNum = getNumber(leadMinutesRaw)
if (Number.isFinite(leadNum)) {
const lead = leadNum as number
setLeadOverride(lead) setLeadOverride(lead)
if (!evt?.opensAt) { if (!opensAt) {
const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
setOpensAtOverride(baseTs - lead * 60_000) setOpensAtOverride(baseTs - lead * 60_000)
} }
@ -583,7 +599,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
} }
const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated']) const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) router.refresh() if (type && REFRESH_TYPES.has(type)) router.refresh()
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow]) }, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
useEffect(() => { useEffect(() => {
@ -600,7 +616,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const url = `${window.location.pathname}?${sp.toString()}${window.location.hash}` const url = `${window.location.pathname}?${sp.toString()}${window.location.hash}`
window.history.replaceState(null, '', url) window.history.replaceState(null, '', url)
} }
}, [currentMapKey, allMaps, bestOf, isPickBanPhase]) // ← Dependencies }, [currentMapKey, allMaps, bestOf, isPickBanPhase, activeMapIdx])
// Löschen // Löschen
const handleDelete = async () => { const handleDelete = async () => {
@ -896,7 +912,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isCommunity && match.teamA?.logo && ( {isCommunity && match.teamA?.logo && (
<img <Image
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`} src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamA.name ?? 'Team A'} alt={match.teamA.name ?? 'Team A'}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12" className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
@ -936,7 +952,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
</div> </div>
</div> </div>
{isCommunity && match.teamB?.logo && ( {isCommunity && match.teamB?.logo && (
<img <Image
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`} src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamB.name ?? 'Team B'} alt={match.teamB.name ?? 'Team B'}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12" className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
@ -1037,7 +1053,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<MiniPlayerCard <MiniPlayerCard
open={!!hoverPlayer} open={!!hoverPlayer}
player={hoverPlayer} player={hoverPlayer}
anchor={null}
onClose={() => { onClose={() => {
// Nicht schließen, wenn entweder Anchor ODER die Card selbst gerade gehovert ist // Nicht schließen, wenn entweder Anchor ODER die Card selbst gerade gehovert ist
const anchorHovered = !!anchorEl?.matches(':hover') const anchorHovered = !!anchorEl?.matches(':hover')

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/MatchPlayerCard.tsx
import Table from './Table' import Table from './Table'
import Image from 'next/image' import Image from 'next/image'
import { MatchPlayer } from '../../../types/match' import { MatchPlayer } from '../../../types/match'
@ -6,7 +8,32 @@ type Props = {
player: MatchPlayer player: MatchPlayer
} }
/** Safeties für optionale/lockere Stats-Felder */
type StatsShape = {
adr?: unknown
hsPercent?: unknown
}
function getAdr(stats: unknown): number | null {
if (stats && typeof stats === 'object') {
const v = (stats as StatsShape).adr
return typeof v === 'number' ? v : null
}
return null
}
function getHsPercent(stats: unknown): number | null {
if (stats && typeof stats === 'object') {
const v = (stats as StatsShape).hsPercent
return typeof v === 'number' ? v : null
}
return null
}
export default function MatchPlayerCard({ player }: Props) { export default function MatchPlayerCard({ player }: Props) {
const adr = getAdr(player.stats)
const hsPercent = getHsPercent(player.stats)
return ( return (
<Table.Row> <Table.Row>
<Table.Cell hoverable className="w-[48px] p-1 text-center align-middle whitespace-nowrap"> <Table.Cell hoverable className="w-[48px] p-1 text-center align-middle whitespace-nowrap">
@ -26,8 +53,9 @@ export default function MatchPlayerCard({ player }: Props) {
<Table.Cell hoverable>{player.stats?.kills ?? '-'}</Table.Cell> <Table.Cell hoverable>{player.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell hoverable>{player.stats?.deaths ?? '-'}</Table.Cell> <Table.Cell hoverable>{player.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell hoverable>{player.stats?.assists ?? '-'}</Table.Cell> <Table.Cell hoverable>{player.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell hoverable>{(player.stats as any)?.adr ?? '-'}</Table.Cell> <Table.Cell hoverable>{adr ?? '-'}</Table.Cell>
<Table.Cell hoverable>{(player.stats as any)?.adr ?? '-'}</Table.Cell> {/* Falls du kein HS% hast, kannst du diese Spalte auch entfernen oder nochmals ADR zeigen */}
<Table.Cell hoverable>{hsPercent ?? '-'}</Table.Cell>
<Table.Cell className="text-end"> <Table.Cell className="text-end">
<button <button

View File

@ -1,4 +1,4 @@
// MatchReadyOverlay.tsx // /src/app/[locale]/components/MatchReadyOverlay.tsx
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -7,6 +7,7 @@ import { useSSEStore } from '@/lib/useSSEStore'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import { MAP_OPTIONS } from '@/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import Image from 'next/image'
type Props = { type Props = {
open: boolean open: boolean
@ -22,6 +23,27 @@ type Props = {
type Presence = 'online' | 'away' | 'offline' type Presence = 'online' | 'away' | 'offline'
type Participant = {
steamId: string
name: string
avatar: string
team: 'A' | 'B' | null
}
type ReadyResponse = {
participants?: Participant[]
ready?: Record<string, string>
total?: number
countReady?: number
}
type LooseObj = Record<string, unknown>
type SseEventLoose = {
type?: string
payload?: LooseObj
ts?: number
} & LooseObj
function fmt(ms: number) { function fmt(ms: number) {
const sec = Math.max(0, Math.ceil(ms / 1000)) const sec = Math.max(0, Math.ceil(ms / 1000))
const m = Math.floor(sec / 60) const m = Math.floor(sec / 60)
@ -78,7 +100,6 @@ export default function MatchReadyOverlay({
const shouldRender = Boolean(isVisibleBase && iAmAllowed) const shouldRender = Boolean(isVisibleBase && iAmAllowed)
// Ready-Status // Ready-Status
type Participant = { steamId: string; name: string; avatar: string; team: 'A' | 'B' | null }
const [participants, setParticipants] = useState<Participant[]>([]) const [participants, setParticipants] = useState<Participant[]>([])
const [readyMap, setReadyMap] = useState<Record<string, string>>({}) const [readyMap, setReadyMap] = useState<Record<string, string>>({})
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@ -90,36 +111,59 @@ export default function MatchReadyOverlay({
// ----- AUDIO ----- // ----- AUDIO -----
const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null) const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null)
const audioStartedRef = useRef(false) const audioStartedRef = useRef(false)
const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } } const stopBeeps = () => {
if (beepsRef.current) {
clearInterval(beepsRef.current)
beepsRef.current = null
}
}
const ensureAudioUnlocked = async () => { // 1) Typ anpassen keine privaten Felder spiegeln
type MaybeSound = {
play?: (name: string) => void
ensureUnlocked?: () => Promise<void> | void
unlock?: () => Promise<void> | void
}
// 2) überall sound über unknown casten
const startBeeps = useCallback(() => {
const S = sound as unknown as MaybeSound
try { S.play?.('ready') } catch {}
stopBeeps()
beepsRef.current = setInterval(() => {
try { S.play?.('beep') } catch {}
}, 1000)
}, [])
const playMenuAccept = useCallback(() => {
const S = sound as unknown as MaybeSound
try { S.play?.('menu_accept') } catch {}
try { void new Audio('/assets/sounds/menu_accept.wav').play() } catch {}
}, [])
// 3) ensureAudioUnlocked ohne public ctx/audioContext im Interface
const ensureAudioUnlocked = useCallback(async (): Promise<boolean> => {
const S = sound as unknown as MaybeSound
try { try {
if (typeof (sound as any).ensureUnlocked === 'function') { await (sound as any).ensureUnlocked(); return true } if (typeof S.ensureUnlocked === 'function') { await S.ensureUnlocked(); return true }
if (typeof (sound as any).unlock === 'function') { await (sound as any).unlock(); return true } if (typeof S.unlock === 'function') { await S.unlock(); return true }
const ctx = (sound as any).ctx || (sound as any).audioContext // Zugriff „locker“, um private Felder nicht in Typen zu erzwingen
const ctx =
(sound as unknown as { ctx?: AudioContext }).ctx ??
(sound as unknown as { audioContext?: AudioContext }).audioContext
if (ctx && typeof ctx.resume === 'function' && ctx.state !== 'running') await ctx.resume() if (ctx && typeof ctx.resume === 'function' && ctx.state !== 'running') await ctx.resume()
return true return true
} catch { return false } } catch {
return false
} }
}, [])
const startBeeps = () => {
try { sound.play('ready') } catch {}
stopBeeps()
beepsRef.current = setInterval(() => { try { sound.play('beep') } catch {} }, 1000)
}
const playMenuAccept = () => {
try { (sound as any).play?.('menu_accept') } catch {}
try { new Audio('/assets/sounds/menu_accept.wav').play() } catch {}
}
// --- sofort verbinden helper ---
const startConnectingNow = useCallback(() => { const startConnectingNow = useCallback(() => {
if (finished) return if (finished) return
stopBeeps() stopBeeps()
setFinished(true) setFinished(true)
setConnecting(true) setConnecting(true)
try { sound.play('loading') } catch {} try { (sound as unknown as MaybeSound).play?.('loading') } catch {}
const doConnect = () => { const doConnect = () => {
try { window.location.href = effectiveConnectHref } try { window.location.href = effectiveConnectHref }
@ -134,7 +178,7 @@ export default function MatchReadyOverlay({
} }
try { onTimeout?.() } catch {} try { onTimeout?.() } catch {}
} }
setTimeout(doConnect, 200) window.setTimeout(doConnect, 200)
}, [finished, effectiveConnectHref, onTimeout]) }, [finished, effectiveConnectHref, onTimeout])
// Ready-API nur nach Accept // Ready-API nur nach Accept
@ -142,8 +186,8 @@ export default function MatchReadyOverlay({
try { try {
const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' }) const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' })
if (!r.ok) return if (!r.ok) return
const j = await r.json() const j = (await r.json()) as ReadyResponse
const parts: Participant[] = j.participants ?? [] const parts: Participant[] = Array.isArray(j.participants) ? j.participants : []
setParticipants(parts) setParticipants(parts)
setReadyMap(j.ready ?? {}) setReadyMap(j.ready ?? {})
setTotal(j.total ?? 0) setTotal(j.total ?? 0)
@ -152,7 +196,9 @@ export default function MatchReadyOverlay({
// Team-Guard füttern // Team-Guard füttern
const ids = parts.map(p => String(p.steamId)).filter(Boolean) const ids = parts.map(p => String(p.steamId)).filter(Boolean)
if (ids.length) setAllowedIds(ids) if (ids.length) setAllowedIds(ids)
} catch {} } catch {
// ignore
}
}, [matchId]) }, [matchId])
// Accept // Accept
@ -178,7 +224,7 @@ export default function MatchReadyOverlay({
} }
} }
// „Es lädt nicht?“ nach 30s // „Es lädt nicht?“ nach 10s
useEffect(() => { useEffect(() => {
let id: number | null = null let id: number | null = null
if (connecting) { if (connecting) {
@ -192,26 +238,39 @@ export default function MatchReadyOverlay({
useEffect(() => { useEffect(() => {
if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return } if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return }
setShowBackdrop(true) setShowBackdrop(true)
const id = setTimeout(() => setShowContent(true), 300) const id = window.setTimeout(() => setShowContent(true), 300)
return () => clearTimeout(id) return () => window.clearTimeout(id)
}, [shouldRender]) }, [shouldRender])
// Nach Accept kurzer Refresh // Nach Accept kurzer Refresh
useEffect(() => { useEffect(() => {
if (!accepted) return if (!accepted) return
const id = setTimeout(loadReady, 250) const id = window.setTimeout(loadReady, 250)
return () => clearTimeout(id) return () => window.clearTimeout(id)
}, [accepted, loadReady]) }, [accepted, loadReady])
// SSE // SSE
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type const ev = lastEvent as unknown as SseEventLoose
const payload = (lastEvent as any).payload?.payload ?? (lastEvent as any).payload ?? lastEvent
const type =
typeof ev.type === 'string'
? ev.type
: typeof ev.payload?.type === 'string'
? (ev.payload.type as string)
: ''
// payload kann an verschiedenen Stellen liegen
const payload =
(ev.payload?.payload as LooseObj | undefined) ??
(ev.payload as LooseObj | undefined) ??
(ev as LooseObj)
// participants aus Event übernehmen (falls geschickt) // participants aus Event übernehmen (falls geschickt)
const payloadParticipants: string[] | undefined = Array.isArray(payload?.participants) const rawParticipants = payload?.participants
? payload.participants.map((sid: any) => String(sid)).filter(Boolean) const payloadParticipants: string[] | undefined = Array.isArray(rawParticipants)
? (rawParticipants as unknown[]).map(sid => String(sid)).filter(Boolean)
: undefined : undefined
if (payloadParticipants && payloadParticipants.length) { if (payloadParticipants && payloadParticipants.length) {
setAllowedIds(payloadParticipants) setAllowedIds(payloadParticipants)
@ -219,21 +278,26 @@ export default function MatchReadyOverlay({
if (type === 'ready-updated' && payload?.matchId === matchId) { if (type === 'ready-updated' && payload?.matchId === matchId) {
if (accepted) { if (accepted) {
const otherSteamId = payload?.steamId as string | undefined const otherSteamId = (payload?.steamId as string | undefined) ?? undefined
if (otherSteamId && otherSteamId !== mySteamId) playMenuAccept() if (otherSteamId && otherSteamId !== mySteamId) playMenuAccept()
} }
loadReady() void loadReady()
return return
} }
if (type === 'user-status-updated') { if (type === 'user-status-updated') {
const steamId: string | undefined = payload?.steamId ?? payload?.user?.steamId const steamId =
(payload?.steamId as string | undefined) ||
(payload?.user && typeof (payload.user as LooseObj).steamId === 'string'
? String((payload.user as LooseObj).steamId)
: undefined)
const status = payload?.status as Presence | undefined const status = payload?.status as Presence | undefined
if (steamId && (status === 'online' || status === 'away' || status === 'offline')) { if (steamId && (status === 'online' || status === 'away' || status === 'offline')) {
setStatusMap(prev => (prev[steamId] === status ? prev : { ...prev, [steamId]: status })) setStatusMap(prev => (prev[steamId] === status ? prev : { ...prev, [steamId]: status }))
} }
} }
}, [accepted, lastEvent, matchId, mySteamId, loadReady]) }, [accepted, lastEvent, matchId, mySteamId, loadReady, playMenuAccept])
// Mount-Animation // Mount-Animation
const [fadeIn, setFadeIn] = useState(false) const [fadeIn, setFadeIn] = useState(false)
@ -269,8 +333,8 @@ export default function MatchReadyOverlay({
window.addEventListener('keydown', once, { once: true }) window.addEventListener('keydown', once, { once: true })
} }
} }
const id = setTimeout(tryPlay, 0) const id = window.setTimeout(tryPlay, 0)
return () => clearTimeout(id) return () => window.clearTimeout(id)
}, [shouldRender, forceGif, prefersReducedMotion]) }, [shouldRender, forceGif, prefersReducedMotion])
useEffect(() => { useEffect(() => {
@ -311,7 +375,7 @@ export default function MatchReadyOverlay({
})() })()
return () => { cleanup(); stopBeeps() } return () => { cleanup(); stopBeeps() }
}, [showContent]) }, [showContent, ensureAudioUnlocked, startBeeps])
// Auto-Connect wenn alle bereit // Auto-Connect wenn alle bereit
useEffect(() => { useEffect(() => {
@ -342,7 +406,7 @@ export default function MatchReadyOverlay({
} }
rafRef.current = requestAnimationFrame(step) rafRef.current = requestAnimationFrame(step)
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
}, [shouldRender, effectiveDeadline, accepted, finished, onTimeout, startConnectingNow]) }, [shouldRender, effectiveDeadline, accepted, finished, startConnectingNow])
// Map-Icon // Map-Icon
const mapIconUrl = useMemo(() => { const mapIconUrl = useMemo(() => {
@ -383,19 +447,21 @@ export default function MatchReadyOverlay({
> >
{p ? ( {p ? (
<> <>
<img <Image
src={p.avatar} src={p.avatar}
alt={p.name} alt={p.name}
fill
sizes="36px"
className={[ className={[
'w-full h-full object-cover rounded-[2px] transition-opacity', 'object-cover rounded-[2px] transition-opacity',
isReady ? '' : 'opacity-40 filter grayscale' isReady ? '' : 'opacity-40 grayscale'
].join(' ')} ].join(' ')}
/> />
{!isReady && <div className="absolute inset-0 bg-black/30 pointer-events-none" />} {!isReady && <div className="absolute inset-0 bg-black/30 pointer-events-none" />}
</> </>
) : ( ) : (
<div className="w-full h-full grid place-items-center"> <div className="w-full h-full grid place-items-center">
<svg viewBox="0 0 24 24" className="w-6 h-6 opacity-60" fill="currentColor"> <svg viewBox="0 0 24 24" className="w-6 h-6 opacity-60" fill="currentColor" aria-hidden>
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-5 0-9 2.5-9 5.5A1.5 1.5 0 0 0 4.5 21h15A1.5 1.5 0 0 0 21 19.5C21 16.5 17 14 12 14Z" /> <path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-5 0-9 2.5-9 5.5A1.5 1.5 0 0 0 4.5 21h15A1.5 1.5 0 0 0 21 19.5C21 16.5 17 14 12 14Z" />
</svg> </svg>
</div> </div>
@ -433,21 +499,26 @@ export default function MatchReadyOverlay({
].join(' ')} ].join(' ')}
> >
{/* Map */} {/* Map */}
<img <Image
src={mapBg} src={mapBg}
alt={mapLabel} alt={mapLabel}
className="absolute inset-0 w-full h-full object-cover brightness-90" fill
priority
sizes="(max-width: 768px) 95vw, 720px"
className="absolute inset-0 object-cover brightness-90"
/> />
{/* Motion-Layer */} {/* Motion-Layer */}
{useGif ? ( {useGif ? (
<div className="absolute inset-0 opacity-50 pointer-events-none"> <div className="absolute inset-0 opacity-50 pointer-events-none">
<img <Image
src="/assets/vids/overlay_cs2_accept.webp" src="/assets/vids/overlay_cs2_accept.webp"
alt="" alt=""
className="absolute inset-0 w-full h-full object-cover" fill
sizes="(max-width: 768px) 95vw, 720px"
className="absolute inset-0 object-cover"
decoding="async" decoding="async"
loading="eager" priority
/> />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
</div> </div>
@ -477,7 +548,7 @@ export default function MatchReadyOverlay({
{/* Icon + Label */} {/* Icon + Label */}
<div className="mt-[10px] flex items-center justify-center text-[#8af784]"> <div className="mt-[10px] flex items-center justify-center text-[#8af784]">
<img src={mapIconUrl} alt={`${mapLabel} Icon`} className="w-5 h-5 object-contain" /> <Image src={mapIconUrl} alt={`${mapLabel} Icon`} width={20} height={20} className="object-contain" />
<span className="ml-2 text-[15px] [transform:scale(1,0.9)]">{mapLabel}</span> <span className="ml-2 text-[15px] [transform:scale(1,0.9)]">{mapLabel}</span>
</div> </div>

View File

@ -1,11 +1,10 @@
// MiniCard.tsx // /src/app/[locale]/components/MiniCard.tsx
'use client' 'use client'
import Button from './Button' import Button from './Button'
import Image from 'next/image'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
import { motion, AnimatePresence } from 'framer-motion'
import UserAvatarWithStatus from './UserAvatarWithStatus' import UserAvatarWithStatus from './UserAvatarWithStatus'
import React from 'react'
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending' type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
@ -22,7 +21,8 @@ type MiniCardProps = {
teamLeaderSteamId?: string | null teamLeaderSteamId?: string | null
location?: string location?: string
rank?: number rank?: number
dragListeners?: any /** optionale Event-/DOM-Attribute (z. B. von dnd-kit: listeners/attributes) */
dragListeners?: React.HTMLAttributes<HTMLDivElement>
hoverEffect?: boolean hoverEffect?: boolean
onPromote?: (steamId: string) => void onPromote?: (steamId: string) => void
hideActions?: boolean hideActions?: boolean
@ -42,10 +42,8 @@ export default function MiniCard({
onSelect, onSelect,
onKick, onKick,
isLeader = false, isLeader = false,
draggable,
currentUserSteamId, currentUserSteamId,
teamLeaderSteamId, teamLeaderSteamId,
location,
rank, rank,
dragListeners, dragListeners,
hoverEffect = false, hoverEffect = false,
@ -56,24 +54,25 @@ export default function MiniCard({
isAdmin = false, isAdmin = false,
invitedStatus, invitedStatus,
isInvite = false, isInvite = false,
invitationId
}: MiniCardProps) { }: MiniCardProps) {
//const isSelectable = typeof onSelect === 'function' const canEdit =
const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
const statusBg = const statusBg =
invitedStatus === 'sent' ? 'bg-green-500 dark:bg-green-700' : invitedStatus === 'sent'
invitedStatus === 'added' ? 'bg-teal-500 dark:bg-teal-700' : ? 'bg-green-500 dark:bg-green-700'
invitedStatus === 'failed' ? 'bg-red-500 dark:bg-red-700' : : invitedStatus === 'added'
invitedStatus === 'pending' ? 'bg-yellow-500 dark:bg-yellow-700': ? 'bg-teal-500 dark:bg-teal-700'
'bg-white dark:bg-neutral-800' : invitedStatus === 'failed'
? 'bg-red-500 dark:bg-red-700'
: invitedStatus === 'pending'
? 'bg-yellow-500 dark:bg-yellow-700'
: 'bg-white dark:bg-neutral-800'
// Rand unabhängig vom Status (nur bei Auswahl Blau; sonst neutral oder transparent) const baseBorder = selected
const baseBorder =
selected
? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400' ? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400'
: invitedStatus : invitedStatus
? 'border-transparent' // kein grüner/roter Rand, Fokus liegt auf dem BG ? 'border-transparent'
: 'border-gray-200 dark:border-neutral-700' : 'border-gray-200 dark:border-neutral-700'
const cardClasses = ` const cardClasses = `
@ -112,26 +111,47 @@ export default function MiniCard({
} }
const statusLabel = const statusLabel =
invitedStatus === 'sent' ? 'Eingeladen' : invitedStatus === 'sent'
invitedStatus === 'added' ? 'Hinzugefügt' : ? 'Eingeladen'
invitedStatus === 'failed'? 'Fehlgeschlagen' : : invitedStatus === 'added'
invitedStatus === 'pending'? 'Wird gesendet…' : null ? 'Hinzugefügt'
: invitedStatus === 'failed'
? 'Fehlgeschlagen'
: invitedStatus === 'pending'
? 'Wird gesendet…'
: null
const statusPillClasses = const statusPillClasses =
invitedStatus === 'sent' ? 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-200' : invitedStatus === 'sent'
invitedStatus === 'added' ? 'bg-teal-100 text-teal-600 dark:bg-teal-900/40 dark:text-teal-300' : ? 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-200'
invitedStatus === 'failed' ? 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-300' : : invitedStatus === 'added'
invitedStatus === 'pending' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': ''; ? 'bg-teal-100 text-teal-600 dark:bg-teal-900/40 dark:text-teal-300'
: invitedStatus === 'failed'
? 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-300'
: invitedStatus === 'pending'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300'
: ''
return ( return (
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}> <div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
{canEdit && !hideActions && !hideOverlay && ( {canEdit && !hideActions && !hideOverlay && (
<div className={`absolute inset-0 bg-white dark:bg-black bg-opacity-80 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${ <div
className={`absolute inset-0 bg-white dark:bg-black bg-opacity-80 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${
hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100' hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'
}`}> }`}
<span className="text-gray-800 dark:text-neutral-200 font-semibold text-sm mb-1 truncate px-2 max-w-[90%] text-center">{title}</span> >
<span className="text-gray-800 dark:text-neutral-200 font-semibold text-sm mb-1 truncate px-2 max-w-[90%] text-center">
{title}
</span>
<div className="pointer-events-auto" onPointerDown={stopDrag}> <div className="pointer-events-auto" onPointerDown={stopDrag}>
<Button className="max-w-[120px]" title={isInvite ? 'Zurückziehen' : 'Kicken'} color="red" variant="solid" size="sm" onClick={isInvite ? handleRevokeClick : handleKickClick} /> <Button
className="max-w-[120px]"
title={isInvite ? 'Zurückziehen' : 'Kicken'}
color="red"
variant="solid"
size="sm"
onClick={isInvite ? handleRevokeClick : handleKickClick}
/>
</div> </div>
{typeof onPromote === 'function' && ( {typeof onPromote === 'function' && (
<div className="pointer-events-auto" onPointerDown={stopDrag}> <div className="pointer-events-auto" onPointerDown={stopDrag}>
@ -166,14 +186,6 @@ export default function MiniCard({
) : ( ) : (
<PremierRankBadge rank={rank ?? 0} /> <PremierRankBadge rank={rank ?? 0} />
)} )}
{ /*
{location ? (
<span className={`fi fi-${location.toLowerCase()} text-xl mt-1`} title={location} />
) : (
<span className="text-xl mt-1" title="Weltweit">🌐</span>
)}
*/ }
</div> </div>
</div> </div>
) )

View File

@ -2,6 +2,7 @@
'use client' 'use client'
import { useDroppable } from "@dnd-kit/core" import { useDroppable } from "@dnd-kit/core"
import Image from "next/image"
type MiniCardDummyProps = { type MiniCardDummyProps = {
title: string title: string
@ -11,7 +12,7 @@ type MiniCardDummyProps = {
} }
export default function MiniCardDummy({ title, onClick, children, zoneId }: MiniCardDummyProps) { export default function MiniCardDummy({ title, onClick, children, zoneId }: MiniCardDummyProps) {
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef } = useDroppable({
id: `${zoneId ?? 'dummy'}-drop`, id: `${zoneId ?? 'dummy'}-drop`,
data: { containerId: zoneId, role: 'dummy' }, // ⬅️ wichtig für Zone-Highlight data: { containerId: zoneId, role: 'dummy' }, // ⬅️ wichtig für Zone-Highlight
}) })
@ -32,7 +33,7 @@ export default function MiniCardDummy({ title, onClick, children, zoneId }: Mini
{children ? ( {children ? (
children children
) : ( ) : (
<img <Image
src="https://via.placeholder.com/64x64.png?text=+" src="https://via.placeholder.com/64x64.png?text=+"
alt="Dummy Avatar" alt="Dummy Avatar"
className="w-16 h-16 object-cover" className="w-16 h-16 object-cover"

View File

@ -2,10 +2,18 @@
'use client' 'use client'
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useRouter } from '@/i18n/navigation' import { useRouter } from '@/i18n/navigation'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import type { MatchPlayer } from '../../../types/match' import type { MatchPlayer } from '../../../types/match'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
import FaceitLevelImage from './FaceitLevelBadge' import FaceitLevelImage from './FaceitLevelBadge'
@ -13,25 +21,42 @@ import FaceitLevelImage from './FaceitLevelBadge'
export type MiniPlayerCardProps = { export type MiniPlayerCardProps = {
open: boolean open: boolean
player: MatchPlayer player: MatchPlayer
anchor: DOMRect | null
onClose?: () => void onClose?: () => void
prefetchedSummary?: PlayerSummary | null prefetchedSummary?: PlayerSummary | null
prefetchedFaceit?: { level: number|null; elo: number|null; nickname: string|null; url: string|null } | null prefetchedFaceit?: {
level: number | null
elo: number | null
nickname: string | null
url: string | null
} | null
anchorEl?: HTMLElement | null anchorEl?: HTMLElement | null
onCardMount?: (el: HTMLDivElement | null) => void onCardMount?: (el: HTMLDivElement | null) => void
} }
type BanStatus = {
vacBanned?: boolean | null
numberOfVACBans?: number | null
numberOfGameBans?: number | null
communityBanned?: boolean | null
economyBan?: string | null
daysSinceLastBan?: number | null
}
type UserWithFaceit = { type UserWithFaceit = {
steamId?: string | null steamId?: string | null
name?: string | null name?: string | null
avatar?: string | null avatar?: string | null
premierRank?: number | null premierRank?: number | null
// flache Ban-Felder (manche APIs liefern das so)
vacBanned?: boolean | null vacBanned?: boolean | null
numberOfVACBans?: number | null numberOfVACBans?: number | null
numberOfGameBans?: number | null numberOfGameBans?: number | null
communityBanned?: boolean | null communityBanned?: boolean | null
economyBan?: string | null economyBan?: string | null
daysSinceLastBan?: number | null daysSinceLastBan?: number | null
// oder geschachtelt:
banStatus?: BanStatus
// Faceit
faceitNickname?: string | null faceitNickname?: string | null
faceitUrl?: string | null faceitUrl?: string | null
faceitLevel?: number | null faceitLevel?: number | null
@ -56,10 +81,17 @@ export type PlayerSummary = {
} }
function Sparkline({ values }: { values: number[] }) { function Sparkline({ values }: { values: number[] }) {
const W = 200, H = 40, pad = 6, n = Math.max(1, values.length) const W = 200,
const max = Math.max(...values, 1), min = Math.min(...values, 0), range = Math.max(0.05, max - min) H = 40,
pad = 6,
n = Math.max(1, values.length)
const max = Math.max(...values, 1),
min = Math.min(...values, 0),
range = Math.max(0.05, max - min)
const step = (W - pad * 2) / Math.max(1, n - 1) const step = (W - pad * 2) / Math.max(1, n - 1)
const pts = values.map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`).join(' ') const pts = values
.map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`)
.join(' ')
return ( return (
<svg viewBox={`0 0 ${W} ${H}`} className="w-[200px] h-[40px] text-blue-300/90"> <svg viewBox={`0 0 ${W} ${H}`} className="w-[200px] h-[40px] text-blue-300/90">
<polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.95" strokeWidth="2" /> <polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.95" strokeWidth="2" />
@ -68,12 +100,22 @@ function Sparkline({ values }: { values: number[] }) {
} }
export default function MiniPlayerCard({ export default function MiniPlayerCard({
open, player, anchor, onClose, prefetchedSummary, prefetchedFaceit, anchorEl, onCardMount open,
player,
onClose,
prefetchedSummary,
prefetchedFaceit,
anchorEl,
onCardMount,
}: MiniPlayerCardProps) { }: MiniPlayerCardProps) {
const router = useRouter() const router = useRouter()
const cardRef = useRef<HTMLDivElement | null>(null) const cardRef = useRef<HTMLDivElement | null>(null)
const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({ top: 0, left: 0, side: 'right' }) const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({
top: 0,
left: 0,
side: 'right',
})
const [measured, setMeasured] = useState(false) const [measured, setMeasured] = useState(false)
// Hover-Intent // Hover-Intent
@ -88,14 +130,19 @@ export default function MiniPlayerCard({
// Summary nur aus Prefetch (kein Fetch) // Summary nur aus Prefetch (kein Fetch)
const [summary, setSummary] = useState<PlayerSummary | null>(prefetchedSummary ?? null) const [summary, setSummary] = useState<PlayerSummary | null>(prefetchedSummary ?? null)
useEffect(() => { setSummary(prefetchedSummary ?? null) }, [prefetchedSummary, player.user?.steamId]) useEffect(() => {
setSummary(prefetchedSummary ?? null)
}, [prefetchedSummary, player.user?.steamId])
// FACEIT aus Prefetch / user-Fallback // FACEIT aus Prefetch / user-Fallback
const faceit = useMemo<FaceitState>(() => { const faceit = useMemo<FaceitState>(() => {
const url = const url =
prefetchedFaceit?.url prefetchedFaceit?.url ??
?? (u.faceitUrl ? u.faceitUrl.replace('{lang}', 'en') (u.faceitUrl
: (u.faceitNickname ? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}` : null)) ? u.faceitUrl.replace('{lang}', 'en')
: u.faceitNickname
? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}`
: null)
return { return {
level: prefetchedFaceit?.level ?? u.faceitLevel ?? null, level: prefetchedFaceit?.level ?? u.faceitLevel ?? null,
@ -111,11 +158,12 @@ export default function MiniPlayerCard({
} }
// Positionierung // Positionierung
const doPosition = () => { const doPosition = useCallback(() => {
if (!cardRef.current || !anchorEl) return if (!cardRef.current || !anchorEl) return
const a = anchorEl.getBoundingClientRect() const a = anchorEl.getBoundingClientRect()
const cardEl = cardRef.current const cardEl = cardRef.current
const vw = window.innerWidth, vh = window.innerHeight const vw = window.innerWidth,
vh = window.innerHeight
const { width: cw, height: ch } = cardEl.getBoundingClientRect() const { width: cw, height: ch } = cardEl.getBoundingClientRect()
const rightLeft = a.right const rightLeft = a.right
@ -130,13 +178,18 @@ export default function MiniPlayerCard({
setPos({ top: Math.round(top), left: Math.round(left), side }) setPos({ top: Math.round(top), left: Math.round(left), side })
setMeasured(true) setMeasured(true)
} }, [anchorEl])
const schedule = () => requestAnimationFrame(() => requestAnimationFrame(doPosition)) const schedule = useCallback(() => {
requestAnimationFrame(() => requestAnimationFrame(doPosition))
}, [doPosition])
useLayoutEffect(() => { useLayoutEffect(() => {
if (open) { setMeasured(false); schedule() } if (open) {
}, [open, anchorEl]) setMeasured(false)
schedule()
}
}, [open, anchorEl, schedule])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -155,7 +208,7 @@ export default function MiniPlayerCard({
window.removeEventListener('resize', onScrollOrResize) window.removeEventListener('resize', onScrollOrResize)
ro?.disconnect() ro?.disconnect()
} }
}, [open, anchorEl]) }, [open, anchorEl, schedule])
// Bei Spielerwechsel sanft neu einmessen // Bei Spielerwechsel sanft neu einmessen
useEffect(() => { useEffect(() => {
@ -166,7 +219,7 @@ export default function MiniPlayerCard({
} }
setMeasured(false) setMeasured(false)
schedule() schedule()
}, [open, anchorEl, player.user?.steamId]) }, [open, anchorEl, player.user?.steamId, schedule])
// Anchor-Hover steuert Open/Close // Anchor-Hover steuert Open/Close
useEffect(() => { useEffect(() => {
@ -174,21 +227,32 @@ export default function MiniPlayerCard({
const armClose = () => { const armClose = () => {
if (!closeT.current) { if (!closeT.current) {
closeT.current = window.setTimeout(() => { closeT.current = null; onClose?.() }, CLOSE_DELAY) closeT.current = window.setTimeout(() => {
closeT.current = null
onClose?.()
}, CLOSE_DELAY)
} }
} }
const disarmClose = () => { const disarmClose = () => {
if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null } if (closeT.current) {
window.clearTimeout(closeT.current)
closeT.current = null
}
} }
const onEnter = () => { const onEnter = () => {
disarmClose() disarmClose()
if (!open && !openT.current) { if (!open && !openT.current) {
openT.current = window.setTimeout(() => { openT.current = null }, OPEN_DELAY) openT.current = window.setTimeout(() => {
openT.current = null
}, OPEN_DELAY)
} }
} }
const onLeave = () => { const onLeave = () => {
if (openT.current) { window.clearTimeout(openT.current); openT.current = null } if (openT.current) {
window.clearTimeout(openT.current)
openT.current = null
}
armClose() armClose()
} }
@ -205,24 +269,31 @@ export default function MiniPlayerCard({
} }
}, [anchorEl, open, onClose]) }, [anchorEl, open, onClose])
// BAN-Badges // BAN-Badges (verschachtelt oder flach)
const nestedBan = (player.user as any)?.banStatus const nestedBan: BanStatus | undefined =
(player.user as { banStatus?: BanStatus } | undefined)?.banStatus
const flat = u const flat = u
const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0) const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0)
const isBannedNested = const isBannedNested = !!(
!!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 || nestedBan?.vacBanned ||
(nestedBan?.numberOfGameBans ?? 0) > 0 || nestedBan?.communityBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 ||
(nestedBan?.economyBan && nestedBan.economyBan !== 'none')) (nestedBan?.numberOfGameBans ?? 0) > 0 ||
nestedBan?.communityBanned ||
(nestedBan?.economyBan && nestedBan.economyBan !== 'none')
)
const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0
const isBannedFlat = const isBannedFlat =
!!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 || (flat.numberOfGameBans ?? 0) > 0 || !!flat.vacBanned ||
!!flat.communityBanned || (!!flat.economyBan && flat.economyBan !== 'none') (flat.numberOfVACBans ?? 0) > 0 ||
(flat.numberOfGameBans ?? 0) > 0 ||
!!flat.communityBanned ||
(!!flat.economyBan && flat.economyBan !== 'none')
const hasVac = nestedBan ? hasVacNested : hasVacFlat const hasVac = nestedBan ? hasVacNested : hasVacFlat
const isBanned = nestedBan ? isBannedNested : isBannedFlat const isBanned = nestedBan ? isBannedNested : isBannedFlat
const banTooltip = useMemo(() => { const banTooltip = useMemo(() => {
const parts: string[] = [] const parts: string[] = []
const src = nestedBan ?? flat const src = (nestedBan ?? flat) as Required<BanStatus>
if (src.vacBanned) parts.push('VAC-Ban aktiv') if (src.vacBanned) parts.push('VAC-Ban aktiv')
if ((src.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${src.numberOfVACBans}`) if ((src.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${src.numberOfVACBans}`)
if ((src.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${src.numberOfGameBans}`) if ((src.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${src.numberOfGameBans}`)
@ -244,10 +315,18 @@ export default function MiniPlayerCard({
tabIndex={-1} tabIndex={-1}
className="pointer-events-auto fixed z-[10000] w-[320px] rounded-lg border border-white/10 bg-neutral-900/95 p-3 text-white shadow-2xl backdrop-blur transition-opacity duration-100" className="pointer-events-auto fixed z-[10000] w-[320px] rounded-lg border border-white/10 bg-neutral-900/95 p-3 text-white shadow-2xl backdrop-blur transition-opacity duration-100"
style={{ top: pos.top, left: pos.left, opacity: measured ? 1 : 0 }} style={{ top: pos.top, left: pos.left, opacity: measured ? 1 : 0 }}
onMouseEnter={() => { if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null } }} onMouseEnter={() => {
if (closeT.current) {
window.clearTimeout(closeT.current)
closeT.current = null
}
}}
onMouseLeave={() => { onMouseLeave={() => {
if (!closeT.current) { if (!closeT.current) {
closeT.current = window.setTimeout(() => { closeT.current = null; onClose?.() }, CLOSE_DELAY) closeT.current = window.setTimeout(() => {
closeT.current = null
onClose?.()
}, CLOSE_DELAY)
} }
}} }}
> >
@ -263,7 +342,9 @@ export default function MiniPlayerCard({
{/* Header mit Links rechts */} {/* Header mit Links rechts */}
<div <div
onClick={() => { steam64 ? router.push(`/profile/${steam64}`) : null }} onClick={() => {
if (steam64) router.push(`/profile/${steam64}`)
}}
className="flex cursor-pointer transition bg-white dark:bg-neutral-800 dark:border-neutral-700 hover:bg-neutral-200 hover:dark:bg-neutral-700 items-center justify-between mb-2 rounded-md bg-white/5 ring-1 ring-white/10 px-2 py-2" className="flex cursor-pointer transition bg-white dark:bg-neutral-800 dark:border-neutral-700 hover:bg-neutral-200 hover:dark:bg-neutral-700 items-center justify-between mb-2 rounded-md bg-white/5 ring-1 ring-white/10 px-2 py-2"
> >
{/* Links: Avatar + Name + Badges */} {/* Links: Avatar + Name + Badges */}
@ -276,11 +357,9 @@ export default function MiniPlayerCard({
/> />
<div className="min-w-0"> <div className="min-w-0">
{/* Name + BAN/VAC direkt daneben (wie MatchDetails) */} {/* Name + BAN/VAC direkt daneben */}
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="truncate text-sm font-semibold"> <span className="truncate text-sm font-semibold">{u.name ?? 'Unbekannt'}</span>
{u.name ?? 'Unbekannt'}
</span>
{isBanned && ( {isBanned && (
<span <span
title={banTooltip} title={banTooltip}
@ -292,7 +371,7 @@ export default function MiniPlayerCard({
)} )}
</div> </div>
{/* darunter: Premier + Faceit (unverändert) */} {/* darunter: Premier + Faceit */}
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<PremierRankBadge rank={u.premierRank ?? 0} /> <PremierRankBadge rank={u.premierRank ?? 0} />
{faceit.nickname && <FaceitLevelImage elo={faceit.elo ?? 0} className="-ml-0.5" />} {faceit.nickname && <FaceitLevelImage elo={faceit.elo ?? 0} className="-ml-0.5" />}
@ -323,7 +402,7 @@ export default function MiniPlayerCard({
title={`Faceit-Profil${faceit.nickname ? ` von ${faceit.nickname}` : ''}`} title={`Faceit-Profil${faceit.nickname ? ` von ${faceit.nickname}` : ''}`}
className="inline-flex size-8 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10" className="inline-flex size-8 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
> >
<img src="/assets/img/logos/faceit.svg" alt="" className="h-4 w-4" aria-hidden /> <Image src="/assets/img/logos/faceit.svg" alt="" width={16} height={16} />
</Link> </Link>
)} )}
</div> </div>
@ -346,10 +425,16 @@ export default function MiniPlayerCard({
<div <div
className={[ className={[
'text-[11px] font-medium', 'text-[11px] font-medium',
summary.perfDelta > 0 ? 'text-emerald-300' : summary.perfDelta < 0 ? 'text-rose-300' : 'text-neutral-300', summary.perfDelta > 0
? 'text-emerald-300'
: summary.perfDelta < 0
? 'text-rose-300'
: 'text-neutral-300',
].join(' ')} ].join(' ')}
> >
{summary.perfDelta === 0 ? '±0.00' : `${summary.perfDelta > 0 ? '+' : ''}${summary.perfDelta.toFixed(2)}`} {summary.perfDelta === 0
? '±0.00'
: `${summary.perfDelta > 0 ? '+' : ''}${summary.perfDelta.toFixed(2)}`}
</div> </div>
</div> </div>
<div className="mt-1"> <div className="mt-1">

View File

@ -1,4 +1,4 @@
// /src/app/components/Modal.tsx // /src/app/[locale]/components/Modal.tsx
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
@ -29,6 +29,19 @@ type ModalProps = {
scrollBody?: boolean scrollBody?: boolean
} }
/* ───────── HSOverlay-Hilfstypen (nur lokal verwendet) ───────── */
type HSOverlayInstance = {
element: HTMLElement
destroy?: () => void
}
type HSOverlayAPI = {
collection?: HSOverlayInstance[]
autoInit?: () => void
open?: (el: HTMLElement) => void
close?: (el: HTMLElement) => void
}
export default function Modal({ export default function Modal({
id, id,
title, title,
@ -44,11 +57,16 @@ export default function Modal({
scrollBody = true, scrollBody = true,
}: ModalProps) { }: ModalProps) {
useEffect(() => { useEffect(() => {
const modalEl = document.getElementById(id) const modalEl = document.getElementById(id) as HTMLElement | null
const hs = (window as any).HSOverlay
// Kollisionsfrei zum globalen Typ: wir lesen Window.HSOverlay und erweitern ihn lokal
type HSOverlayBase = { open?: (el: HTMLElement) => void; close?: (el: HTMLElement) => void }
const hs =
(window as unknown as { HSOverlay?: HSOverlayBase & HSOverlayAPI }).HSOverlay
if (!modalEl || !hs) return if (!modalEl || !hs) return
const getCollection = (): any[] => const getCollection = (): HSOverlayInstance[] =>
Array.isArray(hs.collection) ? hs.collection : [] Array.isArray(hs.collection) ? hs.collection : []
const destroyIfExists = () => { const destroyIfExists = () => {
@ -60,7 +78,7 @@ export default function Modal({
} }
const handleClose = () => onClose?.() const handleClose = () => onClose?.()
modalEl.addEventListener('hsOverlay:close', handleClose) modalEl.addEventListener('hsOverlay:close', handleClose as EventListener)
try { try {
if (show) { if (show) {
@ -71,14 +89,16 @@ export default function Modal({
hs.close?.(modalEl) hs.close?.(modalEl)
destroyIfExists() destroyIfExists()
} }
} catch (err) {} } catch {
// noop
}
return () => { return () => {
modalEl.removeEventListener('hsOverlay:close', handleClose) modalEl.removeEventListener('hsOverlay:close', handleClose as EventListener)
destroyIfExists() destroyIfExists()
// Fallback: Globale Backdrops wegräumen, falls die Lib zickt // Fallback: Globale Backdrops wegräumen, falls die Lib zickt
document.querySelectorAll('.hs-overlay-backdrop')?.forEach(el => el.remove()) document.querySelectorAll('.hs-overlay-backdrop')?.forEach((el) => el.remove())
document.body.classList.remove('overflow-hidden','[&.hs-overlay-open]') // je nach Lib-Version document.body.classList.remove('overflow-hidden', '[&.hs-overlay-open]')
} }
}, [show, id, onClose]) }, [show, id, onClose])
@ -93,8 +113,8 @@ export default function Modal({
if (e.target === e.currentTarget) onClose?.() if (e.target === e.currentTarget) onClose?.()
}} }}
className={ className={
"hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden " + 'hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden ' +
(show ? "" : "pointer-events-none") (show ? '' : 'pointer-events-none')
} }
> >
{/* Backdrop */} {/* Backdrop */}
@ -140,7 +160,7 @@ export default function Modal({
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700"> <div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
{!hideCloseButton && ( {!hideCloseButton && (
<Button <Button
size='sm' size="sm"
data-hs-overlay={`#${id}`} data-hs-overlay={`#${id}`}
onClick={onClose} onClick={onClose}
className="py-2 px-3 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-700" className="py-2 px-3 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-700"
@ -151,7 +171,7 @@ export default function Modal({
{onSave && ( {onSave && (
<Button <Button
size='sm' size="sm"
onClick={onSave} onClick={onSave}
disabled={disableSave} disabled={disableSave}
className={`py-2 px-3 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 hover:bg-${closeButtonColor}-700 focus:bg-${closeButtonColor}-700 text-white`} className={`py-2 px-3 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 hover:bg-${closeButtonColor}-700 focus:bg-${closeButtonColor}-700 text-white`}

View File

@ -14,6 +14,8 @@ type Props = {
initialInvitationMap: Record<string, string> initialInvitationMap: Record<string, string>
} }
type SsePayloadLoose = Record<string, unknown>;
/* helpers */ /* helpers */
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId)) const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
const eqPlayers = (a: Player[] = [], b: Player[] = []) => { const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
@ -21,17 +23,27 @@ const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
return true return true
} }
// sichere Helpers, um den "leader" zu vergleichen, egal ob string oder Player-Objekt
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object'
const leaderKeyOf = (leader: unknown): string => {
if (typeof leader === 'string') return leader
if (isRecord(leader) && typeof leader.steamId === 'string') return leader.steamId
return ''
}
const eqTeam = (a: Team, b: Team) => { const eqTeam = (a: Team, b: Team) => {
if (a.id !== b.id) return false if (a.id !== b.id) return false
if ((a.name ?? '') !== (b.name ?? '')) return false if ((a.name ?? '') !== (b.name ?? '')) return false
if ((a.logo ?? '') !== (b.logo ?? '')) return false if ((a.logo ?? '') !== (b.logo ?? '')) return false
if ((a.leader as any) !== (b.leader as any)) return false if (leaderKeyOf((a as unknown as { leader?: unknown }).leader) !== leaderKeyOf((b as unknown as { leader?: unknown }).leader)) return false
if (a.joinPolicy !== b.joinPolicy) return false if (a.joinPolicy !== b.joinPolicy) return false
return ( return (
eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) && eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers)) eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
) )
} }
const eqTeamList = (a: Team[], b: Team[]) => { const eqTeamList = (a: Team[], b: Team[]) => {
if (a.length !== b.length) return false if (a.length !== b.length) return false
const mapA = new Map(a.map(t => [t.id, t])) const mapA = new Map(a.map(t => [t.id, t]))
@ -41,9 +53,10 @@ const eqTeamList = (a: Team[], b: Team[]) => {
} }
return true return true
} }
function parseTeamsResponse(raw: any): Team[] {
function parseTeamsResponse(raw: unknown): Team[] {
if (Array.isArray(raw)) return raw as Team[] if (Array.isArray(raw)) return raw as Team[]
if (raw && Array.isArray(raw.teams)) return raw.teams as Team[] if (isRecord(raw) && Array.isArray(raw.teams)) return raw.teams as Team[]
return [] return []
} }
@ -64,13 +77,20 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
fetch('/api/user/invitations', { cache: 'no-store' }), fetch('/api/user/invitations', { cache: 'no-store' }),
]) ])
if (!teamRes.ok || !invitesRes.ok) return if (!teamRes.ok || !invitesRes.ok) return
const rawTeams = await teamRes.json() const rawTeams: unknown = await teamRes.json()
const rawInv = await invitesRes.json() const rawInv: unknown = await invitesRes.json()
const nextTeams: Team[] = parseTeamsResponse(rawTeams) const nextTeams: Team[] = parseTeamsResponse(rawTeams)
const mapping: Record<string, string> = {} const mapping: Record<string, string> = {}
for (const inv of rawInv?.invitations || []) { if (isRecord(rawInv) && Array.isArray(rawInv.invitations)) {
if (inv.type === 'team-join-request') mapping[inv.teamId] = inv.id for (const inv of rawInv.invitations as Array<Record<string, unknown>>) {
const type = inv.type
const teamId = inv.teamId
const id = inv.id
if (type === 'team-join-request' && typeof teamId === 'string' && typeof id === 'string') {
mapping[teamId] = id
}
}
} }
setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams)) setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams))
@ -92,7 +112,8 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
useEffect(() => { useEffect(() => {
// Nur nachladen, falls keine Initialdaten übergeben wurden // Nur nachladen, falls keine Initialdaten übergeben wurden
if (!initialTeams?.length) fetchTeamsAndInvitations() if (!initialTeams?.length) fetchTeamsAndInvitations()
}, []) // Warnung beheben: explizit auf die Länge hören
}, [initialTeams?.length])
const teamsRef = useRef(teams) const teamsRef = useRef(teams)
useEffect(() => { teamsRef.current = teams }, [teams]) useEffect(() => { teamsRef.current = teams }, [teams])
@ -103,19 +124,25 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
if (!lastEvent) return if (!lastEvent) return
// Signatur: Typ + die paar Payload-Felder, die sich bei uns ändern // Signatur: Typ + die paar Payload-Felder, die sich bei uns ändern
const p = (lastEvent.payload ?? {}) as SsePayloadLoose;
const sig = JSON.stringify({ const sig = JSON.stringify({
t: lastEvent.type, t: lastEvent.type,
tid: lastEvent.payload?.teamId ?? null, tid: typeof p.teamId === 'string' ? p.teamId : null,
jp: lastEvent.payload?.joinPolicy ?? null, jp: typeof p.joinPolicy === 'string' ? p.joinPolicy : null,
f: lastEvent.payload?.filename ?? null, f: typeof p.filename === 'string' ? p.filename : null,
v: lastEvent.payload?.version ?? null, v: typeof p.version === 'string' ? p.version : null,
}) });
if (lastSigRef.current === sig) return if (lastSigRef.current === sig) return
lastSigRef.current = sig lastSigRef.current = sig
const { type, payload } = lastEvent const { type } = lastEvent
// payload hier erneut „locker“ casten
const payload = (lastEvent.payload ?? {}) as SsePayloadLoose
const teamId = typeof payload.teamId === 'string' ? payload.teamId : undefined
if (TEAM_EVENTS.has(type)) { if (TEAM_EVENTS.has(type)) {
if (!payload?.teamId || teamsRef.current.some(t => t.id === payload.teamId)) { if (!teamId || teamsRef.current.some(t => t.id === teamId)) {
fetchTeamsAndInvitations() fetchTeamsAndInvitations()
} }
return return
@ -127,7 +154,8 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
const visibleTeams = useMemo(() => { const visibleTeams = useMemo(() => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
let list = q ? teams.filter(t => (t.name ?? '').toLowerCase().includes(q)) : teams.slice() const base = q ? teams.filter(t => (t.name ?? '').toLowerCase().includes(q)) : teams.slice()
const list = base // <- wird nicht neu zugewiesen, daher const
if (sortBy === 'name-asc') { if (sortBy === 'name-asc') {
list.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', 'de', { sensitivity: 'base' })) list.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', 'de', { sensitivity: 'base' }))
} else { } else {
@ -168,7 +196,7 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
value={sortBy} value={sortBy}
onChange={e => setSortBy(e.target.value as any)} onChange={e => setSortBy(e.target.value as 'name-asc' | 'members-desc')}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm" className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
> >
<option value="name-asc">Sortieren: Name (AZ)</option> <option value="name-asc">Sortieren: Name (AZ)</option>
@ -212,12 +240,14 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
team={team} team={team}
currentUserSteamId={currentSteamId} currentUserSteamId={currentSteamId}
invitationId={teamToInvitationId[team.id]} invitationId={teamToInvitationId[team.id]}
onUpdateInvitation={(teamId, newValue) => { onUpdateInvitation={(teamId: string, newValue: string | null) => {
setTeamToInvitationId(prev => { setTeamToInvitationId(prev => {
const updated = { ...prev } const updated: Record<string, string> = { ...prev }
if (!newValue) delete updated[teamId] if (newValue === null) {
else if (newValue === 'pending') updated[teamId] = updated[teamId] ?? 'pending' delete updated[teamId]
else updated[teamId] = newValue } else {
updated[teamId] = newValue
}
return updated return updated
}) })
}} }}

View File

@ -7,7 +7,7 @@ import NotificationCenter from './NotificationCenter'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { NOTIFICATION_EVENTS, isSseEventType } from '@/lib/sseEvents' import { NOTIFICATION_EVENTS } from '@/lib/sseEvents'
import { useGameBannerStore } from '@/lib/useGameBannerStore' import { useGameBannerStore } from '@/lib/useGameBannerStore'
type Notification = { type Notification = {
@ -23,22 +23,43 @@ type ActionData =
| { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string } | { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
| { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string } | { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
// --- API Helper --- type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
async function apiJSON(url: string, body?: any, method = 'POST') { async function apiJSON<T = unknown>(url: string, body?: unknown, method: HttpMethod = 'POST'): Promise<T> {
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined, body: body != null ? JSON.stringify(body) : undefined,
}) })
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText)) if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
return res.json().catch(() => ({})) return res.json().catch(() => ({} as T))
} }
type ApiNotification = {
id: string
message: string
read: boolean
actionType?: string
actionData?: string
createdAt?: string
}
type NotificationsResponse = { notifications: ApiNotification[] }
type SsePayload = {
id?: string;
message?: string;
type?: string;
actionType?: string;
actionData?: string;
createdAt?: string;
invitationId?: string;
teamId?: string;
};
export default function NotificationBell() { export default function NotificationBell() {
const { data: session } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
const bellRef = useRef<HTMLButtonElement | null>(null); const bellRef = useRef<HTMLButtonElement | null>(null)
const telemetryBannerPx = useGameBannerStore(s => s.gameBannerPx) const telemetryBannerPx = useGameBannerStore(s => s.gameBannerPx)
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
@ -50,36 +71,6 @@ export default function NotificationBell() {
const baseBottom = 24 // px, entspricht bottom-6 const baseBottom = 24 // px, entspricht bottom-6
const bottomPx = baseBottom + (telemetryBannerPx || 0) const bottomPx = baseBottom + (telemetryBannerPx || 0)
useEffect(() => {
if (!lastEvent) return
if (!isSseEventType(lastEvent.type)) return
const data = lastEvent.payload
// ⬅️ Einladung zurückgezogen: betroffene Notifications entfernen und abbrechen
if (lastEvent.type === 'team-invite-revoked') {
const invId = data?.invitationId as string | undefined
const teamId = data?.teamId as string | undefined
setNotifications(prev =>
prev.filter(n => {
const isInvite = n.actionType === 'team-invite' || n.actionType === 'invitation'
if (!isInvite) return true
if (invId) return n.actionData !== invId && n.id !== invId
if (teamId) return n.actionData !== teamId
return true
})
)
return
}
// Nur Events, die wir als sichtbare Notifications zeigen wollen
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
if (data?.type === 'heartbeat') return
const msg = (data?.message ?? '').trim()
if (!msg) return
}, [lastEvent])
// 1) Initial laden // 1) Initial laden
useEffect(() => { useEffect(() => {
const steamId = session?.user?.steamId const steamId = session?.user?.steamId
@ -88,8 +79,8 @@ export default function NotificationBell() {
try { try {
const res = await fetch('/api/notifications') const res = await fetch('/api/notifications')
if (!res.ok) throw new Error('Fehler beim Laden') if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json() const data: NotificationsResponse = await res.json()
const loaded: Notification[] = data.notifications.map((n: any) => ({ const loaded: Notification[] = data.notifications.map((n: ApiNotification) => ({
id: n.id, id: n.id,
text: n.message, text: n.message,
read: n.read, read: n.read,
@ -106,11 +97,16 @@ export default function NotificationBell() {
// 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen // 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return;
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
const data = lastEvent.payload // optional: nur Events, die dein Set kennt
if (data?.type === 'heartbeat') return if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return;
// falls du zusätzlich deinen Typeguard nutzen willst:
// if (!isSseEventType(lastEvent.type)) return;
const data = lastEvent.payload as SsePayload | undefined;
if (data?.type === 'heartbeat') return;
const newNotification: Notification = { const newNotification: Notification = {
id: data?.id ?? crypto.randomUUID(), id: data?.id ?? crypto.randomUUID(),
@ -119,11 +115,11 @@ export default function NotificationBell() {
actionType: data?.actionType, actionType: data?.actionType,
actionData: data?.actionData, actionData: data?.actionData,
createdAt: data?.createdAt ?? new Date().toISOString(), createdAt: data?.createdAt ?? new Date().toISOString(),
} };
setNotifications(prev => [newNotification, ...prev]) setNotifications(prev => [newNotification, ...prev]);
setPreviewText(newNotification.text) // <-- nur das hier setPreviewText(newNotification.text);
}, [lastEvent]) }, [lastEvent]);
// 2) Timer separat steuern: triggert bei neuem previewText // 2) Timer separat steuern: triggert bei neuem previewText
useEffect(() => { useEffect(() => {
@ -146,7 +142,6 @@ export default function NotificationBell() {
} }
}, [previewText]) }, [previewText])
// 3) Actions // 3) Actions
const markAllAsRead = async () => { const markAllAsRead = async () => {
await apiJSON('/api/notifications/mark-all-read', undefined, 'POST') await apiJSON('/api/notifications/mark-all-read', undefined, 'POST')
@ -155,9 +150,7 @@ export default function NotificationBell() {
const markOneAsRead = async (notificationId: string) => { const markOneAsRead = async (notificationId: string) => {
await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST') await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST')
setNotifications(prev => setNotifications(prev => prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)))
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)),
)
} }
const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => { const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => {
@ -174,21 +167,19 @@ export default function NotificationBell() {
return return
} }
// actionData parsen: erlaubt JSON {kind, inviteId/requestId, teamId} ODER nackte ID // actionData parsen: erlaubt JSON ActionData ODER nackte ID
let kind: 'invite' | 'join-request' | undefined let kind: ActionData['kind'] | undefined
let invitationId: string | undefined let invitationId: string | undefined
let requestId: string | undefined let requestId: string | undefined
let teamId: string | undefined let teamId: string | undefined
try { try {
const data = JSON.parse(n.actionData) as const data = JSON.parse(n.actionData) as ActionData | string
| { kind?: 'invite' | 'join-request'; inviteId?: string; requestId?: string; teamId?: string }
| string
if (typeof data === 'object' && data) { if (typeof data === 'object' && data) {
kind = data.kind kind = data.kind
invitationId = data.inviteId if (data.kind === 'invite') invitationId = data.inviteId
requestId = data.requestId if (data.kind === 'join-request') requestId = data.requestId
teamId = data.teamId teamId = data.teamId
} else if (typeof data === 'string') { } else if (typeof data === 'string') {
// nackte ID: sowohl als invitationId als auch requestId nutzbar // nackte ID: sowohl als invitationId als auch requestId nutzbar
@ -217,16 +208,11 @@ export default function NotificationBell() {
// Optimistic Update (Buttons ausblenden) // Optimistic Update (Buttons ausblenden)
const snapshot = notifications const snapshot = notifications
setNotifications(prev => setNotifications(prev => prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)))
prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)),
)
try { try {
if (kind === 'invite') { if (kind === 'invite') {
await apiJSON(`/api/user/invitations/${action}`, { await apiJSON('/api/user/invitations/' + action, { invitationId, teamId })
invitationId,
teamId,
})
setNotifications(prev => prev.filter(x => x.id !== n.id)) setNotifications(prev => prev.filter(x => x.id !== n.id))
if (action === 'accept') router.refresh() if (action === 'accept') router.refresh()
return return
@ -251,11 +237,10 @@ export default function NotificationBell() {
} }
} }
const onNotificationClick = (notification: Notification) => { const onNotificationClick = (notification: Notification) => {
if (!notification.actionData) return if (!notification.actionData) return
try { try {
const data = JSON.parse(notification.actionData) const data = JSON.parse(notification.actionData) as Partial<ActionData> & { redirectUrl?: string }
if (data.redirectUrl) router.push(data.redirectUrl) if (data.redirectUrl) router.push(data.redirectUrl)
} catch (err) { } catch (err) {
console.error('[NotificationBell] Ungültige actionData:', err) console.error('[NotificationBell] Ungültige actionData:', err)
@ -264,32 +249,32 @@ export default function NotificationBell() {
// 4) Render // 4) Render
return ( return (
<div <div className="fixed right-6 z-50" style={{ bottom: bottomPx }}>
className="fixed right-6 z-50"
style={{ bottom: bottomPx }}
>
<button <button
ref={bellRef} ref={bellRef}
type="button" type="button"
onMouseDown={(e) => e.stopPropagation()} // verhindert, dass der Outside-Handler denselben Pointer verwendet onMouseDown={e => e.stopPropagation()} // verhindert, dass der Outside-Handler denselben Pointer verwendet
onClick={() => setOpen(prev => !prev)} onClick={() => setOpen(prev => !prev)}
className={`relative flex items-center transition-all duration-300 ease-in-out className={`relative flex items-center transition-all duration-300 ease-in-out
${showPreview ? 'w-[400px] pl-4 pr-11' : 'w-[44px] justify-center'} ${showPreview ? 'w-[400px] pl-4 pr-11' : 'w-[44px] justify-center'}
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`} dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
> >
{previewText && ( {previewText && <span className="truncate text-sm text-gray-800 dark:text-white">{previewText}</span>}
<span className="truncate text-sm text-gray-800 dark:text-white">
{previewText}
</span>
)}
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20"> <div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
<svg <svg
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`} className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} <path
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z" /> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z"
/>
</svg> </svg>
{notifications.some(n => !n.read) && ( {notifications.some(n => !n.read) && (
<span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30"> <span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import Image from 'next/image' import Image from 'next/image'
import { Player, Team } from '../../../types/team' import { Player } from '../../../types/team'
export type CardWidth = export type CardWidth =
| 'sm' // max-w-sm (24rem) | 'sm' // max-w-sm (24rem)
@ -14,14 +14,12 @@ export type CardWidth =
type Props = { type Props = {
player: Player player: Player
team?: Team // aktuell nicht genutzt, aber falls du später z.B. TeamFarbe brauchst
align?: 'left' | 'right' align?: 'left' | 'right'
maxWidth?: CardWidth maxWidth?: CardWidth
} }
export default function PlayerCard({ export default function PlayerCard({
player, player,
team,
align = 'left', align = 'left',
maxWidth = 'sm', maxWidth = 'sm',
}: Props) { }: Props) {

View File

@ -1,7 +1,8 @@
// /src/app/[locale]/components/ReadyOverlayHost.tsx
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import MatchReadyOverlay from './MatchReadyOverlay' import MatchReadyOverlay from './MatchReadyOverlay'
@ -16,11 +17,12 @@ async function getConnectHref(matchId?: string): Promise<string | null> {
try { try {
const qs = matchId ? `?matchId=${encodeURIComponent(matchId)}` : '' const qs = matchId ? `?matchId=${encodeURIComponent(matchId)}` : ''
const r = await fetch(`/api/cs2/server${qs}`, { cache: 'no-store' }) const r = await fetch(`/api/cs2/server${qs}`, { cache: 'no-store' })
if (!r.ok) { if (!r.ok) return null
return null const j = (await r.json()) as unknown
} const href =
const j = await r.json() (j && typeof j === 'object' && 'connectHref' in j
const href: string | undefined = j?.connectHref ? (j as Record<string, unknown>).connectHref
: undefined) as string | undefined
if (href) CONNECT_CACHE.set(matchId, href) if (href) CONNECT_CACHE.set(matchId, href)
return href ?? null return href ?? null
} catch { } catch {
@ -28,16 +30,89 @@ async function getConnectHref(matchId?: string): Promise<string | null> {
} }
} }
/* ----------------- Typen & Guards ----------------- */
type PlayerLite = { steamId?: string }
type TeamSide = { players?: PlayerLite[] }
type TeamBlock = { teamA?: TeamSide; teamB?: TeamSide }
type Step = { action?: string; map?: string }
type MapVisual = { label?: string; bg?: string }
type MapVoteUpdatedPayload = {
matchId?: string
locked?: boolean
bestOf?: number
steps?: Step[]
mapVisuals?: Record<string, MapVisual>
teams?: TeamBlock
}
type MatchReadyPayload = {
matchId?: string
firstMap?: { label?: string; bg?: string }
participants?: string[]
}
type SseLike = {
type?: string
payload?: unknown
ts?: number
}
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null
const getPayload = (evt: unknown): unknown =>
isObject(evt) && 'payload' in evt ? (evt as { payload?: unknown }).payload : evt
const isStringArray = (v: unknown): v is string[] =>
Array.isArray(v) && v.every(x => typeof x === 'string')
const asPlayerList = (v: unknown): PlayerLite[] =>
Array.isArray(v) ? (v.filter(p => isObject(p)) as PlayerLite[]) : []
/* ---------- Aus 'map-vote-updated' minimal Summary ableiten ---------- */
function deriveReadySummary(payload: unknown) {
if (!isObject(payload)) return null
const p = payload as MapVoteUpdatedPayload
const matchId = p.matchId
if (!matchId) return null
const locked = !!p.locked
const bestOf = typeof p.bestOf === 'number' ? p.bestOf : 3
const steps = Array.isArray(p.steps) ? p.steps : []
const mapVisuals = isObject(p.mapVisuals) ? (p.mapVisuals as Record<string, MapVisual>) : {}
// Teilnehmer extrahieren
const teamA = isObject(p.teams) && isObject(p.teams.teamA) ? (p.teams.teamA as TeamSide) : undefined
const teamB = isObject(p.teams) && isObject(p.teams.teamB) ? (p.teams.teamB as TeamSide) : undefined
const playersA = asPlayerList(teamA?.players)
const playersB = asPlayerList(teamB?.players)
const participants = [
...playersA.map(pl => pl.steamId).filter((s): s is string => typeof s === 'string'),
...playersB.map(pl => pl.steamId).filter((s): s is string => typeof s === 'string'),
]
const chosen = steps.filter(s => (s?.action === 'pick' || s?.action === 'decider') && s?.map)
const allChosen = locked && chosen.length >= bestOf
if (!allChosen) return null
const first = chosen[0]
const key = first?.map
const label = key ? (mapVisuals[key]?.label ?? key) : '?'
const bg = key ? (mapVisuals[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp'
return { matchId, firstMap: { key, label, bg }, participants }
}
export default function ReadyOverlayHost() { export default function ReadyOverlayHost() {
const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const mySteamId = session?.user?.steamId ?? null const mySteamId = (session?.user as { steamId?: string } | undefined)?.steamId ?? null
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const { open, data, showWithDelay, hide } = useReadyOverlayStore() const { open, data, showWithDelay, hide } = useReadyOverlayStore()
const setRoster = useMatchRosterStore(s => s.setRoster)
const setRoster = useMatchRosterStore(s => s.setRoster) // ⬅️ neu const clearRoster = useMatchRosterStore(s => s.clearRoster)
const clearRoster = useMatchRosterStore(s => s.clearRoster) // ⬅️ neu
const isAccepted = (matchId: string) => const isAccepted = (matchId: string) =>
typeof window !== 'undefined' && typeof window !== 'undefined' &&
@ -49,59 +124,26 @@ export default function ReadyOverlayHost() {
} }
} }
// Aus 'map-vote-updated' minimal Summary ableiten (Map 1 + Teilnehmer)
function deriveReadySummary(payload: any) {
const matchId: string | undefined = payload?.matchId
if (!matchId) return null
const locked = !!payload?.locked
const bestOf = payload?.bestOf ?? 3
const steps: any[] = Array.isArray(payload?.steps) ? payload.steps : []
const mapVisuals = payload?.mapVisuals ?? {}
const playersA = payload?.teams?.teamA?.players ?? []
const playersB = payload?.teams?.teamB?.players ?? []
const participants: string[] = [
...playersA.map((p: any) => p?.steamId).filter(Boolean),
...playersB.map((p: any) => p?.steamId).filter(Boolean),
]
const chosen = steps.filter(
(s) => (s.action === 'pick' || s.action === 'decider') && s.map
)
const allChosen = locked && chosen.length >= bestOf
if (!allChosen) return null
const first = chosen[0]
const key = first?.map
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
const bg = key
? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`)
: '/assets/img/maps/cs2.webp'
return { matchId, firstMap: { key, label, bg }, participants }
}
// Events: 'match-ready' & 'map-vote-updated' // Events: 'match-ready' & 'map-vote-updated'
useEffect(() => { useEffect(() => {
if (!lastEvent || !mySteamId) return if (!lastEvent || !mySteamId) return
const evt = (lastEvent as any).payload ?? lastEvent // ⬅️ robust gegen beide Formen const evt = lastEvent as SseLike
const payload = getPayload(evt)
if (lastEvent.type === 'match-ready') { if (evt.type === 'match-ready') {
(async () => { ;(async () => {
const m: string | undefined = evt?.matchId const p = isObject(payload) ? (payload as MatchReadyPayload) : {}
const participants: string[] = evt?.participants ?? [] const m = p.matchId
const participants = isStringArray(p.participants) ? p.participants : []
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
setRoster(participants) // ✅ wird jetzt sicher gesetzt setRoster(participants)
const label = evt?.firstMap?.label ?? '?' const label = p.firstMap?.label ?? '?'
const bg = evt?.firstMap?.bg ?? '/assets/img/maps/cs2.webp' const bg = p.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
const connectHref = const connectHref =
(await getConnectHref(m)) || (await getConnectHref(m)) || process.env.NEXT_PUBLIC_CONNECT_HREF || null
process.env.NEXT_PUBLIC_CONNECT_HREF ||
null
showWithDelay( showWithDelay(
{ {
@ -117,9 +159,9 @@ export default function ReadyOverlayHost() {
return return
} }
if (lastEvent.type === 'map-vote-updated') { if (evt.type === 'map-vote-updated') {
(async () => { ;(async () => {
const summary = deriveReadySummary(evt) // evt statt lastEvent.payload const summary = deriveReadySummary(payload)
if (!summary) return if (!summary) return
const { matchId: m, firstMap, participants } = summary const { matchId: m, firstMap, participants } = summary
if (!participants.includes(mySteamId) || isAccepted(m)) return if (!participants.includes(mySteamId) || isAccepted(m)) return
@ -127,9 +169,7 @@ export default function ReadyOverlayHost() {
setRoster(participants) setRoster(participants)
const connectHref = const connectHref =
(await getConnectHref(m)) || (await getConnectHref(m)) || process.env.NEXT_PUBLIC_CONNECT_HREF || null
process.env.NEXT_PUBLIC_CONNECT_HREF ||
null
showWithDelay( showWithDelay(
{ {
@ -149,11 +189,11 @@ export default function ReadyOverlayHost() {
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
if (lastEvent.type === 'map-vote-reset') { if (lastEvent.type === 'map-vote-reset') {
const m = lastEvent.payload?.matchId const m = (lastEvent.payload as { matchId?: string } | undefined)?.matchId
if (m && typeof window !== 'undefined') { if (m && typeof window !== 'undefined') {
window.localStorage.removeItem(`match:${m}:readyAccepted`) window.localStorage.removeItem(`match:${m}:readyAccepted`)
} }
clearRoster() // ⬅️ neu clearRoster()
if (open) hide() if (open) hide()
} }
}, [lastEvent, open, hide, clearRoster]) }, [lastEvent, open, hide, clearRoster])

View File

@ -1,5 +1,6 @@
// /src/app/[locale]/components/ScrollSpyTabs.tsx
'use client' 'use client'
import {useEffect, useRef, useState, type RefObject} from 'react' import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
export type SpyItem = { id: string; label: string } export type SpyItem = { id: string; label: string }
@ -14,6 +15,14 @@ type Props = {
smoothMs?: number smoothMs?: number
} }
/* ===== stabile Helper außerhalb der Komponente ===== */
const escapeId = (id: string) =>
id.replace(/([ !"#$%&'()*+,.\/:;<=>?@[\]^`{|}~\\])/g, '\\$1')
const qs = (root: Document | HTMLElement, id: string) =>
root.querySelector<HTMLElement>(`#${escapeId(id)}`)
/* =================================================== */
export default function ScrollSpyTabs({ export default function ScrollSpyTabs({
items, items,
containerRef, containerRef,
@ -29,20 +38,17 @@ export default function ScrollSpyTabs({
const isProgrammaticRef = useRef(false) const isProgrammaticRef = useRef(false)
const progTimerRef = useRef<number | null>(null) const progTimerRef = useRef<number | null>(null)
const setActive = (id: string) => { const setActive = useCallback(
(id: string) => {
if (id && id !== activeId) { if (id && id !== activeId) {
setActiveId(id) setActiveId(id)
if (updateHash) history.replaceState(null, '', `#${id}`) if (updateHash) history.replaceState(null, '', `#${id}`)
} }
} },
[activeId, updateHash]
)
// sichere Query /* -------- IntersectionObserver -------- */
const qs = (root: Document | HTMLElement, id: string) => {
const esc = (window as any).CSS?.escape?.(id) ?? id.replace(/([ #.;?+*~\\':"!^$[\]()=>|\/@])/g, '\\$1')
return root.querySelector<HTMLElement>(`#${esc}`)
}
/* -------- IntersectionObserver: „mittlere“ Logik -------- */
useEffect(() => { useEffect(() => {
const rootEl = containerRef?.current ?? null const rootEl = containerRef?.current ?? null
const rootNode: Document | HTMLElement = rootEl ?? document const rootNode: Document | HTMLElement = rootEl ?? document
@ -55,23 +61,24 @@ export default function ScrollSpyTabs({
() => { () => {
if (isProgrammaticRef.current) return if (isProgrammaticRef.current) return
// ⬇️ Top/Bottom bevorzugen
const firstId = items[0]?.id const firstId = items[0]?.id
const lastId = items[items.length - 1]?.id const lastId = items[items.length - 1]?.id
const EPS = 1 const EPS = 1
if (rootEl) { if (rootEl) {
const atTop = rootEl.scrollTop <= EPS const atTop = rootEl.scrollTop <= EPS
const atBottom = Math.ceil(rootEl.scrollTop + rootEl.clientHeight) >= rootEl.scrollHeight - EPS const atBottom =
if (atTop && firstId) { setActive(firstId); return } Math.ceil(rootEl.scrollTop + rootEl.clientHeight) >= rootEl.scrollHeight - EPS
if (atBottom && lastId){ setActive(lastId); return } if (atTop && firstId) return setActive(firstId)
if (atBottom && lastId) return setActive(lastId)
} else { } else {
const atTop = window.scrollY <= EPS const atTop = window.scrollY <= EPS
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - EPS const atBottom =
if (atTop && firstId) { setActive(firstId); return } Math.ceil(window.scrollY + window.innerHeight) >=
if (atBottom && lastId){ setActive(lastId); return } document.documentElement.scrollHeight - EPS
if (atTop && firstId) return setActive(firstId)
if (atBottom && lastId) return setActive(lastId)
} }
// „mittlere Linie“-Logik
const rootTop = rootEl ? rootEl.getBoundingClientRect().top : 0 const rootTop = rootEl ? rootEl.getBoundingClientRect().top : 0
const targetLine = rootTop + offset + 1 const targetLine = rootTop + offset + 1
@ -93,13 +100,12 @@ export default function ScrollSpyTabs({
sections.forEach(s => observerRef.current!.observe(s)) sections.forEach(s => observerRef.current!.observe(s))
return () => observerRef.current?.disconnect() return () => observerRef.current?.disconnect()
// ⬇️ WICHTIG: auf das ELEMENt hören, nicht nur auf das Ref-Objekt }, [containerRef, items, offset, setActive])
}, [containerRef?.current, items, offset, updateHash])
/* -------- NEU: Kanten-Logik (Top/Bottom bevorzugen) -------- */ /* -------- Kanten-Logik (Top/Bottom bevorzugen) -------- */
useEffect(() => { useEffect(() => {
const el = containerRef?.current const el = containerRef?.current
const target: any = el ?? window const target: Window | HTMLElement = el ?? window
const firstId = items[0]?.id const firstId = items[0]?.id
const lastId = items[items.length - 1]?.id const lastId = items[items.length - 1]?.id
const EPS = 1 const EPS = 1
@ -110,27 +116,29 @@ export default function ScrollSpyTabs({
if (el) { if (el) {
const atTop = el.scrollTop <= EPS const atTop = el.scrollTop <= EPS
const atBottom = Math.ceil(el.scrollTop + el.clientHeight) >= el.scrollHeight - EPS const atBottom =
Math.ceil(el.scrollTop + el.clientHeight) >= el.scrollHeight - EPS
if (atTop) return setActive(firstId) if (atTop) return setActive(firstId)
if (atBottom) return setActive(lastId) if (atBottom) return setActive(lastId)
} else { } else {
const atTop = window.scrollY <= EPS const atTop = window.scrollY <= EPS
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - EPS const atBottom =
Math.ceil(window.scrollY + window.innerHeight) >=
document.documentElement.scrollHeight - EPS
if (atTop) return setActive(firstId) if (atTop) return setActive(firstId)
if (atBottom) return setActive(lastId) if (atBottom) return setActive(lastId)
} }
} }
target.addEventListener('scroll', onScrollOrResize, { passive: true }) target.addEventListener('scroll', onScrollOrResize, { passive: true } as AddEventListenerOptions)
window.addEventListener('resize', onScrollOrResize, { passive: true }) window.addEventListener('resize', onScrollOrResize, { passive: true } as AddEventListenerOptions)
onScrollOrResize() onScrollOrResize()
return () => { return () => {
target.removeEventListener('scroll', onScrollOrResize) target.removeEventListener('scroll', onScrollOrResize as EventListener)
window.removeEventListener('resize', onScrollOrResize) window.removeEventListener('resize', onScrollOrResize as EventListener)
} }
// ⬇️ ebenfalls auf das Element selbst hören }, [containerRef, items, setActive])
}, [containerRef?.current, items])
/* -------- programmatic scroll (Tabs-Klick) -------- */ /* -------- programmatic scroll (Tabs-Klick) -------- */
const onJump = (id: string) => { const onJump = (id: string) => {
@ -161,15 +169,17 @@ export default function ScrollSpyTabs({
return ( return (
<nav className={className} aria-label="Section navigation" role="tablist" aria-orientation="vertical"> <nav className={className} aria-label="Section navigation" role="tablist" aria-orientation="vertical">
{items.map(it => { {items.map(it => {
const isActive = activeId === it.id const isCurrent = activeId === it.id
return ( return (
<button <button
key={it.id} key={it.id}
type="button" type="button"
role="tab" role="tab"
aria-selected={isActive} aria-selected={isCurrent}
onClick={() => onJump(it.id)} onClick={() => onJump(it.id)}
className={`text-left py-2 px-3 rounded-lg transition-colors ${isActive ? activeClassName : inactiveClassName}`} className={`text-left py-2 px-3 rounded-lg transition-colors ${
isCurrent ? activeClassName : inactiveClassName
}`}
> >
{it.label} {it.label}
</button> </button>

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useMemo } from 'react' import { useState, useMemo, useCallback } from 'react'
import { useRouter, usePathname } from '@/i18n/navigation' import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl' import { useTranslations, useLocale } from 'next-intl'
import Button from './Button' import Button from './Button'
@ -23,7 +23,7 @@ export default function Sidebar() {
const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null) const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null)
// Aktive Route prüfen (pathname kommt schon ohne Locale) // Aktive Route prüfen (pathname kommt schon ohne Locale)
const isActive = (path: string) => pathname === path const isActive = useCallback((path: string) => pathname === path, [pathname])
const navBtnBase = const navBtnBase =
'w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors' 'w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors'
@ -38,12 +38,11 @@ export default function Sidebar() {
setOpenSubmenu(prev => (prev === key ? null : key)) setOpenSubmenu(prev => (prev === key ? null : key))
// ✅ Locale-Wechsel: gleiche Route behalten, nur Locale ändern // ✅ Locale-Wechsel: gleiche Route behalten, nur Locale ändern
const changeLocale = (nextLocale: 'en' | 'de') => { const changeLocale = useCallback((nextLocale: 'en' | 'de') => {
if (nextLocale === locale) return if (nextLocale === locale) return
// pathname ist z.B. '/dashboard' next-intl setzt das Locale
router.replace(pathname, { locale: nextLocale }) router.replace(pathname, { locale: nextLocale })
setIsOpen(false) setIsOpen(false)
} }, [router, pathname, locale])
// Gemeinsamer Inhalt (wird in Desktop-Aside und im Mobile-Drawer benutzt) // Gemeinsamer Inhalt (wird in Desktop-Aside und im Mobile-Drawer benutzt)
const SidebarInner = useMemo( const SidebarInner = useMemo(
@ -70,10 +69,10 @@ export default function Sidebar() {
{/* Dashboard */} {/* Dashboard */}
<li> <li>
<Button <Button
onClick={() => { router.push('/dashboard'); setIsOpen(false) }} onClick={() => { router.push('/'); setIsOpen(false) }}
size="sm" size="sm"
variant="link" variant="link"
className={`${navBtnBase} ${isActive('/dashboard') ? activeClasses : idleClasses}`} className={`${navBtnBase} ${isActive('/') ? activeClasses : idleClasses}`}
aria-label={tNav('dashboard')} aria-label={tNav('dashboard')}
> >
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@ -248,7 +247,7 @@ export default function Sidebar() {
</footer> </footer>
</div> </div>
), ),
[pathname, openSubmenu, locale, tNav, tSidebar] [openSubmenu, locale, tNav, tSidebar, isActive, changeLocale, router]
) )
return ( return (

View File

@ -1,40 +1,44 @@
// /src/app/[locale]]/components/SidebarFooter.ts // /src/app/[locale]/components/SidebarFooter.tsx
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSession, signIn, signOut } from 'next-auth/react' import { useSession, signIn } from 'next-auth/react'
import { useTranslations, useLocale } from 'next-intl' import { useTranslations } from 'next-intl'
import { signOutWithStatus } from '@/lib/signOutWithStatus' import { signOutWithStatus } from '@/lib/signOutWithStatus'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import Image from 'next/image'
import LoadingSpinner from '../components/LoadingSpinner' import LoadingSpinner from '../components/LoadingSpinner'
import Button from './Button' import Button from './Button'
import UserAvatarWithStatus from './UserAvatarWithStatus' import UserAvatarWithStatus from './UserAvatarWithStatus'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
type SessUser = {
steamId?: string | null
name?: string | null
image?: string | null
avatar?: string | null
isAdmin?: boolean
}
export default function SidebarFooter() { export default function SidebarFooter() {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Übersetzungen
const tSidebar = useTranslations('sidebar') const tSidebar = useTranslations('sidebar')
const { data: session, status } = useSession() const { data: session, status } = useSession()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [teamName, setTeamName] = useState<string | null>(null) const [teamName, setTeamName] = useState<string | null>(null)
const [premierRank, setPremierRank] = useState<number>(0) const [premierRank, setPremierRank] = useState<number>(0)
// ➜ Nach Login: User aus DB laden (inkl. premierRank & Teamname) // Userdetails laden
useEffect(() => { useEffect(() => {
if (status !== 'authenticated') { if (status !== 'authenticated') {
setTeamName(null) setTeamName(null)
setPremierRank(0) // ← immer 0, nicht null setPremierRank(0)
return return
} }
(async () => { ;(async () => {
try { try {
const res = await fetch('/api/user', { cache: 'no-store' }) const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return if (!res.ok) return
@ -54,7 +58,7 @@ export default function SidebarFooter() {
if (status === 'unauthenticated') { if (status === 'unauthenticated') {
return ( return (
<button <button
onClick={() => signIn('steam', { callbackUrl: `/dashboard` })} onClick={() => signIn('steam', { callbackUrl: `/` })}
className="flex items-center justify-center gap-2 w-full py-4 px-6 bg-green-800 text-white text-md font-medium hover:bg-green-900 transition" className="flex items-center justify-center gap-2 w-full py-4 px-6 bg-green-800 text-white text-md font-medium hover:bg-green-900 transition"
> >
<i className="fab fa-steam" /> <i className="fab fa-steam" />
@ -63,9 +67,10 @@ export default function SidebarFooter() {
) )
} }
const subline = teamName ?? session?.user?.steamId const u = session?.user as SessUser | undefined
const userName = session?.user?.name || 'Profil' const subline = teamName ?? u?.steamId ?? undefined
const avatarSrc = (session?.user as any)?.avatar || session?.user?.image || '/default-avatar.png' const userName = u?.name || 'Profil'
const avatarSrc = u?.avatar ?? u?.image ?? '/default-avatar.png'
const linkClass = (active: boolean) => const linkClass = (active: boolean) =>
`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors ${ `flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors ${
@ -89,7 +94,7 @@ export default function SidebarFooter() {
src={avatarSrc} src={avatarSrc}
alt={userName} alt={userName}
size={30} size={30}
steamId={session?.user?.steamId} steamId={u?.steamId ?? undefined}
/> />
<div className="ms-3 flex-1 min-w-0"> <div className="ms-3 flex-1 min-w-0">
<h3 className="font-semibold text-gray-800 dark:text-white truncate"> <h3 className="font-semibold text-gray-800 dark:text-white truncate">
@ -100,13 +105,11 @@ export default function SidebarFooter() {
</p> </p>
</div> </div>
{/* Badge darf nicht schrumpfen */}
<div className="ml-2 flex-shrink-0"> <div className="ml-2 flex-shrink-0">
<PremierRankBadge rank={premierRank} /> <PremierRankBadge rank={premierRank} />
</div> </div>
</div> </div>
{/* Pfeil ebenfalls nicht schrumpfen */}
<svg <svg
className={`ms-2 size-4 shrink-0 ${isOpen ? 'rotate-180' : ''} text-gray-600 dark:text-neutral-400`} className={`ms-2 size-4 shrink-0 ${isOpen ? 'rotate-180' : ''} text-gray-600 dark:text-neutral-400`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -130,10 +133,10 @@ export default function SidebarFooter() {
> >
<div className="p-2 flex flex-col gap-1"> <div className="p-2 flex flex-col gap-1">
<Button <Button
onClick={() => router.push(`/profile/${session?.user?.steamId}`)} onClick={() => router.push(`/profile/${u?.steamId ?? ''}`)}
size="sm" size="sm"
variant="link" variant="link"
className={linkClass(pathname === `/profile/${session?.user?.steamId}`)} className={linkClass(pathname === `/profile/${u?.steamId ?? ''}`)}
> >
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" <svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -168,7 +171,7 @@ export default function SidebarFooter() {
{tSidebar('footer.settings')} {tSidebar('footer.settings')}
</Button> </Button>
{session?.user?.isAdmin && ( {u?.isAdmin && (
<Button <Button
onClick={() => router.push('/admin')} onClick={() => router.push('/admin')}
size="sm" size="sm"

View File

@ -1,7 +1,5 @@
'use client' 'use client'
import Link from 'next/link'
type TabProps = { type TabProps = {
name: string name: string
href: string href: string

View File

@ -1,10 +1,10 @@
// /src/app/[locale]/components/Tabs.tsx // /src/app/[locale]/components/Tabs.tsx
'use client' 'use client'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { ReactNode, ReactElement } from 'react' import type { ReactNode, FC } from 'react'
import { Children, type ReactElement } from 'react';
export type TabProps = { export type TabProps = {
name: string name: string
@ -13,47 +13,47 @@ export type TabProps = {
type TabsProps = { type TabsProps = {
children: ReactNode children: ReactNode
/** optional kontrollierter Modus */
value?: string value?: string
onChange?: (name: string) => void onChange?: (name: string) => void
/** Ausrichtung */
orientation?: 'horizontal' | 'vertical' orientation?: 'horizontal' | 'vertical'
/** optional: Styling */
className?: string className?: string
tabClassName?: string tabClassName?: string
} }
// ── add a component type that has a static Tab
type TabsComponent = FC<TabsProps> & { Tab: FC<TabProps> }
function normalize(path: string) { function normalize(path: string) {
if (!path) return '/' if (!path) return '/'
const v = path.replace(/\/+$/, '') const v = path.replace(/\/+$/, '')
return v === '' ? '/' : v return v === '' ? '/' : v
} }
export function Tabs({ function isTabElement(v: unknown): v is ReactElement<TabProps> {
if (typeof v !== 'object' || v === null) return false;
if (!('props' in v)) return false;
const propsUnknown = (v as { props: unknown }).props;
if (typeof propsUnknown !== 'object' || propsUnknown === null) return false;
const props = propsUnknown as Record<string, unknown>;
return typeof props.href === 'string' && typeof props.name === 'string';
}
// implement as base function
const TabsBase: FC<TabsProps> = ({
children, children,
value, value,
onChange, onChange,
orientation = 'horizontal', orientation = 'horizontal',
className = '', className = '',
tabClassName = '' tabClassName = ''
}: TabsProps) { }) => {
const pathname = usePathname() const pathname = usePathname()
// Kinder in gültige Tab-Elemente filtern const rawTabs = Children.toArray(children);
const rawTabs = Array.isArray(children) ? children : [children] const tabs = rawTabs.filter(isTabElement);
const tabs = rawTabs.filter(
(tab): tab is ReactElement<TabProps> =>
tab !== null &&
typeof tab === 'object' &&
'props' in tab &&
typeof tab.props.href === 'string' &&
typeof tab.props.name === 'string'
)
const isVertical = orientation === 'vertical' const isVertical = orientation === 'vertical'
const current = normalize(pathname) const current = normalize(pathname)
// Liste aller Tab-URLs (normalisiert) für die Heuristik
const hrefs = tabs.map(t => normalize(t.props.href)) const hrefs = tabs.map(t => normalize(t.props.href))
return ( return (
@ -68,10 +68,8 @@ export function Tabs({
aria-orientation={isVertical ? 'vertical' : 'horizontal'} aria-orientation={isVertical ? 'vertical' : 'horizontal'}
> >
{tabs.map((tab, index) => { {tabs.map((tab, index) => {
const baseClasses = const baseClasses = 'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
// Kontrollierter Modus: Auswahl über value/onChange
if (onChange && value !== undefined) { if (onChange && value !== undefined) {
const isActive = value === tab.props.name const isActive = value === tab.props.name
return ( return (
@ -82,9 +80,7 @@ export function Tabs({
role="tab" role="tab"
aria-selected={isActive} aria-selected={isActive}
className={ className={
baseClasses + baseClasses + ' ' + (isActive
' ' +
(isActive
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white' ? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700') : 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
} }
@ -94,16 +90,9 @@ export function Tabs({
) )
} }
// Unkontrollierter Modus: Link-basiert
const base = normalize(tab.props.href) const base = normalize(tab.props.href)
// Hat dieser Tab "tiefere" Geschwister? (z.B. /profile/... und /profile/.../matches)
const hasSiblingDeeper = hrefs.some(h => h !== base && h.startsWith(base + '/')) const hasSiblingDeeper = hrefs.some(h => h !== base && h.startsWith(base + '/'))
// Nur wenn es KEINEN tieferen Sibling gibt, erlauben wir Prefix-Matching.
// Dadurch ist /profile/... NICHT aktiv, wenn /profile/.../matches aktiv ist.
const allowStartsWith = !hasSiblingDeeper const allowStartsWith = !hasSiblingDeeper
const isActive = current === base || (allowStartsWith && current.startsWith(base + '/')) const isActive = current === base || (allowStartsWith && current.startsWith(base + '/'))
return ( return (
@ -113,9 +102,7 @@ export function Tabs({
role="tab" role="tab"
aria-selected={isActive} aria-selected={isActive}
className={ className={
baseClasses + baseClasses + ' ' + (isActive
' ' +
(isActive
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white' ? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700') : 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
} }
@ -128,6 +115,8 @@ export function Tabs({
) )
} }
Tabs.Tab = function Tab(_props: TabProps) { // the dummy Tab element (for nicer JSX usage)
return null const Tab: FC<TabProps> = () => null
}
// ── export Tabs with a typed static property
export const Tabs: TabsComponent = Object.assign(TabsBase, { Tab })

View File

@ -1,15 +1,13 @@
// /src/app/components/TeamCard.tsx // /src/app/[locale]/components/TeamCard.tsx
'use client' 'use client'
import { useState, useMemo, useEffect, useRef } from 'react' import { useState, useMemo, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Button from './Button' import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
import type { Team, TeamJoinPolicy } from '../../../types/team' import type { Team, TeamJoinPolicy, Player } from '../../../types/team'
// ⬇️ NEU: SSE-Hooks / Type-Guard
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { isSseEventType } from '@/lib/sseEvents'
type Props = { type Props = {
team: Team team: Team
@ -17,9 +15,30 @@ type Props = {
invitationId?: string invitationId?: string
onUpdateInvitation: (teamId: string, newValue: string | null | 'pending') => void onUpdateInvitation: (teamId: string, newValue: string | null | 'pending') => void
adminMode?: boolean adminMode?: boolean
/** Falls false, ist Anfragen grundsätzlich deaktiviert (z. B. User ist bereits in einem Team) */
canRequestJoin?: boolean canRequestJoin?: boolean
} }
type ButtonColor = 'blue' | 'red' | 'gray'
type MemberLike = Pick<Player, 'steamId' | 'name' | 'avatar'>
/* === Helper nach oben (außerhalb der Komponente) ================= */
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null
const extractPayloadObject = (raw: unknown): Record<string, unknown> => {
if (!isRecord(raw)) return {}
if ('payload' in raw && isRecord((raw as Record<string, unknown>).payload)) {
return (raw as { payload: Record<string, unknown> }).payload
}
return raw
}
const asJoinPolicy = (v: unknown): TeamJoinPolicy | undefined =>
v === 'REQUEST' || v === 'INVITE_ONLY' ? v : undefined
/* ================================================================ */
export default function TeamCard({ export default function TeamCard({
team, team,
currentUserSteamId, currentUserSteamId,
@ -31,14 +50,12 @@ export default function TeamCard({
const router = useRouter() const router = useRouter()
const [joining, setJoining] = useState(false) const [joining, setJoining] = useState(false)
// ⬇️ NEU: SSE // SSE
const { connect, lastEvent, isConnected } = useSSEStore() const { connect, lastEvent, isConnected } = useSSEStore()
// ⬇️ NEU: lokale, wirksame Policy startet mit Prop // lokale, wirksame Policy
const [effectivePolicy, setEffectivePolicy] = useState<TeamJoinPolicy>(team.joinPolicy) const [effectivePolicy, setEffectivePolicy] = useState<TeamJoinPolicy>(team.joinPolicy)
const sseWinsUntil = useRef(0) const sseWinsUntil = useRef(0)
const lastHandledKeyRef = useRef('')
const lastSeenTsRef = useRef<number | null>(null) const lastSeenTsRef = useRef<number | null>(null)
// SSE-Verbindung herstellen // SSE-Verbindung herstellen
@ -47,56 +64,57 @@ export default function TeamCard({
if (!isConnected) connect(currentUserSteamId) if (!isConnected) connect(currentUserSteamId)
}, [currentUserSteamId, isConnected, connect]) }, [currentUserSteamId, isConnected, connect])
// ⬇️ Jede 'team-updated'-Änderung verarbeiten, robust entpacken, per ts deduplizieren // team-updated verarbeiten
useEffect(() => { useEffect(() => {
const ev = lastEvent const ev = lastEvent
if (!ev || ev.type !== 'team-updated') return if (!ev || ev.type !== 'team-updated') return
// payload kann entweder direkt die Felder haben … oder unter payload liegen const p = extractPayloadObject(ev.payload)
const p = (ev.payload && typeof ev.payload === 'object' && 'payload' in ev.payload) const tid = typeof p.teamId === 'string' ? (p.teamId as string) : undefined
? ev.payload.payload const jp = asJoinPolicy(p.joinPolicy)
: ev.payload
const tid = p?.teamId
const jp = p?.joinPolicy as TeamJoinPolicy | undefined
if (tid !== team.id) return if (tid !== team.id) return
if (jp !== 'REQUEST' && jp !== 'INVITE_ONLY') return if (!jp) return
// Dedupe an der Ereignis-Identität (ts stammt aus dem Store)
if (ev.ts && lastSeenTsRef.current === ev.ts) return if (ev.ts && lastSeenTsRef.current === ev.ts) return
lastSeenTsRef.current = ev.ts ?? Date.now() lastSeenTsRef.current = ev.ts ?? Date.now()
// kurzes Fenster, in dem Props-Refetch nicht wieder überschreibt
sseWinsUntil.current = Date.now() + 1500 sseWinsUntil.current = Date.now() + 1500
setEffectivePolicy(jp) setEffectivePolicy(jp)
}, [lastEvent?.ts, lastEvent, team.id]) }, [lastEvent, team.id])
// Props übernehmen, wenn kein frisches SSE dazwischenfunkt
// ⬇️ Props nur übernehmen, wenn kein frisches SSE dazwischenfunkt
useEffect(() => { useEffect(() => {
const jp = team.joinPolicy as TeamJoinPolicy | undefined const jp = team.joinPolicy as TeamJoinPolicy | undefined
if (Date.now() < sseWinsUntil.current) return if (Date.now() < sseWinsUntil.current) return
if (jp === 'REQUEST' || jp === 'INVITE_ONLY') { if (jp === 'REQUEST' || jp === 'INVITE_ONLY') {
setEffectivePolicy(prev => (prev === jp ? prev : jp)) setEffectivePolicy((prev) => (prev === jp ? prev : jp))
} }
}, [team.id, team.joinPolicy]) }, [team.id, team.joinPolicy])
// ── Stati ableiten (jetzt von effectivePolicy!) // Stati ableiten
const isInviteOnly = effectivePolicy === 'INVITE_ONLY' const isInviteOnly = effectivePolicy === 'INVITE_ONLY'
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending') const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
const hasPendingRequest = invitationId === 'pending' const hasPendingRequest = invitationId === 'pending'
const isRequested = hasRealInvitation || hasPendingRequest const isRequested = hasRealInvitation || hasPendingRequest
const isMemberOfThisTeam = useMemo(() => { const isMemberOfThisTeam = useMemo(() => {
const inActive = (team.activePlayers ?? []).some(p => String(p.steamId) === String(currentUserSteamId)) const inActive = (team.activePlayers ?? []).some(
const inInactive = (team.inactivePlayers ?? []).some(p => String(p.steamId) === String(currentUserSteamId)) (p) => String(p.steamId) === String(currentUserSteamId)
const isLeader = team.leader?.steamId && String(team.leader.steamId) === String(currentUserSteamId) )
const inInactive = (team.inactivePlayers ?? []).some(
(p) => String(p.steamId) === String(currentUserSteamId)
)
const isLeader =
team.leader?.steamId &&
String(team.leader.steamId) === String(currentUserSteamId)
return Boolean(inActive || inInactive || isLeader) return Boolean(inActive || inInactive || isLeader)
}, [team, currentUserSteamId]) }, [team, currentUserSteamId])
const isDisabled = const isDisabled =
joining || joining ||
isMemberOfThisTeam || isMemberOfThisTeam ||
!canRequestJoin ||
(isInviteOnly && !hasRealInvitation && !hasPendingRequest) (isInviteOnly && !hasRealInvitation && !hasPendingRequest)
const handleClick = async () => { const handleClick = async () => {
@ -140,23 +158,47 @@ export default function TeamCard({
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" /> <span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
Lädt Lädt
</> </>
) : isMemberOfThisTeam ? 'Schon Mitglied' ) : isMemberOfThisTeam ? (
: hasRealInvitation ? 'Einladung ablehnen' 'Schon Mitglied'
: hasPendingRequest ? 'Angefragt (zurückziehen)' ) : hasRealInvitation ? (
: isInviteOnly ? 'Nur Einladungen' 'Einladung ablehnen'
: 'Beitritt anfragen' ) : hasPendingRequest ? (
'Angefragt (zurückziehen)'
) : isInviteOnly ? (
'Nur Einladungen'
) : (
'Beitritt anfragen'
)
const buttonColor = const buttonColor: ButtonColor =
hasRealInvitation ? 'red' : hasRealInvitation ? 'red' : isDisabled ? 'gray' : isRequested ? 'gray' : 'blue'
isDisabled ? 'gray' :
(isRequested ? 'gray' : 'blue') // Mitglieder-Avatare (Leader zuerst, dann aktiv, dann inaktiv; unique)
const members: MemberLike[] = useMemo(() => {
const seen = new Set<string>()
const out: MemberLike[] = []
const pushUnique = (p?: MemberLike | null) => {
if (!p?.steamId) return
const sid = String(p.steamId)
if (seen.has(sid)) return
seen.add(sid)
out.push({ steamId: sid, name: p.name, avatar: p.avatar })
}
if (team.leader) pushUnique(team.leader)
;(team.activePlayers ?? []).forEach((p) => pushUnique(p))
;(team.inactivePlayers ?? []).forEach((p) => pushUnique(p))
return out
}, [team.leader, team.activePlayers, team.inactivePlayers])
return ( return (
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => router.push(targetHref)} onClick={() => router.push(targetHref)}
onKeyDown={e => (e.key === 'Enter') && router.push(targetHref)} onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
className="p-4 border rounded-lg bg-white dark:bg-neutral-800 className="p-4 border rounded-lg bg-white dark:bg-neutral-800
dark:border-neutral-700 shadow-sm hover:shadow-md dark:border-neutral-700 shadow-sm hover:shadow-md
transition cursor-pointer focus:outline-none transition cursor-pointer focus:outline-none
@ -164,12 +206,18 @@ export default function TeamCard({
> >
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img <div className="relative h-12 w-12 overflow-hidden rounded-full border border-gray-200 dark:border-neutral-600">
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`} <Image
src={
team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`
}
alt={team.name ?? 'Teamlogo'} alt={team.name ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border fill
border-gray-200 dark:border-neutral-600" sizes="48px"
className="object-cover"
unoptimized
/> />
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200"> <span className="font-medium truncate text-gray-800 dark:text-neutral-200">
{team.name ?? 'Team'} {team.name ?? 'Team'}
@ -184,7 +232,7 @@ export default function TeamCard({
size="md" size="md"
color="blue" color="blue"
variant="solid" variant="solid"
onClick={e => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
router.push(`/admin/teams/${team.id}`) router.push(`/admin/teams/${team.id}`)
}} }}
@ -192,13 +240,15 @@ export default function TeamCard({
Verwalten Verwalten
</Button> </Button>
) : ( ) : (
// 👉 Button immer zeigen falls nicht möglich: disabled + anderes Label
<Button <Button
title={typeof buttonLabel === 'string' ? buttonLabel : undefined} title={typeof buttonLabel === 'string' ? buttonLabel : undefined}
size="sm" size="sm"
color={buttonColor as any} color={buttonColor}
disabled={isDisabled} disabled={isDisabled}
onClick={e => { e.stopPropagation(); handleClick() }} onClick={(e) => {
e.stopPropagation()
handleClick()
}}
aria-disabled={isDisabled ? 'true' : undefined} aria-disabled={isDisabled ? 'true' : undefined}
> >
{buttonLabel} {buttonLabel}
@ -207,33 +257,16 @@ export default function TeamCard({
</div> </div>
<div className="flex -space-x-3"> <div className="flex -space-x-3">
{(() => { {members.map((p) => (
const seen = new Set<string>(); <div
const members: any[] = [];
const pushUnique = (p?: any) => {
if (!p || !p.steamId || seen.has(p.steamId)) return;
seen.add(p.steamId);
members.push(p);
};
// 1) Leader (falls vorhanden) zuerst
if (team.leader) pushUnique(team.leader);
// 2) aktive & inaktive
(team.activePlayers ?? []).forEach(pushUnique);
(team.inactivePlayers ?? []).forEach(pushUnique);
return members.map(p => (
<img
key={p.steamId} key={p.steamId}
src={p.avatar}
alt={p.name}
title={p.name} title={p.name}
className="w-8 h-8 rounded-full border-2 border-white dark:border-neutral-800 object-cover" aria-label={p.name}
/> className="relative h-8 w-8 overflow-hidden rounded-full border-2 border-white dark:border-neutral-800"
)); >
})()} <Image src={p.avatar} alt={p.name} fill sizes="32px" className="object-cover" unoptimized />
</div>
))}
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,7 @@
// /src/app/components/TeamCardComponent.tsx // /src/app/[locale]/components/TeamCardComponent.tsx
'use client' 'use client'
import { useEffect, useRef, useState, forwardRef } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import TeamInvitationBanner from './TeamInvitationBanner' import TeamInvitationBanner from './TeamInvitationBanner'
@ -13,16 +13,18 @@ import type { Player, Team } from '../../../types/team'
import type { Invitation } from '../../../types/invitation' import type { Invitation } from '../../../types/invitation'
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { INVITE_EVENTS, TEAM_EVENTS, SELF_EVENTS, isSseEventType } from '@/lib/sseEvents' import { INVITE_EVENTS, TEAM_EVENTS, SELF_EVENTS, isSseEventType } from '@/lib/sseEvents'
import Image from 'next/image'
type Props = { type Props = {
refetchKey?: string refetchKey?: string
initialTeams: Team[] initialTeams: Team[]
initialInvitationMap: Record<string, string> // ⬅️ wieder nur string initialInvitationMap: Record<string, string>
initialInvites?: Invitation[] initialInvites?: Invitation[]
onUpdateInvitation?: (teamId: string, inviteId: string | null) => void onUpdateInvitation?: (teamId: string, inviteId: string | null) => void
} }
/* ---------- kleine Helper ---------- */ /* ---------- kleine Helper ---------- */
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId)) const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
const eqPlayers = (a: Player[] = [], b: Player[] = []) => { const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
@ -31,6 +33,12 @@ const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
return true return true
} }
type TeamLegacyFields = Team & {
leaderId?: string
logoUpdatedAt?: string | Date
updatedAt?: string | Date
}
const eqTeam = (a?: Team | null, b?: Team | null) => { const eqTeam = (a?: Team | null, b?: Team | null) => {
if (!a && !b) return true if (!a && !b) return true
if (!a || !b) return false if (!a || !b) return false
@ -38,13 +46,12 @@ const eqTeam = (a?: Team | null, b?: Team | null) => {
if ((a.name ?? '') !== (b.name ?? '')) return false if ((a.name ?? '') !== (b.name ?? '')) return false
if ((a.logo ?? '') !== (b.logo ?? '')) return false if ((a.logo ?? '') !== (b.logo ?? '')) return false
const la = a.leader?.steamId ?? (a as any).leaderId ?? null const la = (a.leader?.steamId ?? (a as TeamLegacyFields).leaderId ?? null)
const lb = b.leader?.steamId ?? (b as any).leaderId ?? null const lb = (b.leader?.steamId ?? (b as TeamLegacyFields).leaderId ?? null)
if (la !== lb) return false if (la !== lb) return false
// >>> hier neu: const va = (a as TeamLegacyFields).logoUpdatedAt ?? (a as TeamLegacyFields).updatedAt ?? null
const va = (a as any).logoUpdatedAt ?? (a as any).updatedAt ?? null const vb = (b as TeamLegacyFields).logoUpdatedAt ?? (b as TeamLegacyFields).updatedAt ?? null
const vb = (b as any).logoUpdatedAt ?? (b as any).updatedAt ?? null
if ((va ? String(va) : '') !== (vb ? String(vb) : '')) return false if ((va ? String(va) : '') !== (vb ? String(vb) : '')) return false
return ( return (
@ -59,30 +66,34 @@ const eqInviteList = (a: Invitation[] = [], b: Invitation[] = []) => {
const B = b.map((x) => x.id).sort().join(',') const B = b.map((x) => x.id).sort().join(',')
return A === B return A === B
} }
const logoVer = (t: Team, vmap: Record<string, number>) =>
vmap[t.id] ?? (t as any).logoUpdatedAt ? new Date((t as any).logoUpdatedAt).getTime()
: (t as any).updatedAt ? new Date((t as any).updatedAt).getTime()
: 0;
async function loadTeamFull(teamId: string) { const logoVer = (t: Team, vmap: Record<string, number>) => {
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' }) const legacy = t as TeamLegacyFields
if (!res.ok) return null if (vmap[t.id] != null) return vmap[t.id]
return await res.json() if (legacy.logoUpdatedAt) return new Date(legacy.logoUpdatedAt).getTime()
if (legacy.updatedAt) return new Date(legacy.updatedAt).getTime()
return 0
} }
async function loadTeamFull(teamId: string): Promise<Team | null> {
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
if (!res.ok) return null
return (await res.json()) as Team
}
/* ---------- Komponente ---------- */ /* ---------- Komponente ---------- */
function TeamCardComponent( export default function TeamCardComponent({
{ initialTeams, initialInvitationMap, initialInvites = [] }: Props, initialTeams,
_ref: any initialInvitationMap,
) { initialInvites = [],
}: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const steamId = session?.user?.steamId ?? '' const steamId = ((session?.user as { steamId?: string } | undefined)?.steamId) ?? ''
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const [initialLoading, setInitialLoading] = useState(true) const [initialLoading, setInitialLoading] = useState(true)
const [logoVersionByTeam, setLogoVersionByTeam] = useState<Record<string, number>>({}); const [logoVersionByTeam, setLogoVersionByTeam] = useState<Record<string, number>>({})
// Alle Teams, in denen ich Mitglied bin (Leader/aktiv/inaktiv) // Alle Teams, in denen ich Mitglied bin (Leader/aktiv/inaktiv)
const [myTeams, setMyTeams] = useState<Team[]>([]) const [myTeams, setMyTeams] = useState<Team[]>([])
@ -91,7 +102,7 @@ function TeamCardComponent(
// Einladungen (nur relevant, wenn ich in KEINEM Team bin) // Einladungen (nur relevant, wenn ich in KEINEM Team bin)
const [pendingInvitations, setPendingInvitations] = useState<Invitation[]>( const [pendingInvitations, setPendingInvitations] = useState<Invitation[]>(
initialInvites.filter(i => i.type === 'team-invite' && i.team) as any initialInvites.filter((i): i is Invitation => i.type === 'team-invite' && !!i.team)
) )
// Drag/Modals für TeamMemberView // Drag/Modals für TeamMemberView
@ -107,15 +118,18 @@ function TeamCardComponent(
const softReloadInFlight = useRef(false) // keine Parallel-Reloads const softReloadInFlight = useRef(false) // keine Parallel-Reloads
const lastSoftReloadAt = useRef(0) // einfacher Throttle (ms) const lastSoftReloadAt = useRef(0) // einfacher Throttle (ms)
/* ------- User+Teams laden (einmalig) ------- */ /* ------- User+Teams laden (einmalig, aber stabil typisiert) ------- */
const loadUserTeams = async () => { const loadUserTeams = useCallback(async () => {
try { try {
setInitialLoading(true) setInitialLoading(true)
const res = await fetch('/api/user', { cache: 'no-store' }) const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) throw new Error('failed /api/user') if (!res.ok) throw new Error('failed /api/user')
const data = await res.json() const data: unknown = await res.json()
const teams: Team[] = Array.isArray((data as { teams?: unknown }).teams)
? ((data as { teams: Team[] }).teams)
: []
const teams: Team[] = Array.isArray(data?.teams) ? data.teams : []
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams)) setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
// Auto-Auswahl // Auto-Auswahl
@ -132,12 +146,15 @@ function TeamCardComponent(
} finally { } finally {
setInitialLoading(false) setInitialLoading(false)
} }
} }, [pendingInvitations.length, selectedTeam])
useEffect(() => { loadUserTeams() }, []) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => {
// einmalig ausführen; loadUserTeams ist memoized (siehe deps)
void loadUserTeams()
}, [loadUserTeams])
/* ------- Gedrosseltes Soft-Reload ------- */ /* ------- Gedrosseltes Soft-Reload ------- */
const softReload = async () => { const softReload = useCallback(async () => {
const now = Date.now() const now = Date.now()
if (softReloadInFlight.current) return if (softReloadInFlight.current) return
if (now - lastSoftReloadAt.current < 500) return // 500ms Cooldown if (now - lastSoftReloadAt.current < 500) return // 500ms Cooldown
@ -147,8 +164,10 @@ function TeamCardComponent(
try { try {
const res = await fetch('/api/user', { cache: 'no-store' }) const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return if (!res.ok) return
const data = await res.json() const data: unknown = await res.json()
const teams: Team[] = Array.isArray(data?.teams) ? data.teams : [] const teams: Team[] = Array.isArray((data as { teams?: unknown }).teams)
? ((data as { teams: Team[] }).teams)
: []
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams)) setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
@ -157,18 +176,26 @@ function TeamCardComponent(
if (teams.length > 0 && pendingInvitations.length) setPendingInvitations([]) if (teams.length > 0 && pendingInvitations.length) setPendingInvitations([])
// Einladungen nachladen falls kein Team
if (teams.length === 0 && Date.now() - lastInviteCheck.current > 1500) { if (teams.length === 0 && Date.now() - lastInviteCheck.current > 1500) {
lastInviteCheck.current = Date.now() lastInviteCheck.current = Date.now()
const inv = await fetch('/api/user/invitations', { cache: 'no-store' }) const inv = await fetch('/api/user/invitations', { cache: 'no-store' })
if (inv.ok) { if (inv.ok) {
const json = await inv.json() const json: unknown = await inv.json()
const all: Invitation[] = (json.invitations ?? []) type RawInv = { id?: string; type?: string; team?: Team }
.filter((i: any) => i.type === 'team-invite' && i.team) const rawList: RawInv[] = Array.isArray((json as { invitations?: unknown }).invitations)
.map((i: any) => ({ id: i.id, team: i.team })) ? ((json as { invitations: RawInv[] }).invitations)
: []
const all: Invitation[] = rawList
.filter((i): i is Required<Pick<RawInv, 'id' | 'team'>> & RawInv => !!i.id && i.type === 'team-invite' && !!i.team)
.map((i) => ({ id: i.id!, type: 'team-invite', team: i.team! }))
setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all)) setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all))
} }
} }
// selektiertes Team vollständig aktualisieren
if (selectedTeam) { if (selectedTeam) {
const full = await loadTeamFull(selectedTeam.id) const full = await loadTeamFull(selectedTeam.id)
if (full) { if (full) {
@ -179,44 +206,48 @@ function TeamCardComponent(
} finally { } finally {
softReloadInFlight.current = false softReloadInFlight.current = false
} }
} }, [pendingInvitations.length, selectedTeam])
/* ------- SSE-gestützte Updates (dedupliziert) ------- */ /* ------- SSE-gestützte Updates (dedupliziert) ------- */
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
if (!isSseEventType(lastEvent.type)) return if (!isSseEventType(lastEvent.type)) return
const { type, payload } = lastEvent as any const type = lastEvent.type
const payload = (lastEvent.payload ?? {}) as Record<string, unknown>
// Dedupe-Key (Type + teamId + version + invitationId) // Dedupe-Key (Type + teamId + version + invitationId)
const key = [ const key = [
type, type,
payload?.teamId ?? '', (payload.teamId as string | undefined) ?? '',
payload?.version ?? '', String(payload.version ?? ''),
payload?.invitationId ?? '' (payload.invitationId as string | undefined) ?? '',
].join('|') ].join('|')
if (key === lastHandledRef.current) return if (key === lastHandledRef.current) return
lastHandledRef.current = key lastHandledRef.current = key
// Logo-Event: nur lokal updaten, KEIN /api/user-Reload // Logo-Event: nur lokal updaten, KEIN /api/user-Reload
if (type === 'team-logo-updated' && payload?.teamId) { if (type === 'team-logo-updated' && typeof payload.teamId === 'string') {
// Filename bleibt oft gleich -> trotzdem Teams updaten (ok) const teamId = payload.teamId
if (payload?.filename) { const filename = payload.filename as string | undefined
setMyTeams(prev => prev.map(t => t.id === payload.teamId ? { ...t, logo: payload.filename } : t)); const version = payload.version as number | undefined
if (selectedTeam?.id === payload.teamId) {
setSelectedTeam(prev => (prev ? { ...prev, logo: payload.filename } : prev)); if (filename) {
setMyTeams(prev => prev.map(t => t.id === teamId ? { ...t, logo: filename } : t))
if (selectedTeam?.id === teamId) {
setSelectedTeam(prev => (prev ? { ...prev, logo: filename } : prev))
} }
} }
if (payload?.version) { if (typeof version === 'number') {
setLogoVersionByTeam(prev => ({ ...prev, [payload.teamId]: payload.version })); setLogoVersionByTeam(prev => ({ ...prev, [teamId]: version }))
} }
return; return
} }
// Invite revoked: Liste anpassen, dann gedrosseltes Reload // Invite revoked: Liste anpassen, dann gedrosseltes Reload
if (type === 'team-invite-revoked') { if (type === 'team-invite-revoked') {
const revokedId = payload?.invitationId as string | undefined const revokedId = payload.invitationId as string | undefined
const revokedTeamId = payload?.teamId as string | undefined const revokedTeamId = payload.teamId as string | undefined
if (revokedId || revokedTeamId) { if (revokedId || revokedTeamId) {
setPendingInvitations(prev => setPendingInvitations(prev =>
prev.filter(i => prev.filter(i =>
@ -225,31 +256,31 @@ function TeamCardComponent(
) )
) )
} }
softReload() void softReload()
return return
} }
// Relevante Gruppen → gedrosseltes Reload // Relevante Gruppen → gedrosseltes Reload
if (SELF_EVENTS.has(type)) { softReload(); return } if (SELF_EVENTS.has(type)) { void softReload(); return }
if (TEAM_EVENTS.has(type)) { softReload(); return } if (TEAM_EVENTS.has(type)) { void softReload(); return }
if (INVITE_EVENTS.has(type) && myTeams.length === 0) { softReload(); return } if (INVITE_EVENTS.has(type) && myTeams.length === 0) { void softReload(); return }
}, [lastEvent, myTeams.length, selectedTeam, softReload])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEvent, myTeams.length]) // bewusst schlanke Dependencies
// wenn selectedTeam nur aus der /api/user-Quelle kommt (ohne invitedPlayers), einmalig vollständig laden
useEffect(() => { useEffect(() => {
if (!selectedTeam) return if (!selectedTeam) return
if (Array.isArray(selectedTeam.invitedPlayers)) return // schon voll if (Array.isArray(selectedTeam.invitedPlayers)) return
(async () => { let cancelled = false
;(async () => {
const full = await loadTeamFull(selectedTeam.id) const full = await loadTeamFull(selectedTeam.id)
if (!full) return if (!full || cancelled) return
// in myTeams ersetzen …
setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t)) setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t))
// … und als selectedTeam setzen
setSelectedTeam(full) setSelectedTeam(full)
})() })()
}, [selectedTeam?.id])
return () => { cancelled = true }
}, [selectedTeam])
/* ------- Render-Zweige ------- */ /* ------- Render-Zweige ------- */
@ -343,7 +374,12 @@ function TeamCardComponent(
</div> </div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{myTeams.map(team => ( {myTeams.map(team => {
const v = logoVer(team, logoVersionByTeam)
const src = team.logo
? `/assets/img/logos/${team.logo}${v ? `?v=${v}` : ''}`
: `/assets/img/logos/cs2.webp`
return (
<button <button
key={team.id} key={team.id}
type="button" type="button"
@ -357,16 +393,17 @@ function TeamCardComponent(
> >
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img <div className="relative h-12 w-12 overflow-hidden rounded-full border border-gray-200 dark:border-neutral-600">
key={`${team.logo ?? 'fallback'}-${logoVer(team, logoVersionByTeam)}`} <Image
src={ key={`${team.logo ?? 'fallback'}-${v}`}
team.logo src={src}
? `/assets/img/logos/${team.logo}${logoVer(team, logoVersionByTeam) ? `?v=${logoVer(team, logoVersionByTeam)}` : ''}`
: `/assets/img/logos/cs2.webp`
}
alt={team.name ?? 'Teamlogo'} alt={team.name ?? 'Teamlogo'}
className="h-12 w-12 rounded-full border object-cover border-gray-200 dark:border-neutral-600" fill
sizes="48px"
className="object-cover"
unoptimized
/> />
</div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="truncate font-medium text-gray-800 dark:text-neutral-200"> <span className="truncate font-medium text-gray-800 dark:text-neutral-200">
{team.name ?? 'Team'} {team.name ?? 'Team'}
@ -376,25 +413,35 @@ function TeamCardComponent(
</span> </span>
</div> </div>
</div> </div>
<svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24"> <svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24" aria-hidden>
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" /> <path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
</svg> </svg>
</div> </div>
<div className="flex -space-x-3"> <div className="flex -space-x-3">
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].slice(0, 6).map(p => ( {[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]
<img .slice(0, 6)
.map(p => (
<div
key={p.steamId} key={p.steamId}
className="relative h-8 w-8 overflow-hidden rounded-full border-2 border-white dark:border-neutral-800"
title={p.name}
aria-label={p.name}
>
<Image
src={p.avatar} src={p.avatar}
alt={p.name} alt={p.name}
title={p.name} fill
className="h-8 w-8 rounded-full border-2 border-white sizes="32px"
dark:border-neutral-800 object-cover" className="object-cover"
unoptimized
/> />
</div>
))} ))}
</div> </div>
</button> </button>
))} )
})}
</div> </div>
</div> </div>
) )
@ -447,5 +494,3 @@ function TeamCardComponent(
</div> </div>
) )
} }
export default forwardRef(TeamCardComponent)

View File

@ -1,8 +1,9 @@
// TeamInvitationBanner.tsx // /src/app/[locale]/components/TeamInvitationBanner.tsx
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Button from './Button' import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
import type { Invitation } from '../../../types/invitation' import type { Invitation } from '../../../types/invitation'
@ -45,7 +46,6 @@ export default function TeamInvitationBanner({
} }
} }
// Klassen als Ausdruck (keine mehrzeiligen String-Literals im JSX)
const cardClasses = const cardClasses =
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' + 'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
'dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer ' + 'dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer ' +
@ -59,18 +59,21 @@ export default function TeamInvitationBanner({
onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)} onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
className={cardClasses} className={cardClasses}
> >
{/* animierter, dezenter grüner Gradient */}
<div aria-hidden className="absolute inset-0 z-0 pointer-events-none invitationGradient" /> <div aria-hidden className="absolute inset-0 z-0 pointer-events-none invitationGradient" />
{/* Inhalt */}
<div className="relative z-[1] p-4"> <div className="relative z-[1] p-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img {/* Teamlogo */}
<Image
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`} src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt={team.name ?? 'Teamlogo'} alt={team.name ?? 'Teamlogo'}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600" className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
priority={false}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200"> <span className="font-medium truncate text-gray-800 dark:text-neutral-200">
@ -86,11 +89,16 @@ export default function TeamInvitationBanner({
{/* Teammitglieder */} {/* Teammitglieder */}
<div className="flex -space-x-3"> <div className="flex -space-x-3">
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].map((p) => ( {[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].map((p) => (
<img <Image
key={p.steamId} key={p.steamId}
src={p.avatar} src={p.avatar}
alt={p.name} alt={p.name}
title={p.name} title={p.name}
width={48}
height={48}
// Falls Avatare externe URLs sind, ist unoptimized sicher;
// alternativ remotePatterns in next.config.js setzen.
unoptimized
className="w-12 h-12 rounded-full border-2 border-white dark:border-neutral-800 object-cover" className="w-12 h-12 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
/> />
))} ))}
@ -144,7 +152,6 @@ export default function TeamInvitationBanner({
</div> </div>
<style jsx>{` <style jsx>{`
/* Hintergrund-Schimmer (läuft permanent) */
@keyframes slide-x { @keyframes slide-x {
from { background-position-x: 0%; } from { background-position-x: 0%; }
to { background-position-x: 200%; } to { background-position-x: 200%; }
@ -158,7 +165,7 @@ export default function TeamInvitationBanner({
); );
background-size: 200% 100%; background-size: 200% 100%;
background-repeat: repeat-x; background-repeat: repeat-x;
animation: slide-x 6s linear infinite; /* etwas ruhiger */ animation: slide-x 6s linear infinite;
} }
:global(.dark) .invitationGradient { :global(.dark) .invitationGradient {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
@ -168,18 +175,13 @@ export default function TeamInvitationBanner({
rgba(16,168,54,0.28) 100% rgba(16,168,54,0.28) 100%
); );
} }
/* Shine-Sweep nur auf Hover */
@keyframes shine { @keyframes shine {
0% { transform: translateX(-120%) skewX(-20deg); opacity: 0; } 0% { transform: translateX(-120%) skewX(-20deg); opacity: 0; }
10% { opacity: .7; } 10% { opacity: .7; }
27% { transform: translateX(120%) skewX(-20deg); opacity: 0; } 27% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
100% { transform: translateX(120%) skewX(-20deg); opacity: 0; } 100% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
} }
.shine { .shine { position: absolute; inset: 0; }
position: absolute;
inset: 0;
}
.shine::before { .shine::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -194,12 +196,9 @@ export default function TeamInvitationBanner({
transform: translateX(-120%) skewX(-20deg); transform: translateX(-120%) skewX(-20deg);
transition: opacity .2s; transition: opacity .2s;
} }
/* nur wenn die Karte offen ist und gehovert wird */
:global(.group:hover) .shine::before { :global(.group:hover) .shine::before {
animation: shine 3.8s ease-out infinite; animation: shine 3.8s ease-out infinite;
} }
/* Respektiere Bewegungs-Präferenzen */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.invitationGradient { animation: none; } .invitationGradient { animation: none; }
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; } .shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }

View File

@ -1,10 +1,14 @@
// /src/app/[locale]/components/TeamMemberView.tsx // /src/app/[locale]/components/TeamMemberView.tsx
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core' import {
DndContext,
closestCenter,
DragOverlay,
type DragStartEvent,
type DragEndEvent,
} from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { DroppableZone } from './DroppableZone' import { DroppableZone } from './DroppableZone'
import MiniCard from './MiniCard' import MiniCard from './MiniCard'
@ -43,16 +47,10 @@ type Props = {
adminMode?: boolean adminMode?: boolean
} }
/**
* Wrapper-Komponente:
* - spiegelt optionales team-Prop in den Store
* - rendert Body erst, wenn team + Berechtigungen vorhanden sind
* Dadurch bleiben Hooks-Reihenfolgen stabil.
*/
export default function TeamMemberView(props: Props) { export default function TeamMemberView(props: Props) {
const { team: storeTeam, setTeam } = useTeamStore() const { team: storeTeam, setTeam } = useTeamStore()
// Prop -> Store spiegeln: auch bei gleicher ID relevante Felder patchen // Prop -> Store spiegeln
useEffect(() => { useEffect(() => {
if (!props.team) return if (!props.team) return
const curr = useTeamStore.getState().team const curr = useTeamStore.getState().team
@ -67,26 +65,19 @@ export default function TeamMemberView(props: Props) {
if (curr.logo !== next.logo) diff.logo = next.logo if (curr.logo !== next.logo) diff.logo = next.logo
if ((curr.leader?.steamId ?? null) !== (next.leader?.steamId ?? null)) diff.leader = next.leader if ((curr.leader?.steamId ?? null) !== (next.leader?.steamId ?? null)) diff.leader = next.leader
if (typeof next.joinPolicy === 'string' && curr.joinPolicy !== next.joinPolicy) { if (typeof next.joinPolicy === 'string' && curr.joinPolicy !== next.joinPolicy) {
diff.joinPolicy = next.joinPolicy as any diff.joinPolicy = next.joinPolicy as TeamJoinPolicy
} }
if (Object.keys(diff).length) setTeam({ ...curr, ...diff } as Team) if (Object.keys(diff).length) setTeam({ ...curr, ...diff } as Team)
}, [props.team, setTeam]) }, [props.team, setTeam])
// Guards dürfen im Wrapper stehen (kein Hook darunter bricht ab)
if (!props.adminMode && !props.currentUserSteamId) return null if (!props.adminMode && !props.currentUserSteamId) return null
const team = props.team ?? storeTeam ?? null const team = props.team ?? storeTeam ?? null
if (!team) return null if (!team) return null
// Ab hier nur noch Body rendern dort gibt es keine frühen Returns mehr vor Hooks
return <TeamMemberViewBody {...props} team={team} /> return <TeamMemberViewBody {...props} team={team} />
} }
/**
* Body-Komponente:
* - enthält ALLE übrigen Hooks in fester Reihenfolge
* - hier ist team garantiert vorhanden (nicht null)
*/
function TeamMemberViewBody({ function TeamMemberViewBody({
team, team,
activeDragItem, activeDragItem,
@ -105,7 +96,11 @@ function TeamMemberViewBody({
const teamId = team.id const teamId = team.id
const teamLeaderSteamId = team.leader?.steamId ?? '' const teamLeaderSteamId = team.leader?.steamId ?? ''
const RELEVANT: ReadonlySet<SSEEventType> = new Set([...TEAM_EVENTS, ...SELF_EVENTS]) // stabile Menge für useEffect-Deps
const RELEVANT = useMemo<ReadonlySet<SSEEventType>>(
() => new Set([...TEAM_EVENTS, ...SELF_EVENTS]),
[]
)
const isLeader = currentUserSteamId === team.leader?.steamId const isLeader = currentUserSteamId === team.leader?.steamId
const canManage = adminMode || isLeader const canManage = adminMode || isLeader
@ -139,18 +134,20 @@ function TeamMemberViewBody({
const [inviteKey, setInviteKey] = useState(0) const [inviteKey, setInviteKey] = useState(0)
const openInvite = () => { const openInvite = () => {
setInviteKey(k => k + 1) // erzwingt frischen Mount setInviteKey(k => k + 1)
setShowInviteModal(true) setShowInviteModal(true)
} }
// Cache-Busting fürs Logo // Cache-Busting fürs Logo (ohne any)
type TeamWithStamp = Team & { logoUpdatedAt?: string | Date; updatedAt?: string | Date }
const tStamped = team as TeamWithStamp
const initialLogoVersion = const initialLogoVersion =
(team as any).logoUpdatedAt tStamped.logoUpdatedAt
? new Date((team as any).logoUpdatedAt).getTime() ? new Date(tStamped.logoUpdatedAt).getTime()
: (team as any).updatedAt : tStamped.updatedAt
? new Date((team as any).updatedAt).getTime() ? new Date(tStamped.updatedAt).getTime()
: 0; : 0
const [logoVersion, setLogoVersion] = useState<number | null>(initialLogoVersion); const [logoVersion, setLogoVersion] = useState<number | null>(initialLogoVersion)
// Upload-Progress // Upload-Progress
const [isUploadingLogo, setIsUploadingLogo] = useState(false) const [isUploadingLogo, setIsUploadingLogo] = useState(false)
@ -184,7 +181,6 @@ function TeamMemberViewBody({
} }
useEffect(() => { useEffect(() => {
// Nur setzen, wenn der Server wirklich einen Wert liefert.
if (typeof team.joinPolicy === 'string') { if (typeof team.joinPolicy === 'string') {
setJoinPolicy(team.joinPolicy as TeamJoinPolicy) setJoinPolicy(team.joinPolicy as TeamJoinPolicy)
} }
@ -218,17 +214,10 @@ function TeamMemberViewBody({
useEffect(() => { useEffect(() => {
if (!lastEvent || !team.id) return if (!lastEvent || !team.id) return
if (!isSseEventType(lastEvent.type)) return if (!isSseEventType(lastEvent.type)) return
const payload = lastEvent.payload ?? {} const payload = (lastEvent.payload ?? {}) as Record<string, unknown>
const now = Date.now() const now = Date.now()
// nur joinPolicy geändert → minimal patchen // Nach lokalem Speichern: kurzes Ignore-Fenster
if (lastEvent.type === 'team-updated' && payload.teamId === team.id) {
return
}
// Nach lokalem Speichern kommt oft ein generisches team-updated ohne joinPolicy.
// Das würde ein Reload triggern → 1x kurz ignorieren.
if (lastEvent.type === 'team-updated' && payload.teamId === team.id) { if (lastEvent.type === 'team-updated' && payload.teamId === team.id) {
if (policyChangedAtRef.current && (now - policyChangedAtRef.current) < 2000) { if (policyChangedAtRef.current && (now - policyChangedAtRef.current) < 2000) {
policyChangedAtRef.current = null policyChangedAtRef.current = null
@ -238,16 +227,20 @@ function TeamMemberViewBody({
// nur Logo geändert → minimal patchen // nur Logo geändert → minimal patchen
if (lastEvent.type === 'team-logo-updated') { if (lastEvent.type === 'team-logo-updated') {
if (payload.teamId && payload.teamId !== team.id) return
const curr = useTeamStore.getState().team const curr = useTeamStore.getState().team
if (payload?.filename && curr) setTeam({ ...curr, logo: payload.filename }) const filename = (payload as { filename?: string }).filename
if (payload?.version) setLogoVersion(payload.version) const version = (payload as { version?: number }).version
const evTeamId = (payload as { teamId?: string }).teamId
if (evTeamId && evTeamId !== team.id) return
if (filename && curr) setTeam({ ...curr, logo: filename })
if (typeof version === 'number') setLogoVersion(version)
return return
} }
// Rest: reload + remount NUR wenn Listen wirklich anders sind // Rest: reload wenn relevant
if (!RELEVANT.has(lastEvent.type)) return if (!RELEVANT.has(lastEvent.type)) return
if (payload.teamId && payload.teamId !== team.id) return const evTeamId = (payload as { teamId?: string }).teamId
if (evTeamId && evTeamId !== team.id) return
;(async () => { ;(async () => {
const updated = await reloadTeam(team.id) const updated = await reloadTeam(team.id)
@ -256,8 +249,8 @@ function TeamMemberViewBody({
setTeam(updated) setTeam(updated)
setEditedName(updated.name || '') setEditedName(updated.name || '')
if (typeof (updated as any).joinPolicy === 'string') { if (typeof updated.joinPolicy === 'string') {
setJoinPolicy((updated as any).joinPolicy as TeamJoinPolicy) setJoinPolicy(updated.joinPolicy as TeamJoinPolicy)
} }
const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name)) const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
@ -270,35 +263,31 @@ function TeamMemberViewBody({
return return
} }
// 1) Set-Vergleich (Inhalt)
const contentChanged = const contentChanged =
!eqSetByIds(activePlayers, nextActive) || !eqSetByIds(activePlayers, nextActive) ||
!eqSetByIds(inactivePlayers, nextInactive) || !eqSetByIds(inactivePlayers, nextInactive) ||
!eqSetByIds(invitedPlayers, nextInvited) !eqSetByIds(invitedPlayers, nextInvited)
// 2) Reihenfolge-Vergleich (nur Order)
const orderChanged = const orderChanged =
!eqByIds(activePlayers, nextActive) || !eqByIds(activePlayers, nextActive) ||
!eqByIds(inactivePlayers, nextInactive) || !eqByIds(inactivePlayers, nextInactive) ||
!eqByIds(invitedPlayers, nextInvited) !eqByIds(invitedPlayers, nextInvited)
if (contentChanged) { if (contentChanged) {
// IDs haben sich geändert → Listen setzen + DnD remounten (Keys bleiben!)
setActivePlayers(nextActive) setActivePlayers(nextActive)
setInactivePlayers(nextInactive) setInactivePlayers(nextInactive)
setInvitedPlayers(nextInvited) setInvitedPlayers(nextInvited)
setRemountKey(k => k + 1) setRemountKey(k => k + 1)
} else if (orderChanged) { } else if (orderChanged) {
// Nur Reihenfolge/Sichtung anders → Listen setzen, aber KEIN remount
setActivePlayers(nextActive) setActivePlayers(nextActive)
setInactivePlayers(nextInactive) setInactivePlayers(nextInactive)
setInvitedPlayers(nextInvited) setInvitedPlayers(nextInvited)
} }
})() })()
}, [lastEvent, team.id, setTeam, activePlayers, inactivePlayers, invitedPlayers]) }, [RELEVANT, lastEvent, team.id, setTeam, activePlayers, inactivePlayers, invitedPlayers])
const handleDragStart = (event: any) => { const handleDragStart = (event: DragStartEvent) => {
const id = event.active.id as string const id = String(event.active.id)
const item = const item =
activePlayers.find(p => p.steamId === id) || activePlayers.find(p => p.steamId === id) ||
inactivePlayers.find(p => p.steamId === id) inactivePlayers.find(p => p.steamId === id)
@ -314,8 +303,8 @@ function TeamMemberViewBody({
const applyPolicy = async (p: TeamJoinPolicy) => { const applyPolicy = async (p: TeamJoinPolicy) => {
if (p === joinPolicy) { setShowPolicyMenu(false); return } if (p === joinPolicy) { setShowPolicyMenu(false); return }
setJoinPolicy(p) // optimistisch setJoinPolicy(p)
await saveJoinPolicy(p) // serverseitig speichern await saveJoinPolicy(p)
setShowPolicyMenu(false) setShowPolicyMenu(false)
} }
@ -325,7 +314,6 @@ function TeamMemberViewBody({
const onOutside = (e: PointerEvent) => { const onOutside = (e: PointerEvent) => {
if (!policyMenuRef.current) return if (!policyMenuRef.current) return
if (!policyMenuRef.current.contains(e.target as Node)) { if (!policyMenuRef.current.contains(e.target as Node)) {
// Klick außerhalb: Menü schließen + Navigation/Drag verhindern
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setShowPolicyMenu(false) setShowPolicyMenu(false)
@ -335,7 +323,6 @@ function TeamMemberViewBody({
if (e.key === 'Escape') setShowPolicyMenu(false) if (e.key === 'Escape') setShowPolicyMenu(false)
} }
// Capture-Phase, damit wir VOR Links/Drag reagieren
document.addEventListener('pointerdown', onOutside, { capture: true }) document.addEventListener('pointerdown', onOutside, { capture: true })
document.addEventListener('keydown', onEsc) document.addEventListener('keydown', onEsc)
@ -345,28 +332,27 @@ function TeamMemberViewBody({
} }
}, [showPolicyMenu]) }, [showPolicyMenu])
const updateTeamMembers = async (tId: string, active: Player[], inactive: Player[]) => {
const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => {
try { try {
const res = await fetch('/api/team/update-players', { const res = await fetch('/api/team/update-players', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
teamId, teamId: tId,
activePlayers: active.map(p => p.steamId), activePlayers: active.map(p => p.steamId),
inactivePlayers: inactive.map(p => p.steamId), inactivePlayers: inactive.map(p => p.steamId),
}), }),
}) })
if (!res.ok) throw new Error('Update fehlgeschlagen') if (!res.ok) throw new Error('Update fehlgeschlagen')
const updated = await reloadTeam(teamId) const updated = await reloadTeam(tId)
if (updated) setTeam(updated) if (updated) setTeam(updated)
} catch (err) { } catch (err) {
console.error('Fehler beim Aktualisieren:', err) console.error('Fehler beim Aktualisieren:', err)
} }
} }
const handleDragEnd = async (event: any) => { const handleDragEnd = async (event: DragEndEvent) => {
setActiveDragItem(null) setActiveDragItem(null)
setIsDragging(false) setIsDragging(false)
isDraggingRef.current = false isDraggingRef.current = false
@ -482,8 +468,8 @@ function TeamMemberViewBody({
body: JSON.stringify({ teamId, newLeaderSteamId: newLeaderId }), body: JSON.stringify({ teamId, newLeaderSteamId: newLeaderId }),
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data: unknown = await res.json().catch(() => ({}))
console.error('Fehler bei Leader-Übertragung:', data.message) console.error('Fehler bei Leader-Übertragung:', (data as { message?: string }).message)
return return
} }
await handleReload() await handleReload()
@ -493,11 +479,11 @@ function TeamMemberViewBody({
} }
type DownscaleOpts = { type DownscaleOpts = {
size?: number; // Zielkante (px) size?: number
quality?: number; // 0..1 quality?: number
mime?: string; // Wunschformat, default 'image/webp' mime?: string
square?: boolean; // center-crop auf Quadrat square?: boolean
}; }
async function saveJoinPolicy(next: TeamJoinPolicy = joinPolicy) { async function saveJoinPolicy(next: TeamJoinPolicy = joinPolicy) {
const prev = joinPolicy const prev = joinPolicy
@ -509,20 +495,21 @@ function TeamMemberViewBody({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin', credentials: 'same-origin',
cache: 'no-store', cache: 'no-store',
body: JSON.stringify({ teamId, joinPolicy: next }), // teamId aus dem Body-Scope body: JSON.stringify({ teamId, joinPolicy: next }),
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data: unknown = await res.json().catch(() => ({}))
throw new Error(data?.message ?? `Speichern fehlgeschlagen (${res.status})`) const msg = (data as { message?: string }).message
throw new Error(msg ?? `Speichern fehlgeschlagen (${res.status})`)
} }
const { joinPolicy: serverPolicy } = await res.json().catch(() => ({})) const parsed: unknown = await res.json().catch(() => ({}))
const serverPolicy = (parsed as { joinPolicy?: TeamJoinPolicy }).joinPolicy
const patched = (serverPolicy ?? next) as TeamJoinPolicy const patched = (serverPolicy ?? next) as TeamJoinPolicy
setJoinPolicy(patched) setJoinPolicy(patched)
// Store patchen
const curr = useTeamStore.getState().team const curr = useTeamStore.getState().team
if (curr && curr.id === teamId && curr.joinPolicy !== patched) { if (curr && curr.id === teamId && curr.joinPolicy !== patched) {
setTeam({ ...curr, joinPolicy: patched }) setTeam({ ...curr, joinPolicy: patched })
@ -543,18 +530,17 @@ function TeamMemberViewBody({
async function canEncode(mime: string): Promise<boolean> { async function canEncode(mime: string): Promise<boolean> {
try { try {
// OffscreenCanvas hat die zuverlässigste Blob-API
if ('OffscreenCanvas' in window) { if ('OffscreenCanvas' in window) {
const c = new OffscreenCanvas(2, 2); const c = new OffscreenCanvas(2, 2)
const b = await (c as any).convertToBlob?.({ type: mime, quality: 0.8 }); const b = await c.convertToBlob({ type: mime, quality: 0.8 })
return !!b; return !!b
} }
const c = document.createElement('canvas'); const c = document.createElement('canvas')
c.width = 2; c.height = 2; c.width = 2; c.height = 2
const url = c.toDataURL(mime); const url = c.toDataURL(mime)
return typeof url === 'string' && url.startsWith(`data:${mime}`); return typeof url === 'string' && url.startsWith(`data:${mime}`)
} catch { } catch {
return false; return false
} }
} }
@ -564,104 +550,93 @@ function TeamMemberViewBody({
quality = 0.85, quality = 0.85,
mime: wantedMime = 'image/webp', mime: wantedMime = 'image/webp',
square = true, square = true,
} = opts; } = opts
// 1) Bild laden (ImageBitmap bevorzugt) // 1) Bild laden (ImageBitmap bevorzugt, ohne any)
let url: string | null = null; let url: string | null = null
let img: ImageBitmap | HTMLImageElement; let img: ImageBitmap | HTMLImageElement
const useBitmap = 'createImageBitmap' in window;
if (useBitmap) {
try { try {
img = await (createImageBitmap as any)(file, { imageOrientation: 'from-image' }); img = await createImageBitmap(file)
} catch { } catch {
url = URL.createObjectURL(file); url = URL.createObjectURL(file)
img = await new Promise<HTMLImageElement>((res, rej) => { img = await new Promise<HTMLImageElement>((res, rej) => {
const im = new window.Image(); const im = new window.Image()
im.onload = () => res(im); im.onload = () => res(im)
im.onerror = rej; im.onerror = rej
im.src = url!; im.src = url!
}); })
}
} else {
url = URL.createObjectURL(file);
img = await new Promise<HTMLImageElement>((res, rej) => {
const im = new window.Image();
im.onload = () => res(im);
im.onerror = rej;
im.src = url!;
});
} }
const srcW = (img as any).width as number; const dims = img as unknown as { width: number; height: number }
const srcH = (img as any).height as number; const srcW = dims.width
const srcH = dims.height
if (!srcW || !srcH) { if (!srcW || !srcH) {
if (url) URL.revokeObjectURL(url); if (url) URL.revokeObjectURL(url)
if ('close' in (img as any)) try { (img as ImageBitmap).close(); } catch {} if ('close' in (img as ImageBitmap)) try { (img as ImageBitmap).close() } catch {}
throw new Error('Invalid image dimensions'); throw new Error('Invalid image dimensions')
} }
// 2) Zielgröße + optionaler Center-Crop // 2) Zielgröße + optionaler Center-Crop
let sx = 0, sy = 0, sw = srcW, sh = srcH; let sx = 0, sy = 0, sw = srcW, sh = srcH
if (square) { if (square) {
const side = Math.min(srcW, srcH); const side = Math.min(srcW, srcH)
sx = Math.max(0, Math.floor((srcW - side) / 2)); sx = Math.max(0, Math.floor((srcW - side) / 2))
sy = Math.max(0, Math.floor((srcH - side) / 2)); sy = Math.max(0, Math.floor((srcH - side) / 2))
sw = side; sh = side; sw = side; sh = side
} }
const scale = Math.min(size / sw, size / sh, 1); const scale = Math.min(size / sw, size / sh, 1)
const dw = Math.max(1, Math.round(sw * scale)); const dw = Math.max(1, Math.round(sw * scale))
const dh = Math.max(1, Math.round(sh * scale)); const dh = Math.max(1, Math.round(sh * scale))
// 3) Canvas wählen (Offscreen bevorzugt) // 3) Canvas (Offscreen bevorzugt)
const offscreen = 'OffscreenCanvas' in window; const source = img as unknown as CanvasImageSource
let blob: Blob | null = null; const offscreen = 'OffscreenCanvas' in window
let blob: Blob | null = null
if (offscreen) { if (offscreen) {
const c = new OffscreenCanvas(dw, dh); const c = new OffscreenCanvas(dw, dh)
const ctx = c.getContext('2d', { alpha: true })!; const ctx = c.getContext('2d', { alpha: true })!
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high'
ctx.drawImage(img as any, sx, sy, sw, sh, 0, 0, dw, dh); ctx.drawImage(source, sx, sy, sw, sh, 0, 0, dw, dh)
// 4) Format mit Fallbacks // 4) Format mit Fallbacks
const canWebp = await canEncode('image/webp'); const canWebp = await canEncode('image/webp')
const canJpeg = await canEncode('image/jpeg'); const canJpeg = await canEncode('image/jpeg')
const targetMime = const targetMime =
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' : (wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' : (wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
canWebp ? 'image/webp' : canWebp ? 'image/webp' :
canJpeg ? 'image/jpeg' : 'image/png'; canJpeg ? 'image/jpeg' : 'image/png'
blob = await (c as any).convertToBlob({ type: targetMime, quality: targetMime === 'image/png' ? undefined : quality }); blob = await c.convertToBlob({ type: targetMime, quality: targetMime === 'image/png' ? undefined : quality })
} else { } else {
const c = document.createElement('canvas'); const c = document.createElement('canvas')
c.width = dw; c.height = dh; c.width = dw; c.height = dh
const ctx = c.getContext('2d')!; const ctx = c.getContext('2d')!
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high'
ctx.drawImage(img as any, sx, sy, sw, sh, 0, 0, dw, dh); ctx.drawImage(source, sx, sy, sw, sh, 0, 0, dw, dh)
const canWebp = await canEncode('image/webp'); const canWebp = await canEncode('image/webp')
const canJpeg = await canEncode('image/jpeg'); const canJpeg = await canEncode('image/jpeg')
const targetMime = const targetMime =
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' : (wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' : (wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
canWebp ? 'image/webp' : canWebp ? 'image/webp' :
canJpeg ? 'image/jpeg' : 'image/png'; canJpeg ? 'image/jpeg' : 'image/png'
blob = await new Promise<Blob | null>((res) => blob = await new Promise<Blob | null>((res) =>
c.toBlob(b => res(b), targetMime, targetMime === 'image/png' ? undefined : quality) c.toBlob(b => res(b), targetMime, targetMime === 'image/png' ? undefined : quality)
); )
} }
// Cleanup if (url) URL.revokeObjectURL(url)
if (url) URL.revokeObjectURL(url); if ('close' in (img as ImageBitmap)) { try { (img as ImageBitmap).close() } catch {} }
if ('close' in (img as any)) { try { (img as ImageBitmap).close(); } catch {} }
if (!blob) throw new Error('Canvas encoding failed (toBlob returned null)'); if (!blob) throw new Error('Canvas encoding failed (toBlob returned null)')
return blob; return blob
} }
// Upload mit Progress via XHR setzt filename/version direkt, kein Reload nötig
async function uploadTeamLogo(file: File) { async function uploadTeamLogo(file: File) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const formData = new FormData() const formData = new FormData()
@ -677,10 +652,12 @@ function TeamMemberViewBody({
xhr.onload = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
try { try {
const json = JSON.parse(xhr.responseText) const json: unknown = JSON.parse(xhr.responseText)
const current = useTeamStore.getState().team const current = useTeamStore.getState().team
if (json?.filename && current) setTeam({ ...current, logo: json.filename }) const filename = (json as { filename?: string }).filename
if (json?.version) setLogoVersion(json.version) const version = (json as { version?: number }).version
if (filename && current) setTeam({ ...current, logo: filename })
if (typeof version === 'number') setLogoVersion(version)
} catch {} } catch {}
resolve() resolve()
} else { } else {
@ -709,7 +686,6 @@ function TeamMemberViewBody({
> >
<Link <Link
href={`/profile/${player.steamId}`} href={`/profile/${player.steamId}`}
passHref
onClick={e => { if (isDragging) e.preventDefault() }} onClick={e => { if (isDragging) e.preventDefault() }}
> >
<SortableMiniCard <SortableMiniCard
@ -769,7 +745,6 @@ function TeamMemberViewBody({
unoptimized unoptimized
/> />
{/* Hover-Overlay nur, wenn klickbar */}
{canManage && isClickable && ( {canManage && isClickable && (
<div className="absolute inset-0 bg-black/50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 bg-black/50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 mb-1" viewBox="0 0 576 512" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 mb-1" viewBox="0 0 576 512" fill="currentColor">
@ -778,7 +753,6 @@ function TeamMemberViewBody({
</div> </div>
)} )}
{/* Progress-Kreis (Start bei 12 Uhr via rotate(-90 …)) */}
{isUploadingLogo && ( {isUploadingLogo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} className="absolute"> <svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} className="absolute">
@ -810,24 +784,22 @@ function TeamMemberViewBody({
className="hidden" className="hidden"
disabled={!isClickable} disabled={!isClickable}
onChange={async (e) => { onChange={async (e) => {
if (isUploadingLogo) return; if (isUploadingLogo) return
const file = e.target.files?.[0]; const file = e.target.files?.[0]
if (!file) return; if (!file) return
try { try {
const blob = await downscaleImage(file, { size: 512, quality: 0.85, mime: 'image/webp', square: true }); const blob = await downscaleImage(file, { size: 512, quality: 0.85, mime: 'image/webp', square: true })
// Dateiendung passend zum MIME bestimmen (nur kosmetisch) const mime = blob.type || 'image/webp'
const mime = blob.type || 'image/webp'; const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp'
const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp'; const processed = new File([blob], `${team!.id}.${ext}`, { type: mime })
const processed = new File([blob], `${team!.id}.${ext}`, { type: mime }); await uploadTeamLogo(processed)
await uploadTeamLogo(processed);
} catch (err) { } catch (err) {
console.error('Fehler beim Hochladen des Logos:', err); console.error('Fehler beim Hochladen des Logos:', err)
alert('Fehler beim Hochladen des Logos.'); alert('Fehler beim Hochladen des Logos.')
} finally { } finally {
setTimeout(() => { setIsUploadingLogo(false); setUploadPct(0); }, 300); setTimeout(() => { setIsUploadingLogo(false); setUploadPct(0) }, 300)
e.currentTarget.value = ''; e.currentTarget.value = ''
} }
}} }}
/> />
@ -880,7 +852,6 @@ function TeamMemberViewBody({
<TeamPremierRankBadge players={activePlayers} /> <TeamPremierRankBadge players={activePlayers} />
</div> </div>
{/* Beitritts-Einstellungen (nur Leader/Admin) */}
{canManage && ( {canManage && (
<> <>
<Button <Button
@ -897,12 +868,12 @@ function TeamMemberViewBody({
Bearbeiten Bearbeiten
</Button> </Button>
{/* 🔽 Dezente Policy-Pill */} {/* Policy-Pill */}
<div className="relative" ref={policyMenuRef}> <div className="relative" ref={policyMenuRef}>
<button <button
type="button" type="button"
onPointerDownCapture={(e) => { e.stopPropagation(); }} // verhindert Drag/Link schon sehr früh onPointerDownCapture={(e) => { e.stopPropagation() }}
onMouseDown={(e) => e.stopPropagation()} // fallback onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setShowPolicyMenu(v => !v) }} onClick={(e) => { e.stopPropagation(); setShowPolicyMenu(v => !v) }}
className="h-[32px] px-2.5 rounded-xl text-xs border border-gray-300 dark:border-neutral-600 className="h-[32px] px-2.5 rounded-xl text-xs border border-gray-300 dark:border-neutral-600
bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-200
@ -926,11 +897,10 @@ function TeamMemberViewBody({
</button> </button>
{showPolicyMenu && ( {showPolicyMenu && (
<>
<div <div
className="absolute right-0 z-[60] mt-1 w-56 rounded-md border border-gray-200 className="absolute right-0 z-[60] mt-1 w-56 rounded-md border border-gray-200
dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-lg p-1" dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-lg p-1"
onPointerDownCapture={(e) => e.stopPropagation()} // Klicks bleiben im Menü onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
@ -963,10 +933,8 @@ function TeamMemberViewBody({
</div> </div>
</button> </button>
</div> </div>
</>
)} )}
</div> </div>
{/* 🔼 Ende Policy-Pill */}
</> </>
)} )}
</> </>
@ -1038,7 +1006,7 @@ function TeamMemberViewBody({
<div className="w-full rounded-lg p-4 transition-colors min-h-[200px] border border-gray-300 dark:border-neutral-700"> <div className="w-full rounded-lg p-4 transition-colors min-h-[200px] border border-gray-300 dark:border-neutral-700">
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]"> <div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
<AnimatePresence> <AnimatePresence>
{invitedPlayers.map((player: InvitedPlayer) => ( {invitedPlayers.map((player) => (
<motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}> <motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
<MiniCard <MiniCard
steamId={player.steamId} steamId={player.steamId}
@ -1053,7 +1021,8 @@ function TeamMemberViewBody({
isSelectable={false} isSelectable={false}
isInvite={true} isInvite={true}
rank={player.premierRank} rank={player.premierRank}
invitationId={(player as any).invitationId} // optional lokales Extra-Feld sicher lesen
invitationId={(player as InvitedPlayer & { invitationId?: string }).invitationId}
onKick={async (sid) => { onKick={async (sid) => {
setInvitedPlayers(list => list.filter(p => p.steamId !== sid)) setInvitedPlayers(list => list.filter(p => p.steamId !== sid))
try { try {
@ -1061,7 +1030,7 @@ function TeamMemberViewBody({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
invitationId: (player as any).invitationId ?? undefined, invitationId: (player as InvitedPlayer & { invitationId?: string }).invitationId ?? undefined,
teamId: team.id, teamId: team.id,
steamId: sid, steamId: sid,
}), }),

View File

@ -6,20 +6,26 @@ import ComboBox from '../components/ComboBox'
type ComboItem = { id: string; label: string } type ComboItem = { id: string; label: string }
function toComboItems(raw: any): ComboItem[] { const isRecord = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object' && !Array.isArray(v)
function toComboItems(raw: unknown): ComboItem[] {
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
return raw return raw
.map((t) => { .map((t: unknown) => {
if (typeof t === 'string') return { id: t, label: t } if (typeof t === 'string') return { id: t, label: t }
if (t && typeof t === 'object') { if (isRecord(t)) {
const id = const id = String(
String(t.id ?? t.teamId ?? t.value ?? t.slug ?? t.name ?? '') t.id ?? t.teamId ?? t.value ?? t.slug ?? t.name ?? ''
const label = String(t.name ?? t.label ?? t.teamname ?? id) )
const label = String(
(t.name ?? t.label ?? (t as Record<string, unknown>).teamname ?? id) as string
)
return id ? { id, label } : null return id ? { id, label } : null
} }
return null return null
}) })
.filter(Boolean) as ComboItem[] .filter((x): x is ComboItem => !!x)
} }
export default function TeamSelector() { export default function TeamSelector() {
@ -30,10 +36,10 @@ export default function TeamSelector() {
;(async () => { ;(async () => {
try { try {
const res = await fetch('/api/teams', { cache: 'no-store' }) const res = await fetch('/api/teams', { cache: 'no-store' })
const data = await res.json() const data: unknown = await res.json()
const items = toComboItems(data?.teams) const items = toComboItems((isRecord(data) ? data.teams : undefined) as unknown)
setTeams(items) setTeams(items)
if (!selectedTeam && items[0]) setSelectedTeam(items[0].id) // optional: default auswählen if (!selectedTeam && items[0]) setSelectedTeam(items[0].id)
} catch (err) { } catch (err) {
console.error('Fehler beim Laden der Teams:', err) console.error('Fehler beim Laden der Teams:', err)
} }

View File

@ -1,14 +1,16 @@
// /src/app/[locale]/components/TelemetrySocket.tsx // /src/app/[locale]/components/TelemetrySocket.tsx
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore' import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
import { usePresenceStore } from '@/lib/usePresenceStore' import { usePresenceStore } from '@/lib/usePresenceStore'
import { useTelemetryStore } from '@/lib/useTelemetryStore' import { useTelemetryStore } from '@/lib/useTelemetryStore'
import { useMatchRosterStore } from '@/lib/useMatchRosterStore' import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import type { SSEEventType } from '@/lib/sseEvents'
/* ===================== helpers & types ===================== */
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) { function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
const h = (host ?? '').trim() || '127.0.0.1' const h = (host ?? '').trim() || '127.0.0.1'
@ -22,8 +24,66 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string)
return `${proto}://${h}${portPart}${pa}` return `${proto}://${h}${portPart}${pa}`
} }
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '') function toSnapshotList(arr: PlayerLike[]): SnapshotPlayer[] {
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String)) const out: SnapshotPlayer[] = [];
for (const p of arr) {
const sid = p.steamId ?? p.steam_id ?? p.id;
if (sid == null) continue; // ohne ID überspringen
out.push({ steamId: sid, name: p.name, team: p.team });
}
return out;
}
type PlayerLike = {
steamId?: string | number
steam_id?: string | number
id?: string | number
name?: string
team?: unknown
}
type SnapshotPlayer = { steamId: string | number; name?: string; team?: unknown };
const sidOf = (p: unknown): string => {
if (p && typeof p === 'object') {
const o = p as PlayerLike
const raw = o.steamId ?? o.steam_id ?? o.id
return raw != null ? String(raw) : ''
}
return ''
}
/* ---- incoming telemetry message shapes ---- */
type PlayersMsg = { type: 'players'; players: PlayerLike[] }
type PlayerJoinMsg = { type: 'player_join'; player: PlayerLike }
type PlayerLeaveMsg = { type: 'player_leave'; steamId?: string | number; steam_id?: string | number; id?: string | number }
type MapMsg = { type: 'map'; name: string }
type PhaseMsg = { type: 'phase'; phase: string }
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null
}
function isPlayersMsg(v: unknown): v is PlayersMsg {
return isObject(v) && v.type === 'players' && Array.isArray((v as PlayersMsg).players)
}
function isPlayerJoinMsg(v: unknown): v is PlayerJoinMsg {
return isObject(v) && v.type === 'player_join' && isObject((v as PlayerJoinMsg).player)
}
function isPlayerLeaveMsg(v: unknown): v is PlayerLeaveMsg {
return isObject(v) && v.type === 'player_leave'
}
function isMapMsg(v: unknown): v is MapMsg {
return isObject(v) && v.type === 'map' && typeof (v as MapMsg).name === 'string'
}
function isPhaseMsg(v: unknown): v is PhaseMsg {
return isObject(v) && v.type === 'phase' && typeof (v as PhaseMsg).phase === 'string'
}
const shouldRefetchRoster = (t?: SSEEventType | string | null | undefined) =>
!!t && ['match-updated', 'match-ready', 'map-vote-updated', 'match-exported'].includes(String(t))
/* ===================== component ===================== */
export default function TelemetrySocket() { export default function TelemetrySocket() {
// WS-URL aus ENV ableiten // WS-URL aus ENV ableiten
@ -40,7 +100,7 @@ export default function TelemetrySocket() {
// aktiver User // aktiver User
const { data: session } = useSession() const { data: session } = useSession()
const mySteamId = (session?.user as any)?.steamId ?? null const mySteamId = (session?.user as { steamId?: string } | undefined)?.steamId ?? null
// Overlay-Steuerung // Overlay-Steuerung
const hideOverlay = useReadyOverlayStore((s) => s.hide) const hideOverlay = useReadyOverlayStore((s) => s.hide)
@ -52,40 +112,42 @@ export default function TelemetrySocket() {
const setMapKey = useTelemetryStore((s) => s.setMapKey) const setMapKey = useTelemetryStore((s) => s.setMapKey)
const setPhase = useTelemetryStore((s) => s.setPhase) const setPhase = useTelemetryStore((s) => s.setPhase)
// 👇 NEU: online-Status für GameBanner
const setOnline = useTelemetryStore((s) => s.setOnline) const setOnline = useTelemetryStore((s) => s.setOnline)
// Roster-Store // Roster-Store
const setRoster = useMatchRosterStore((s) => s.setRoster) const setRoster = useMatchRosterStore((s) => s.setRoster)
const clearRoster = useMatchRosterStore((s) => s.clearRoster) const clearRoster = useMatchRosterStore((s) => s.clearRoster)
// lokaler Telemetry-Set (wer ist per WS online)
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
// SSE -> bei relevanten Events Roster nachladen // SSE -> bei relevanten Events Roster nachladen
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
async function fetchCurrentRoster() { const fetchCurrentRoster = useCallback(async () => {
try { try {
const r = await fetch('/api/matches/current', { cache: 'no-store' }) const r = await fetch('/api/matches/current', { cache: 'no-store' })
if (!r.ok) return if (!r.ok) return
const j = await r.json() const j: unknown = await r.json()
const ids: string[] = Array.isArray(j?.steamIds) ? j.steamIds : [] const ids: string[] = Array.isArray((j as Record<string, unknown>)?.steamIds)
? ((j as Record<string, unknown>).steamIds as unknown[]).map(String)
: []
if (ids.length) setRoster(ids) if (ids.length) setRoster(ids)
else clearRoster() else clearRoster()
} catch {} } catch {
// still
} }
}, [setRoster, clearRoster])
// initial + bei Events // initial + bei Events
useEffect(() => { fetchCurrentRoster() }, []) useEffect(() => {
fetchCurrentRoster()
}, [fetchCurrentRoster])
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
const t = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type const t = lastEvent.type ?? (isObject(lastEvent.payload) ? (lastEvent.payload as Record<string, unknown>).type : undefined)
if (['match-updated', 'match-ready', 'map-vote-updated', 'match-exported'].includes(String(t))) { if (shouldRefetchRoster(t as SSEEventType | string)) {
fetchCurrentRoster() fetchCurrentRoster()
} }
}, [lastEvent]) }, [lastEvent, fetchCurrentRoster])
// wenn User ab-/anmeldet → Online-Flag sinnvoll zurücksetzen // wenn User ab-/anmeldet → Online-Flag sinnvoll zurücksetzen
useEffect(() => { useEffect(() => {
@ -104,17 +166,21 @@ export default function TelemetrySocket() {
if (!aliveRef.current || !url) return if (!aliveRef.current || !url) return
// nicht doppelt verbinden // nicht doppelt verbinden
if (wsRef.current && ( if (
wsRef.current.readyState === WebSocket.OPEN || wsRef.current &&
wsRef.current.readyState === WebSocket.CONNECTING (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)
)) return )
return
const ws = new WebSocket(url) const ws = new WebSocket(url)
wsRef.current = ws wsRef.current = ws
ws.onopen = () => { ws.onopen = () => {
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open') if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open')
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null } if (retryRef.current) {
window.clearTimeout(retryRef.current)
retryRef.current = null
}
} }
ws.onerror = () => { ws.onerror = () => {
@ -135,59 +201,56 @@ export default function TelemetrySocket() {
} }
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
let msg: any = null let msg: unknown
try { msg = JSON.parse(String(ev.data ?? '')) } catch {} try {
if (!msg) return msg = JSON.parse(String(ev.data ?? ''))
} catch {
return
}
// komplette Playerliste // komplette Playerliste
if (msg.type === 'players' && Array.isArray(msg.players)) { if (isPlayersMsg(msg)) {
setSnapshot(msg.players) setSnapshot(toSnapshotList(msg.players));
const ids = msg.players.map(sidOf).filter(Boolean) const ids = msg.players.map(sidOf).filter(Boolean)
setTelemetrySet(toSet(ids))
const mePresent = !!mySteamId && ids.includes(String(mySteamId)) const mePresent = !!mySteamId && ids.includes(String(mySteamId))
setOnline(!!mePresent) setOnline(!!mePresent)
if (mePresent) hideOverlay() if (mePresent) hideOverlay()
return
} }
// join/leave deltas // join/leave deltas
if (msg.type === 'player_join' && msg.player) { if (isPlayerJoinMsg(msg)) {
setJoin(msg.player) const sid = msg.player.steamId ?? msg.player.steam_id ?? msg.player.id;
setTelemetrySet(prev => { if (sid != null) {
const next = new Set(prev) setJoin({ steamId: sid, name: msg.player.name, team: msg.player.team }); // ✅ required steamId
const sid = sidOf(msg.player) if (mySteamId && String(sid) === String(mySteamId)) {
if (sid) next.add(sid) setOnline(true);
return next hideOverlay();
})
const sid = sidOf(msg.player)
if (mySteamId && sid === String(mySteamId)) {
setOnline(true)
hideOverlay()
} }
} }
return;
}
if (msg.type === 'player_leave') { if (isPlayerLeaveMsg(msg)) {
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '') const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '')
if (sid) setLeave(sid) if (sid) setLeave(sid)
setTelemetrySet(prev => {
const next = new Set(prev)
if (sid) next.delete(sid)
return next
})
if (mySteamId && sid === String(mySteamId)) { if (mySteamId && sid === String(mySteamId)) {
setOnline(false) setOnline(false)
} }
return
} }
// Map-Key und Phase ins Telemetry-Store schreiben // Map-Key und Phase ins Telemetry-Store schreiben
if (msg.type === 'map' && typeof msg.name === 'string') { if (isMapMsg(msg)) {
const key = msg.name.toLowerCase() const key = msg.name.toLowerCase()
setMapKey(key) setMapKey(key)
return
} }
if (msg.type === 'phase' && typeof msg.phase === 'string') {
setPhase(String(msg.phase).toLowerCase() as any) if (isPhaseMsg(msg)) {
// phase-Typ auf den Store-Parameter abbilden, ohne any
const nextPhase = String(msg.phase).toLowerCase() as Parameters<typeof setPhase>[0]
setPhase(nextPhase)
} }
} }
} }
@ -195,12 +258,16 @@ export default function TelemetrySocket() {
connectOnce() connectOnce()
return () => { return () => {
aliveRef.current = false aliveRef.current = false
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null } if (retryRef.current) {
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {} window.clearTimeout(retryRef.current)
retryRef.current = null
}
try {
wsRef.current?.close(1000, 'telemetry socket unmounted')
} catch {}
setOnline(false) setOnline(false)
} }
}, [url, hideOverlay, mySteamId, setJoin, setLeave, setMapKey, setPhase, setSnapshot, setOnline]) }, [url, hideOverlay, mySteamId, setJoin, setLeave, setMapKey, setPhase, setSnapshot, setOnline])
// ⬇️ WICHTIG: Kein Banner-Rendering mehr hier. UI kommt aus GameBannerHost.
return null return null
} }

View File

@ -1,4 +1,4 @@
// /src/app/components/UserAvatarWithStatus.tsx // /src/app/[locale]/components/UserAvatarWithStatus.tsx
'use client' 'use client'
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
@ -22,6 +22,34 @@ type Props = React.HTMLAttributes<HTMLDivElement> & {
avatarClassName?: string avatarClassName?: string
} }
function isPresence(v: unknown): v is Presence {
return v === 'online' || v === 'away' || v === 'offline'
}
type MaybeStatusPayload =
| { steamId?: unknown; status?: unknown }
| { payload?: { steamId?: unknown; status?: unknown } }
| Record<string, unknown>
| unknown
function extractStatusPayload(v: MaybeStatusPayload): { steamId?: string; status?: Presence } {
if (typeof v !== 'object' || v == null) return {}
const top = v as Record<string, unknown>
const directSteam = typeof top.steamId === 'string' ? top.steamId : undefined
const directStatus = isPresence(top.status) ? top.status : undefined
if (directSteam && directStatus) return { steamId: directSteam, status: directStatus }
const nested = (typeof top.payload === 'object' && top.payload != null)
? (top.payload as Record<string, unknown>)
: undefined
const nestedSteam = typeof nested?.steamId === 'string' ? nested.steamId : undefined
const nestedStatus = isPresence(nested?.status) ? nested.status : undefined
return { steamId: nestedSteam, status: nestedStatus }
}
export default function UserAvatarWithStatus({ export default function UserAvatarWithStatus({
steamId, steamId,
src, src,
@ -31,20 +59,21 @@ export default function UserAvatarWithStatus({
isLeader = false, isLeader = false,
alignRight = false, alignRight = false,
showStatus = true, showStatus = true,
className, // <— NEU: Klassen für den äußeren Wrapper className,
avatarClassName, // <— NEU: Klassen für den inneren Avatar-Container avatarClassName,
...rest // <— NEU: alle weiteren div-Props (onClick, title, …) ...rest
}: Props) { }: Props) {
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const [status, setStatus] = useState<Presence>(initialStatus) const [status, setStatus] = useState<Presence>(initialStatus)
const { crownSize, crownOffset, crownIconSize } = useMemo(() => { const { crownSize, crownOffset, crownIconSize } = useMemo(() => {
const cs = Math.min(20, Math.round(Math.max(size * 0.5, 14))) const cs = Math.min(20, Math.round(Math.max(size * 0.5, 14)))
const off = Math.round(size * 0.10) const off = Math.round(size * 0.1)
const icon = Math.round(cs * 0.70) const icon = Math.round(cs * 0.7)
return { crownSize: cs, crownOffset: off, crownIconSize: icon } return { crownSize: cs, crownOffset: off, crownIconSize: icon }
}, [size]) }, [size])
// Initialstatus vom Server holen (optional)
useEffect(() => { useEffect(() => {
if (!showStatus || !steamId) return if (!showStatus || !steamId) return
let alive = true let alive = true
@ -52,19 +81,29 @@ export default function UserAvatarWithStatus({
try { try {
const res = await fetch(`/api/user/${steamId}`, { cache: 'no-store' }) const res = await fetch(`/api/user/${steamId}`, { cache: 'no-store' })
if (!res.ok) return if (!res.ok) return
const data = await res.json() const data: unknown = await res.json()
if (alive) setStatus((data?.user?.status ?? 'offline') as Presence) const next = typeof data === 'object' && data != null
} catch {} ? (data as Record<string, unknown>).user
: undefined
const nextStatus =
typeof next === 'object' && next != null && isPresence((next as Record<string, unknown>).status)
? ((next as Record<string, unknown>).status as Presence)
: undefined
if (alive && nextStatus) setStatus(nextStatus)
} catch {
// still
}
})() })()
return () => { alive = false } return () => {
alive = false
}
}, [steamId, showStatus]) }, [steamId, showStatus])
// Live-Updates via SSE
useEffect(() => { useEffect(() => {
if (!showStatus || !steamId) return if (!showStatus || !steamId) return
if (!lastEvent || lastEvent.type !== 'user-status-updated') return if (!lastEvent || lastEvent.type !== 'user-status-updated') return
const raw = lastEvent.payload as any const { steamId: sid, status: st } = extractStatusPayload(lastEvent.payload as MaybeStatusPayload)
const sid = raw?.steamId ?? raw?.payload?.steamId
const st = (raw?.status ?? raw?.payload?.status) as Presence | undefined
if (sid === steamId && st) setStatus(st) if (sid === steamId && st) setStatus(st)
}, [lastEvent, steamId, showStatus]) }, [lastEvent, steamId, showStatus])
@ -113,7 +152,13 @@ export default function UserAvatarWithStatus({
...(alignRight ? { left: -crownOffset } : { right: -crownOffset }), ...(alignRight ? { left: -crownOffset } : { right: -crownOffset }),
}} }}
> >
<svg xmlns="http://www.w3.org/2000/svg" height={crownIconSize} width={crownIconSize} fill="currentColor" viewBox="0 0 640 640"> <svg
xmlns="http://www.w3.org/2000/svg"
height={crownIconSize}
width={crownIconSize}
fill="currentColor"
viewBox="0 0 640 640"
>
<path d="M345 151.2C354.2 143.9 360 132.6 360 120C360 97.9 342.1 80 320 80C297.9 80 280 97.9 280 120C280 132.6 285.9 143.9 295 151.2L226.6 258.8C216.6 274.5 195.3 278.4 180.4 267.2L120.9 222.7C125.4 216.3 128 208.4 128 200C128 177.9 110.1 160 88 160C65.9 160 48 177.9 48 200C48 221.8 65.5 239.6 87.2 240L119.8 457.5C124.5 488.8 151.4 512 183.1 512L456.9 512C488.6 512 515.5 488.8 520.2 457.5L552.8 240C574.5 239.6 592 221.8 592 200C592 177.9 574.1 160 552 160C529.9 160 512 177.9 512 200C512 208.4 514.6 216.3 519.1 222.7L459.7 267.3C444.8 278.5 423.5 274.6 413.5 258.9L345 151.2z" /> <path d="M345 151.2C354.2 143.9 360 132.6 360 120C360 97.9 342.1 80 320 80C297.9 80 280 97.9 280 120C280 132.6 285.9 143.9 295 151.2L226.6 258.8C216.6 274.5 195.3 278.4 180.4 267.2L120.9 222.7C125.4 216.3 128 208.4 128 200C128 177.9 110.1 160 88 160C65.9 160 48 177.9 48 200C48 221.8 65.5 239.6 87.2 240L119.8 457.5C124.5 488.8 151.4 512 183.1 512L456.9 512C488.6 512 515.5 488.8 520.2 457.5L552.8 240C574.5 239.6 592 221.8 592 200C592 177.9 574.1 160 552 160C529.9 160 512 177.9 512 200C512 208.4 514.6 216.3 519.1 222.7L459.7 267.3C444.8 278.5 423.5 274.6 413.5 258.9L345 151.2z" />
</svg> </svg>
</span> </span>

View File

@ -1,37 +1,10 @@
// /src/app/[locale]/components/admin/MatchesAdminManager.tsx
'use client' 'use client'
import { useEffect, useState } from 'react'
import CommunityMatchList from '../CommunityMatchList' import CommunityMatchList from '../CommunityMatchList'
function getRoundedDate() {
const now = new Date()
const minutes = now.getMinutes()
const roundedMinutes = Math.ceil(minutes / 15) * 15
now.setMinutes(roundedMinutes === 60 ? 0 : roundedMinutes)
if (roundedMinutes === 60) now.setHours(now.getHours() + 1)
now.setSeconds(0)
now.setMilliseconds(0)
return now
}
export default function MatchesAdminManager() { export default function MatchesAdminManager() {
const [teams, setTeams] = useState<any[]>([])
const [matches, setMatches] = useState<any[]>([])
const [teamAId, setTeamAId] = useState('')
const [teamBId, setTeamBId] = useState('')
const [title, setTitle] = useState('')
const [titleManuallySet, setTitleManuallySet] = useState(false)
useEffect(() => {
if (!titleManuallySet && teamAId && teamBId && teamAId !== teamBId) {
const teamA = teams.find(t => t.id === teamAId)
const teamB = teams.find(t => t.id === teamBId)
if (teamA && teamB) {
setTitle(`${teamA.name} vs ${teamB.name}`)
}
}
}, [teamAId, teamBId, teams, titleManuallySet])
return ( return (
<CommunityMatchList matchType="community" /> <CommunityMatchList matchType="community" />
) )

View File

@ -3,7 +3,6 @@
'use client' 'use client'
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import Button from '../../Button' import Button from '../../Button'
import Modal from '../../Modal' import Modal from '../../Modal'
import Input from '../../Input' import Input from '../../Input'
@ -12,8 +11,6 @@ import type { Team } from '@/types/team'
import LoadingSpinner from '../../LoadingSpinner' import LoadingSpinner from '../../LoadingSpinner'
export default function AdminTeamsView() { export default function AdminTeamsView() {
/* ────────────────────────── Session ─────────────────────────── */
const { data: session } = useSession()
/* ─────────────────────────── State ───────────────────────────── */ /* ─────────────────────────── State ───────────────────────────── */
const [teams, setTeams] = useState<Team[]>([]) const [teams, setTeams] = useState<Team[]>([])

View File

@ -3,6 +3,7 @@ import Card from '../../Card'
import PremierRankBadge from '../../PremierRankBadge' import PremierRankBadge from '../../PremierRankBadge'
import CompRankBadge from '../../CompRankBadge' import CompRankBadge from '../../CompRankBadge'
import { MAP_OPTIONS } from '@/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import Image from 'next/image'
type Props = { steamId: string } type Props = { steamId: string }
@ -191,13 +192,15 @@ const iconForMap = (raw: string) => {
const withPrefix = k.startsWith('de_') ? k : `de_${k}` const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg` return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg`
} }
const bgForMap = (raw: string) => { const bgForMap = (raw: string) => {
const k = normKey(raw) const k = normKey(raw)
const opt: any = MAP_OPTIONS.find(o => o.key === k) const opt = MAP_OPTIONS.find(o => o.key === k)
if (opt?.images?.length) return String(opt.images[0]) if (opt?.images?.length) return String(opt.images[0])
const withPrefix = k.startsWith('de_') ? k : `de_${k}` const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return `/assets/img/maps/${withPrefix}.webp` return `/assets/img/maps/${withPrefix}.webp`
} }
const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
const parseScoreString = (raw?: string | null): [number | null, number | null] => { const parseScoreString = (raw?: string | null): [number | null, number | null] => {
@ -436,7 +439,13 @@ export default async function Profile({ steamId }: Props) {
{/* Map + Meta */} {/* Map + Meta */}
<div className="relative z-[1] flex items-center gap-3 shrink-0"> <div className="relative z-[1] flex items-center gap-3 shrink-0">
<div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden"> <div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden">
<img src={iconSrc} alt={mapLabel} className="h-10 w-10 object-contain" loading="lazy" /> <Image
src={iconSrc}
alt={mapLabel}
width={40}
height={40}
className="h-10 w-10 object-contain"
/>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-neutral-300/90">{fmtDateTime(m.date)}</div> <div className="text-xs text-neutral-300/90">{fmtDateTime(m.date)}</div>

View File

@ -4,6 +4,7 @@ import Card from '../../../Card'
import PremierRankBadge from '../../../PremierRankBadge' import PremierRankBadge from '../../../PremierRankBadge'
import CompRankBadge from '../../../CompRankBadge' import CompRankBadge from '../../../CompRankBadge'
import { MAP_OPTIONS } from '@/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import Image from 'next/image'
type Props = { steamId: string } type Props = { steamId: string }
@ -61,16 +62,9 @@ const iconForMap = (raw: string) => {
return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg` return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg`
} }
const typeIconFor = (t?: string | null) =>
t === 'premier'
? '/assets/img/icons/ui/competitive_teams.svg'
: t === 'competitive'
? '/assets/img/icons/ui/competitive.svg'
: null
const bgForMap = (raw: string) => { const bgForMap = (raw: string) => {
const k = normKey(raw) const k = normKey(raw)
const opt: any = MAP_OPTIONS.find(o => o.key === k) const opt = MAP_OPTIONS.find(o => o.key === k)
if (opt?.images?.length) return String(opt.images[0]) if (opt?.images?.length) return String(opt.images[0])
const withPrefix = k.startsWith('de_') ? k : `de_${k}` const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return `/assets/img/maps/${withPrefix}.webp` return `/assets/img/maps/${withPrefix}.webp`
@ -231,7 +225,12 @@ export default async function MatchesList({ steamId }: Props) {
{/* LINKS: Map + Meta */} {/* LINKS: Map + Meta */}
<div className="relative z-[1] flex items-center gap-3 shrink-0"> <div className="relative z-[1] flex items-center gap-3 shrink-0">
<div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden"> <div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden">
<img src={iconSrc} alt={mapLabel} className="h-10 w-10 object-contain" loading="lazy" /> <Image
src={iconSrc}
alt={mapLabel}
width={40}
height={40}className="h-10 w-10 object-contain"
/>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">

View File

@ -1,7 +1,9 @@
// /src/app/[locale]/components/profile/[steamId]/stats/StatsView.tsx
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSession } from 'next-auth/react' import type { TooltipItem } from 'chart.js'
import Chart from '../../../Chart' import Chart from '../../../Chart'
import Card from '../../../Card' import Card from '../../../Card'
import { MatchStats } from '@/types/match' import { MatchStats } from '@/types/match'
@ -45,15 +47,20 @@ const tone = {
const fmtADR = (v: number) => const fmtADR = (v: number) =>
new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v) new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v)
/** Runden robust ziehen */ /** Runden robust ziehen (ohne any) */
function getRounds(m: Partial<MatchStats>): number { function getRounds(m: Partial<MatchStats>): number {
const r = (k: string): number | null => {
const v = (m as Record<string, unknown>)[k]
const n = Number(v)
return Number.isFinite(n) ? n : null
}
return ( return (
(m as any).rounds ?? r('rounds') ??
(m as any).roundCount ?? r('roundCount') ??
(m as any).roundsPlayed ?? r('roundsPlayed') ??
(m as any).roundsTotal ?? r('roundsTotal') ??
0 0
) || 0 )
} }
/* kleine Sparkline */ /* kleine Sparkline */
@ -126,11 +133,11 @@ function perfOfMatch(m: Partial<MatchStats>) {
const d = m.deaths ?? 0 const d = m.deaths ?? 0
const a = m.assists ?? 0 const a = m.assists ?? 0
const r = Math.max(1, getRounds(m)) const r = Math.max(1, getRounds(m))
const kd = d > 0 ? k / d : KD_CAP const kdV = d > 0 ? k / d : KD_CAP
const adr = (m.totalDamage ?? 0) / r const adr = (m.totalDamage ?? 0) / r
const kpr = k / r const kpr = k / r
const apr = a / r const apr = a / r
const kdS = clamp01(kd / KD_CAP) const kdS = clamp01(kdV / KD_CAP)
const adrS = clamp01(adr / ADR_CAP) const adrS = clamp01(adr / ADR_CAP)
const kprS = clamp01(kpr / KPR_CAP) const kprS = clamp01(kpr / KPR_CAP)
const aprS = clamp01(apr / APR_CAP) const aprS = clamp01(apr / APR_CAP)
@ -139,8 +146,11 @@ function perfOfMatch(m: Partial<MatchStats>) {
/* Hauptkomponente */ /* Hauptkomponente */
export default function StatsView({ steamId, stats }: Props) { export default function StatsView({ steamId, stats }: Props) {
const { data: session } = useSession() // Matches-Array stabilisieren, damit useMemo-Dep sauber bleibt
const allMatches = stats.matches ?? [] const allMatches = useMemo<MatchStats[]>(
() => stats.matches ?? [],
[stats.matches]
)
/* ─ Filter: 30 | 90 | Alle ─ */ /* ─ Filter: 30 | 90 | Alle ─ */
const [range, setRange] = useState<'30' | '90' | 'all'>('30') const [range, setRange] = useState<'30' | '90' | 'all'>('30')
@ -159,7 +169,8 @@ export default function StatsView({ steamId, stats }: Props) {
// ► ADR-Berechnung // ► ADR-Berechnung
const totalRounds = matches.reduce((s, m) => s + getRounds(m), 0) const totalRounds = matches.reduce((s, m) => s + getRounds(m), 0)
const adrOverall = totalRounds > 0 ? totalDamage / totalRounds : (matches.length ? totalDamage / matches.length : 0) const adrOverall =
totalRounds > 0 ? totalDamage / totalRounds : (matches.length ? totalDamage / matches.length : 0)
const overallKD = kd(totalKills, totalDeaths) const overallKD = kd(totalKills, totalDeaths)
const dateLabels = matches.map((m) => fmtShortDate(m.date)) const dateLabels = matches.map((m) => fmtShortDate(m.date))
@ -187,7 +198,7 @@ export default function StatsView({ steamId, stats }: Props) {
}, [matches]) }, [matches])
const mapKeys = Object.keys(killsPerMap) const mapKeys = Object.keys(killsPerMap)
const mapLabels = mapKeys.map((k) => const mapLabels = mapKeys.map((k) =>
k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
) )
/* Winrate je Map (vom API) */ /* Winrate je Map (vom API) */
@ -211,13 +222,11 @@ export default function StatsView({ steamId, stats }: Props) {
} }
} }
})() })()
return () => { return () => { stop = true }
stop = true
}
}, [steamId]) }, [steamId])
const winPct = wrValues.map(v => Math.max(0, Math.min(100, v ?? 0))); const winPct = wrValues.map(v => Math.max(0, Math.min(100, v ?? 0)))
const lossPct = winPct.map(v => 100 - v); const lossPct = winPct.map(v => 100 - v)
// ►► Per-Match-ADR Serie für Chart // ►► Per-Match-ADR Serie für Chart
const adrPerMatch = matches.map((m) => { const adrPerMatch = matches.map((m) => {
@ -348,16 +357,8 @@ export default function StatsView({ steamId, stats }: Props) {
type="bar" type="bar"
labels={wrLabels} labels={wrLabels}
datasets={[ datasets={[
{ { label: 'Win %', data: winPct, backgroundColor: 'rgba(16,185,129,.85)' },
label: 'Win %', { label: 'Loss %', data: lossPct, backgroundColor: 'rgba(239,68,68,.85)' },
data: winPct,
backgroundColor: 'rgba(16,185,129,.85)',
},
{
label: 'Loss %',
data: lossPct,
backgroundColor: 'rgba(239,68,68,.85)',
},
]} ]}
options={{ options={{
indexAxis: 'y', indexAxis: 'y',
@ -366,7 +367,8 @@ export default function StatsView({ steamId, stats }: Props) {
legend: { position: 'bottom' }, legend: { position: 'bottom' },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (ctx: any) => `${ctx.dataset.label}: ${Number(ctx.parsed.x).toFixed(0)}%`, label: (ctx: TooltipItem<'bar'>) =>
`${ctx.dataset.label ?? ''}: ${Number(ctx.parsed.x).toFixed(0)}%`,
}, },
}, },
}, },
@ -375,15 +377,10 @@ export default function StatsView({ steamId, stats }: Props) {
stacked: true, stacked: true,
min: 0, min: 0,
max: 100, max: 100,
ticks: { ticks: { callback: (v) => `${v}%` },
callback: (v) => `${v}%`,
},
grid: { color: 'rgba(255,255,255,.08)' }, grid: { color: 'rgba(255,255,255,.08)' },
}, },
y: { y: { stacked: true, grid: { display: false } },
stacked: true,
grid: { display: false },
},
}, },
}} }}
/> />
@ -409,9 +406,7 @@ export default function StatsView({ steamId, stats }: Props) {
datasets={[ datasets={[
{ {
label: 'K/D', label: 'K/D',
data: matches.map((m) => data: matches.map((m) => (m.deaths ?? 0) > 0 ? (m.kills ?? 0) / (m.deaths ?? 1) : (m.kills ?? 0)),
(m.deaths ?? 0) > 0 ? (m.kills ?? 0) / (m.deaths ?? 1) : (m.kills ?? 0),
),
borderColor: tone.red, borderColor: tone.red,
backgroundColor: tone.redBg, backgroundColor: tone.redBg,
borderWidth: 2, borderWidth: 2,

View File

@ -1,65 +1,78 @@
// /src/app/[locale]/components/radar/GameSocket.tsx // /src/app/[locale]/components/radar/GameSocket.tsx
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error' type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
type UnknownRecord = Record<string, unknown>
type GameSocketProps = { type GameSocketProps = {
url?: string url?: string
onStatus?: (s: Status) => void onStatus?: (s: Status) => void
onMap?: (mapKey: string) => void onMap?: (mapKey: string) => void
onPlayerUpdate?: (p: any) => void onPlayerUpdate?: (p: UnknownRecord) => void
onPlayersAll?: (payload: any) => void onPlayersAll?: (payload: UnknownRecord) => void
onGrenades?: (g: any) => void onGrenades?: (g: unknown) => void
onRoundStart?: () => void onRoundStart?: () => void
onRoundEnd?: () => void onRoundEnd?: () => void
onBomb?: (b:any) => void onBomb?: (b: UnknownRecord) => void
} }
// HINZUFÜGEN: oben im Modul kleine Helfer function isObj(v: unknown): v is UnknownRecord {
function pickVec3Loose(src: any) { return !!v && typeof v === 'object'
// akzeptiert {x,y,z}, [x,y,z], "x, y, z" }
/* akzeptiert {x,y,z}, [x,y,z], "x, y, z" */
function pickVec3Loose(src: unknown): { x: number; y: number; z: number } | null {
if (!src) return null if (!src) return null
if (Array.isArray(src)) { if (Array.isArray(src)) {
const [x, y, z] = src const [x, y, z] = src as unknown[]
const nx = Number(x), ny = Number(y), nz = Number(z) const nx = Number(x), ny = Number(y), nz = Number(z)
if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 } if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
return null return null
} }
if (typeof src === 'string') { if (typeof src === 'string') {
const parts = src.split(',').map(s => Number(s.trim())) const parts = src.split(',').map((s) => Number(s.trim()))
if (parts.length >= 2 && parts.slice(0, 2).every(Number.isFinite)) { if (parts.length >= 2 && parts.slice(0, 2).every(Number.isFinite)) {
return { x: parts[0], y: parts[1], z: Number.isFinite(parts[2]) ? parts[2] : 0 } return { x: parts[0], y: parts[1], z: Number.isFinite(parts[2]) ? parts[2] : 0 }
} }
return null return null
} }
const nx = Number(src?.x), ny = Number(src?.y), nz = Number(src?.z) if (isObj(src)) {
const nx = Number((src as UnknownRecord).x)
const ny = Number((src as UnknownRecord).y)
const nz = Number((src as UnknownRecord).z)
if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 } if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
}
return null return null
} }
function extractBombPayload(msg: any): any | null { function extractBombPayload(msg: UnknownRecord): UnknownRecord | null {
// 1) Wenn msg.bomb / msg.c4 schon da ist → ggf. Position aus bekannten Feldern ergänzen const base = (msg.bomb as UnknownRecord | undefined) ?? (msg.c4 as UnknownRecord | undefined) ?? null
const base = msg?.bomb ?? msg?.c4 ?? null
// mögliche Felder, wo Positionsinfos oft landen const posCandidates: unknown[] = [
const posCandidates = [
base?.pos, base?.position, base?.location, base?.coordinates, base?.origin, base?.pos, base?.position, base?.location, base?.coordinates, base?.origin,
msg?.bomb_pos, msg?.bomb_position, msg?.bombLocation, msg?.bomblocation, (msg as UnknownRecord)['bomb_pos'],
msg?.pos, msg?.position, msg?.location, msg?.coordinates, msg?.origin, (msg as UnknownRecord)['bomb_position'],
msg?.world?.bomb, msg?.objectives?.bomb (msg as UnknownRecord)['bombLocation'],
(msg as UnknownRecord)['bomblocation'],
msg.pos, msg.position, msg.location, msg.coordinates, msg.origin,
(msg.world as UnknownRecord | undefined)?.bomb,
(msg.objectives as UnknownRecord | undefined)?.bomb,
] ]
let P = null let P: { x: number; y: number; z: number } | null = null
for (const p of posCandidates) { P = pickVec3Loose(p); if (P) break } for (const p of posCandidates) {
P = pickVec3Loose(p)
if (P) break
}
// Status aus explizitem Feld oder vom Event-Type ableiten const t = String((msg.type ?? '') as string).toLowerCase()
const t = String(msg?.type ?? '').toLowerCase()
let status: 'carried' | 'dropped' | 'planted' | 'defusing' | 'defused' | 'unknown' = 'unknown' let status: 'carried' | 'dropped' | 'planted' | 'defusing' | 'defused' | 'unknown' = 'unknown'
const s = String(base?.status ?? base?.state ?? '').toLowerCase() const s = String(((base as UnknownRecord | undefined)?.status ?? (base as UnknownRecord | undefined)?.state ?? '') as string).toLowerCase()
if (s.includes('plant')) status = 'planted' if (s.includes('plant')) status = 'planted'
else if (s.includes('drop')) status = 'dropped' else if (s.includes('drop')) status = 'dropped'
else if (s.includes('carry')) status = 'carried' else if (s.includes('carry')) status = 'carried'
else if (s.includes('defus')) status = 'defusing' else if (s.includes('defus') && !s.endsWith('ed')) status = 'defusing'
else if (s.includes('defus') && s.includes('ed')) status = 'defused' else if (s.includes('defus') && s.includes('ed')) status = 'defused'
if (t === 'bomb_planted') status = 'planted' if (t === 'bomb_planted') status = 'planted'
@ -69,94 +82,117 @@ function extractBombPayload(msg: any): any | null {
else if (t === 'bomb_abortdefuse') status = 'planted' else if (t === 'bomb_abortdefuse') status = 'planted'
else if (t === 'bomb_defused') status = 'defused' else if (t === 'bomb_defused') status = 'defused'
// Wir wollen nur liefern, wenn NICHT getragen
const notCarried = status !== 'carried' const notCarried = status !== 'carried'
if (!base && !P && !t.startsWith('bomb_')) return null if (!base && !P && !t.startsWith('bomb_')) return null
if (!notCarried && !t.startsWith('bomb_')) return null if (!notCarried && !t.startsWith('bomb_')) return null
const payload = { const bombPayload: UnknownRecord = {
// Lass LiveRadar.normalizeBomb entscheiden wir geben „bomb“ aus ...(base ?? {}),
bomb: {
...(base || {}),
...(P ? { x: P.x, y: P.y, z: P.z } : {}), ...(P ? { x: P.x, y: P.y, z: P.z } : {}),
status status,
},
// original message für evtl. weitere Felder
type: msg?.type
}
return payload
} }
return { bomb: bombPayload, type: msg.type }
}
export default function GameSocket(props: GameSocketProps) { export default function GameSocket(props: GameSocketProps) {
const { url, onStatus, onMap, onPlayerUpdate, onPlayersAll, onGrenades, onRoundStart, onRoundEnd, onBomb } = props const { url, onStatus, onMap, onPlayerUpdate, onPlayersAll, onGrenades, onRoundStart, onRoundEnd, onBomb } = props
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const retryRef = useRef<number | null>(null) const retryRef = useRef<number | null>(null)
const connectTimerRef = useRef<number | null>(null) // <- NEU const connectTimerRef = useRef<number | null>(null)
const shouldReconnectRef = useRef(true) const shouldReconnectRef = useRef(true)
const dispatch = (msg: any) => { const dispatch = useCallback(
if (!msg) return; (msg: UnknownRecord) => {
if (!msg) return
if (msg.type === 'round_start') { onRoundStart?.(); return; } const type = String((msg.type ?? '') as string)
if (msg.type === 'round_end') { onRoundEnd?.(); return; }
if (msg.type === 'tick') { if (type === 'round_start') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase()); onRoundStart?.()
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {})); return
}
const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles; if (type === 'round_end') {
if (g) onGrenades?.(g); onRoundEnd?.()
return
// 1) Bisher: direkt durchreichen
if (msg.bomb) onBomb?.(msg.bomb);
// 2) NEU: Falls keine msg.bomb vorhanden, aber Position/Status auffindbar → synthetische Bomb-Payload senden
if (!msg.bomb) {
const synth = extractBombPayload(msg);
if (synth) onBomb?.(synth);
} }
onPlayersAll?.(msg); if (type === 'tick') {
return; const mapVal = msg.map
if (typeof mapVal === 'string') onMap?.(mapVal.toLowerCase())
const players = msg.players
if (Array.isArray(players)) {
const cb = onPlayerUpdate ?? (() => undefined)
for (const p of players) {
if (isObj(p)) cb(p)
}
} }
// --- non-tick messages (hello, map, bomb_* events, etc.) --- const g =
(msg.grenades as unknown) ??
(msg.projectiles as unknown) ??
(msg.nades as unknown) ??
(msg.grenadeProjectiles as unknown)
if (g !== undefined) onGrenades?.(g)
if (msg.bomb) {
const b = msg.bomb
if (isObj(b)) onBomb?.(b)
} else {
const synth = extractBombPayload(msg)
if (synth) onBomb?.(synth)
}
onPlayersAll?.(msg)
return
}
// non-tick
if (typeof msg.map === 'string') { if (typeof msg.map === 'string') {
onMap?.(msg.map.toLowerCase()); onMap?.(msg.map.toLowerCase())
} else if (msg.map && typeof msg.map.name === 'string') { } else if (isObj(msg.map) && typeof (msg.map as UnknownRecord).name === 'string') {
onMap?.(msg.map.name.toLowerCase()); onMap?.(String((msg.map as UnknownRecord).name).toLowerCase())
} }
if (msg.allplayers) onPlayersAll?.(msg); if (msg.allplayers) onPlayersAll?.(msg)
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg);
const g2 = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles; if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg)
if (g2) onGrenades?.(g2);
// Bombe: generische Events + direkte bomb/c4-Payload const g2 =
const t = String(msg.type || '').toLowerCase(); (msg.grenades as unknown) ??
(msg.projectiles as unknown) ??
(msg.nades as unknown) ??
(msg.grenadeProjectiles as unknown)
if (g2 !== undefined) onGrenades?.(g2)
const t = String((msg.type ?? '') as string).toLowerCase()
if (msg.bomb || msg.c4) { if (msg.bomb || msg.c4) {
onBomb?.(msg); // unverändert weiterreichen onBomb?.(msg)
} else if (t.startsWith('bomb_')) { } else if (t.startsWith('bomb_')) {
// NEU: Event ohne bomb-Objekt → mit Position/Status anreichern const enriched = extractBombPayload(msg)
const enriched = extractBombPayload(msg); if (enriched) onBomb?.(enriched)
if (enriched) onBomb?.(enriched); else onBomb?.(msg)
else onBomb?.(msg); // Fallback: Event trotzdem melden
} }
}; },
[onBomb, onGrenades, onMap, onPlayerUpdate, onPlayersAll, onRoundEnd, onRoundStart]
)
useEffect(() => { useEffect(() => {
if (!url) return if (!url) return
shouldReconnectRef.current = true shouldReconnectRef.current = true
// evtl. alte Ressourcen räumen try {
try { wsRef.current?.close(1000, 'replaced by new /radar visit') } catch {} wsRef.current?.close(1000, 'replaced by new /radar visit')
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null } } catch {}
if (connectTimerRef.current) { window.clearTimeout(connectTimerRef.current); connectTimerRef.current = null } if (retryRef.current) {
window.clearTimeout(retryRef.current)
retryRef.current = null
}
if (connectTimerRef.current) {
window.clearTimeout(connectTimerRef.current)
connectTimerRef.current = null
}
const connect = () => { const connect = () => {
if (!shouldReconnectRef.current) return if (!shouldReconnectRef.current) return
@ -175,31 +211,46 @@ export default function GameSocket(props: GameSocketProps) {
} }
} }
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
let msg: any = null let parsed: unknown = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {} try {
if (Array.isArray(msg)) msg.forEach(dispatch) parsed = JSON.parse(String(ev.data ?? ''))
else dispatch(msg) } catch {
parsed = null
}
if (Array.isArray(parsed)) {
for (const item of parsed as unknown[]) {
if (isObj(item)) dispatch(item)
}
} else if (isObj(parsed)) {
dispatch(parsed)
}
} }
} }
// *** WICHTIG: leicht verzögert verbinden ***
// Verhindert das Fehl-Log im React-Strict-Mode (Mount->Unmount->Mount).
connectTimerRef.current = window.setTimeout(connect, 0) connectTimerRef.current = window.setTimeout(connect, 0)
return () => { return () => {
shouldReconnectRef.current = false shouldReconnectRef.current = false
if (connectTimerRef.current) { window.clearTimeout(connectTimerRef.current); connectTimerRef.current = null } if (connectTimerRef.current) {
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null } window.clearTimeout(connectTimerRef.current)
connectTimerRef.current = null
}
if (retryRef.current) {
window.clearTimeout(retryRef.current)
retryRef.current = null
}
const ws = wsRef.current const ws = wsRef.current
if (ws) { if (ws) {
ws.onclose = null // Reconnect nicht anstoßen ws.onclose = null
try { ws.close(1000, 'left /radar') } catch {} try {
ws.close(1000, 'left /radar')
} catch {}
} }
onStatus?.('closed') onStatus?.('closed')
} }
}, [url]) }, [url, dispatch, onStatus])
return null return null
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,21 @@
// /src/app/[locale]/components/radar/RadarCanvas.tsx // /src/app/[locale]/components/radar/RadarCanvas.tsx
'use client' 'use client'
import Image from 'next/image'
import StaticEffects from './StaticEffects'; import StaticEffects from './StaticEffects';
import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui'; import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';
import { contrastStroke } from './lib/helpers'; import { contrastStroke } from './lib/helpers';
import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types'; import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types';
type AvatarEntry = { avatar?: string; notFound?: boolean } | undefined
export default function RadarCanvas({ export default function RadarCanvas({
activeMapKey, activeMapKey,
currentSrc, onImgLoad, onImgError, currentSrc, onImgLoad, onImgError,
imgSize, imgSize,
worldToPx, unitsToPx, worldToPx, unitsToPx,
players, grenades, trails, deathMarkers, players, grenades, trails, deathMarkers,
useAvatars, avatarById, hoveredPlayerId, setHoveredPlayerId, useAvatars, avatarById, hoveredPlayerId,
myTeam, myTeam,
beepState, bombFinal10, beepState, bombFinal10,
bomb, bomb,
@ -30,9 +33,8 @@ export default function RadarCanvas({
trails: Trail[]; trails: Trail[];
deathMarkers: DeathMarker[]; deathMarkers: DeathMarker[];
useAvatars: boolean; useAvatars: boolean;
avatarById: Record<string, any>; avatarById: Record<string, AvatarEntry>;
hoveredPlayerId: string | null; hoveredPlayerId: string | null;
setHoveredPlayerId: (id: string|null)=>void;
myTeam: 'T'|'CT'|string|null; myTeam: 'T'|'CT'|string|null;
beepState: {key:number;dur:number}|null; beepState: {key:number;dur:number}|null;
bombFinal10: boolean; bombFinal10: boolean;
@ -50,9 +52,7 @@ export default function RadarCanvas({
} }
const raw = activeMapKey.replace(/^de_/, '').replace(/[_-]+/g, ' ').trim() const raw = activeMapKey.replace(/^de_/, '').replace(/[_-]+/g, ' ').trim()
// Leerzeichen zwischen Buchstabe↔Zahl einfügen (z.B. "dust2" -> "dust 2")
const spaced = raw.replace(/(\D)(\d)/g, '$1 $2') const spaced = raw.replace(/(\D)(\d)/g, '$1 $2')
// Jedes Wort kapitalisieren
const pretty = spaced.replace(/\b\w/g, c => c.toUpperCase()) const pretty = spaced.replace(/\b\w/g, c => c.toUpperCase())
return ( return (
@ -66,13 +66,16 @@ export default function RadarCanvas({
{currentSrc ? ( {currentSrc ? (
<div className="absolute inset-0"> <div className="absolute inset-0">
<img <Image
key={currentSrc} key={currentSrc}
src={currentSrc} src={currentSrc}
alt={activeMapKey ?? 'map'} alt={activeMapKey ?? 'map'}
className="absolute inset-0 h-full w-full object-contain object-center" fill
onLoad={(e) => onImgLoad(e.currentTarget)} sizes="100vw"
onError={onImgError} style={{ objectFit: 'contain', objectPosition: 'center' }}
onLoadingComplete={(img) => onImgLoad(img)}
onError={() => onImgError()}
priority={false}
/> />
{imgSize && ( {imgSize && (
@ -117,10 +120,6 @@ export default function RadarCanvas({
{grenades.filter(shouldShowGrenade).map((g) => { {grenades.filter(shouldShowGrenade).map((g) => {
const P = worldToPx(g.x, g.y); const P = worldToPx(g.x, g.y);
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null; if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null;
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? 60));
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
: g.team === 'T' ? UI.nade.teamStrokeT
: UI.nade.stroke;
// projectile icon // projectile icon
if (g.phase === 'projectile') { if (g.phase === 'projectile') {
@ -181,7 +180,7 @@ export default function RadarCanvas({
dxp *= dirLenPx / cur; dyp *= dirLenPx / cur; dxp *= dirLenPx / cur; dyp *= dirLenPx / cur;
} }
const entry = avatarById[p.id] as any; const entry = avatarById[p.id];
const avatarFromStore = entry && !entry?.notFound && entry?.avatar ? entry.avatar : null; const avatarFromStore = entry && !entry?.notFound && entry?.avatar ? entry.avatar : null;
const avatarUrl = useAvatars ? (p.id.toUpperCase().startsWith('BOT:') ? BOT_ICON : (avatarFromStore || DEFAULT_AVATAR)) : null; const avatarUrl = useAvatars ? (p.id.toUpperCase().startsWith('BOT:') ? BOT_ICON : (avatarFromStore || DEFAULT_AVATAR)) : null;
const isAvatar = !!avatarUrl; const isAvatar = !!avatarUrl;

View File

@ -28,6 +28,11 @@ type Grenade = {
effectTimeSec?: number effectTimeSec?: number
lifeElapsedMs?: number lifeElapsedMs?: number
lifeLeftMs?: number lifeLeftMs?: number
/** lokal gemerkter Sichtungszeitpunkt (für Timer-Anzeige) */
firstSeenAt?: number | null
/** Server-/Parser-Felder für Feuer */
spreaded?: boolean
flamesCount?: number | null
} }
type Mapper = (xw: number, yw: number) => { x: number; y: number } type Mapper = (xw: number, yw: number) => { x: number; y: number }
@ -62,7 +67,6 @@ function seedRng(seed: string) {
}; };
} }
export default function StaticEffects({ export default function StaticEffects({
grenades, grenades,
bomb, bomb,
@ -111,7 +115,7 @@ export default function StaticEffects({
// ── TIMER: Immer lokal bei 20s starten, unabhängig von Serverzeiten // ── TIMER: Immer lokal bei 20s starten, unabhängig von Serverzeiten
const DISPLAY_TIMER_MS = 20_000 const DISPLAY_TIMER_MS = 20_000
const firstSeenAt = (g as any).firstSeenAt ?? g.spawnedAt ?? Date.now() const firstSeenAt = (g.firstSeenAt ?? g.spawnedAt) ?? Date.now()
const timerLeftMs = Math.max(0, DISPLAY_TIMER_MS - (Date.now() - firstSeenAt)) const timerLeftMs = Math.max(0, DISPLAY_TIMER_MS - (Date.now() - firstSeenAt))
const timerSecs = Math.ceil(timerLeftMs / 1000) const timerSecs = Math.ceil(timerLeftMs / 1000)
const timerAlpha = Math.min(1, timerLeftMs / 1000) // in letzter Sekunde ausblenden const timerAlpha = Math.min(1, timerLeftMs / 1000) // in letzter Sekunde ausblenden
@ -230,16 +234,12 @@ export default function StaticEffects({
) )
} }
const molotovNode = (g: Grenade) => { const molotovNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y) const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60)) const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
// 1.0 = wie jetzt. 1.3..1.8 = größerer Radius // 1.0 = wie jetzt. 1.3..1.8 = größerer Radius
const FIRE_RADIUS_MULT = 2 const FIRE_RADIUS_MULT = 2
const stroke = g.team === 'CT' ? ui.nade.teamStrokeCT : g.team === 'T' ? ui.nade.teamStrokeT : ui.nade.stroke
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
// Größen analog zum CodePen (Container ~10em breit, Rise ~10em) // Größen analog zum CodePen (Container ~10em breit, Rise ~10em)
const coverPx = rPx * FIRE_RADIUS_MULT // „Radius“ der Fläche const coverPx = rPx * FIRE_RADIUS_MULT // „Radius“ der Fläche
const W = Math.max(26, coverPx * 1.45) // Breite des Feuers const W = Math.max(26, coverPx * 1.45) // Breite des Feuers
@ -256,6 +256,11 @@ export default function StaticEffects({
const gradPartId = `molo-fire-grad-${g.id}` const gradPartId = `molo-fire-grad-${g.id}`
const blurPartId = `molo-fire-blur-${g.id}` const blurPartId = `molo-fire-blur-${g.id}`
// CSS-Var für --rise typsicher setzen
const riseStyle: React.CSSProperties & { ['--rise']?: string } = {
['--rise']: `${Math.round(RISE)}px`
}
return ( return (
<g key={g.id}> <g key={g.id}>
<defs> <defs>
@ -270,7 +275,7 @@ export default function StaticEffects({
</defs> </defs>
{/* Partikel exakt bei P, über die Breite verteilt */} {/* Partikel exakt bei P, über die Breite verteilt */}
<g transform={`translate(${P.x}, ${P.y})`} style={{ ['--rise' as any]: `${Math.round(RISE)}px` }}> <g transform={`translate(${P.x}, ${P.y})`} style={riseStyle}>
{Array.from({ length: PARTS }).map((_, i) => { {Array.from({ length: PARTS }).map((_, i) => {
// „left: calc((100% - partSize) * (i/parts))“ // „left: calc((100% - partSize) * (i/parts))“
const leftX = (i / PARTS) * (W - PART_SIZE) - (W/2 - PART_SIZE/2) const leftX = (i / PARTS) * (W - PART_SIZE) - (W/2 - PART_SIZE/2)
@ -283,7 +288,7 @@ export default function StaticEffects({
fill={`url(#${gradPartId})`} fill={`url(#${gradPartId})`}
filter={`url(#${blurPartId})`} filter={`url(#${blurPartId})`}
style={{ style={{
mixBlendMode: 'screen' as any, mixBlendMode: 'screen',
animation: `molotovFireRise ${DUR_MS}ms ease-in infinite`, animation: `molotovFireRise ${DUR_MS}ms ease-in infinite`,
animationDelay: `${Math.round(DUR_MS * rnd())}ms`, animationDelay: `${Math.round(DUR_MS * rnd())}ms`,
transformBox: 'fill-box', transformBox: 'fill-box',
@ -299,10 +304,6 @@ export default function StaticEffects({
) )
} }
const decoyNode = (g: Grenade) => { const decoyNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y) const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60)) const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
@ -359,7 +360,7 @@ export default function StaticEffects({
cx={P.x} cy={P.y} r={rBase} cx={P.x} cy={P.y} r={rBase}
fill="none" fill="none"
stroke={bombFinal10 ? '#ef4444' : '#f59e0b'} stroke={bombFinal10 ? '#ef4444' : '#f59e0b'}
strokeWidth={2} // vorher 3 strokeWidth={2}
style={{ style={{
transformBox: 'fill-box', transformBox: 'fill-box',
transformOrigin: 'center', transformOrigin: 'center',
@ -410,8 +411,8 @@ export default function StaticEffects({
if (g.kind === 'smoke') return smokeNode(g) if (g.kind === 'smoke') return smokeNode(g)
if (g.kind === 'molotov' || g.kind === 'incendiary') { if (g.kind === 'molotov' || g.kind === 'incendiary') {
const spreaded = (g as any).spreaded === true || ((g as any).flamesCount ?? 0) > 0 const spreaded = g.spreaded === true || ((g.flamesCount ?? 0) > 0)
if (!spreaded) return null // << Nur zeigen, wenn wirklich Flames vorhanden/spreaded if (!spreaded) return null // Nur zeigen, wenn wirklich Flames vorhanden/spreaded
return molotovNode(g) return molotovNode(g)
} }

View File

@ -1,7 +1,7 @@
// /src/app/[locale]/components/radar/TeamSidebar.tsx // /src/app/[locale]/components/radar/TeamSidebar.tsx
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import Image from 'next/image'
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore' import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'
export type Team = 'T' | 'CT' export type Team = 'T' | 'CT'
@ -39,7 +39,7 @@ const ShieldIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
</svg> </svg>
) )
/* ── Rotes Bomben-Icon via CSS-Maske, damit es sicher rot ist ── */ /* ── Rotes Bomben-Icon via CSS-Maske ── */
const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string; title?: string; className?: string }) => ( const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string; title?: string; className?: string }) => (
<span <span
title={title} title={title}
@ -59,7 +59,18 @@ const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string;
/> />
) )
/* ── Gear Blöcke (links/rechts trennen) ── */ /* ── kleine Image-Hilfen ── */
function IconImg({
src, alt, title, w, h, className,
}: { src: string; alt: string; title?: string; w: number; h: number; className?: string }) {
return (
<span className={className} title={title} aria-label={title}>
<Image src={src} alt={alt} width={w} height={h} unoptimized />
</span>
)
}
/* ── Gear Blöcke ── */
function leftGear(opts: { armor?: number|null; helmet?: boolean|null }) { function leftGear(opts: { armor?: number|null; helmet?: boolean|null }) {
const out: { src: string; title: string; key: string }[] = [] const out: { src: string; title: string; key: string }[] = []
if ((opts.armor ?? 0) > 0) out.push({ src: equipIcon('armor.svg'), title: 'Kevlar', key: 'armor' }) if ((opts.armor ?? 0) > 0) out.push({ src: equipIcon('armor.svg'), title: 'Kevlar', key: 'armor' })
@ -143,13 +154,6 @@ function grenadeIconFromKey(k: string): string {
} }
} }
function activeWeaponNameOf(w?: string | { name?: string | null } | null): string | null {
if (!w) return null
if (typeof w === 'string') return w
if (typeof w === 'object' && w?.name) return w.name
return null
}
export default function TeamSidebar({ export default function TeamSidebar({
team, teamId, players, align = 'left', onHoverPlayer, score, oppScore team, teamId, players, align = 'left', onHoverPlayer, score, oppScore
}: { }: {
@ -203,35 +207,39 @@ export default function TeamSidebar({
return (a.name ?? '').localeCompare(b.name ?? '') return (a.name ?? '').localeCompare(b.name ?? '')
}) })
// lokaler Typ für Avatar-Lookup (eliminiert 'any')
type AvatarEntry = { avatar?: string; notFound?: boolean }
const byId: Record<string, AvatarEntry | undefined> = avatarById as Record<string, AvatarEntry | undefined>
void avatarVer // nur, um Re-Render bei Versionswechsel zu triggern
return ( return (
<aside className="h-full min-h-0 flex flex-col rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2 overflow-hidden"> <aside className="h-full min-h-0 flex flex-col rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2 overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between text-xs uppercase tracking-wide opacity-80"> <div className="flex items-center justify-between text-xs uppercase tracking-wide opacity-80">
<span className={`font-semibold flex items-center gap-2 ${teamColor}`}> <span className={`font-semibold flex items-center gap-2 ${teamColor}`}>
{/* Logo größer */} {/* Logo größer */}
<img src={logoSrc} alt={teamName} className="w-7 h-7 md:w-8 md:h-8 object-contain" /> <span className="relative block h-8 w-8 md:h-9 md:w-9">
<Image src={logoSrc} alt={teamName} fill sizes="36px" className="object-contain" unoptimized />
</span>
<span className="hidden sm:inline">{teamName}</span> <span className="hidden sm:inline">{teamName}</span>
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{/* Score-Pill in der Sidebar */}
{(typeof score === 'number' && typeof oppScore === 'number') && ( {(typeof score === 'number' && typeof oppScore === 'number') && (
<span className="px-2 py-0.5 rounded bg-black/45 text-white text-[11px] font-semibold tabular-nums"> <span className="px-2 py-0.5 rounded bg-black/45 text-white text-[11px] font-semibold tabular-nums">
{score}<span className="opacity-60 mx-1">:</span>{oppScore} {score}<span className="opacity-60 mx-1">:</span>{oppScore}
</span> </span>
)} )}
{/* Alive-Count bleibt */}
<span className="tabular-nums">{aliveCount}/{players.length}</span> <span className="tabular-nums">{aliveCount}/{players.length}</span>
</span> </span>
</div> </div>
<div className="mt-2 flex-1 overflow-auto space-y-3 pr-1"> <div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
{sorted.map(p=>{ {sorted.map(p=>{
void avatarVer
const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100) const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100)
const armor = clamp(p.armor ?? 0, 0, 100) const armor = clamp(p.armor ?? 0, 0, 100)
const dead = p.alive === false || hp <= 0 const dead = p.alive === false || hp <= 0
const entry = avatarById[p.id] as any const entry: AvatarEntry | undefined = byId[p.id]
const avatarUrl = isBotId(p.id) const avatarUrl = isBotId(p.id)
? BOT_ICON ? BOT_ICON
: (entry && !entry?.notFound && entry?.avatar ? entry.avatar : '/assets/img/avatars/default_steam_avatar.jpg') : (entry && !entry?.notFound && entry?.avatar ? entry.avatar : '/assets/img/avatars/default_steam_avatar.jpg')
@ -271,19 +279,23 @@ export default function TeamSidebar({
<div className={`flex ${isRight ? 'flex-row-reverse text-right' : 'flex-row'} items-center gap-3`}> <div className={`flex ${isRight ? 'flex-row-reverse text-right' : 'flex-row'} items-center gap-3`}>
{/* Avatar mit Bomben-Glow / Dead-Desaturierung */} {/* Avatar mit Bomben-Glow / Dead-Desaturierung */}
<div className={`rounded-full ${p.hasBomb ? 'ring-2 ring-red-500/70 shadow-[0_0_12px_rgba(239,68,68,.35)]' : ''}`}> <div className={`rounded-full ${p.hasBomb ? 'ring-2 ring-red-500/70 shadow-[0_0_12px_rgba(239,68,68,.35)]' : ''}`}>
<img <span className={`relative block h-12 w-12 rounded-full border border-white/10 ring-2 ${ringColor} bg-neutral-900 p-1 overflow-hidden ${dead ? 'grayscale opacity-70' : ''}`}>
<Image
src={avatarUrl} src={avatarUrl}
alt={p.name || p.id} alt={p.name || p.id}
className={`w-12 h-12 rounded-full border border-white/10 ring-2 ${ringColor} bg-neutral-900 object-contain p-1 ${dead ? 'grayscale opacity-70' : ''}`} fill
width={48} height={48} loading="lazy" sizes="48px"
className="object-contain"
unoptimized
/> />
</span>
</div> </div>
<div className={`flex-1 min-w-0 flex flex-col ${isRight ? 'items-end' : 'items-start'}`}> <div className={`flex-1 min-w-0 flex flex-col ${isRight ? 'items-end' : 'items-start'}`}>
{/* Kopfzeile: Name & Gear je Seite */} {/* Kopfzeile: Name & Gear */}
{!isRight ? ( {!isRight ? (
<div className="flex items-center justify-between w-full min-h-[22px] gap-2"> <div className="flex items-center justify-between w-full min-h-[22px] gap-2">
<span className={`truncate font-medium text-left tracking-wide [font-variant-numeric:tabular-nums]`}> <span className="truncate font-medium text-left tracking-wide [font-variant-numeric:tabular-nums]">
{p.name || p.id} {p.name || p.id}
</span> </span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
@ -292,7 +304,7 @@ export default function TeamSidebar({
).map(icon => ( ).map(icon => (
icon.key === 'c4' icon.key === 'c4'
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" /> ? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
: <img key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} className="h-5 w-5 opacity-90" /> : <IconImg key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} w={20} h={20} className="inline-flex" />
))} ))}
</span> </span>
</div> </div>
@ -304,16 +316,16 @@ export default function TeamSidebar({
).map(icon => ( ).map(icon => (
icon.key === 'c4' icon.key === 'c4'
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" /> ? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
: <img key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} className="h-5 w-5 opacity-90" /> : <IconImg key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} w={20} h={20} className="inline-flex" />
))} ))}
</span> </span>
<span className={`truncate font-medium text-right tracking-wide [font-variant-numeric:tabular-nums]`}> <span className="truncate font-medium text-right tracking-wide [font-variant-numeric:tabular-nums]">
{p.name || p.id} {p.name || p.id}
</span> </span>
</div> </div>
)} )}
{/* Waffenzeile: Primär (links/rechts je nach align) — Sekundär+Messer auf der Gegenseite */} {/* Waffenzeile */}
<div <div
className={[ className={[
'mt-1 w-full flex items-center', 'mt-1 w-full flex items-center',
@ -325,11 +337,13 @@ export default function TeamSidebar({
{/* Primär */} {/* Primär */}
{primIcon && ( {primIcon && (
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<img <IconImg
src={primIcon} src={primIcon}
alt={prim?.name ?? 'primary'} alt={prim?.name ?? 'primary'}
title={prim?.name ?? 'primary'} title={prim?.name ?? 'primary'}
className={`h-16 w-16 transition filter p-2 rounded-md ${ w={64}
h={64}
className={`p-2 rounded-md ${
primActive primActive
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30' ? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30'
: 'grayscale brightness-90 contrast-75 opacity-90' : 'grayscale brightness-90 contrast-75 opacity-90'
@ -338,52 +352,52 @@ export default function TeamSidebar({
</div> </div>
)} )}
{/* Sekundär + Messer (als Gruppe) */} {/* Sekundär + Messer */}
{(secIcon || knifeIcon) && ( {(secIcon || knifeIcon) && (
<div <div className={['flex items-center gap-2', !primIcon ? (isRight ? 'justify-end' : 'justify-start') : ''].join(' ')}>
className={[
'flex items-center gap-2',
// Wenn keine Primärwaffe existiert, die Gruppe passend ausrichten
!primIcon ? (isRight ? 'justify-end' : 'justify-start') : ''
].join(' ')}
>
{secIcon && ( {secIcon && (
<img <IconImg
src={secIcon} src={secIcon}
alt={sec?.name ?? 'secondary'} alt={sec?.name ?? 'secondary'}
title={sec?.name ?? 'secondary'} title={sec?.name ?? 'secondary'}
className={`h-10 w-10 transition filter p-2 rounded-md ${ w={40}
secActive ? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30' : 'grayscale brightness-90 contrast-75 opacity-90' h={40}
className={`p-2 rounded-md ${
secActive
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30'
: 'grayscale brightness-90 contrast-75 opacity-90'
}`} }`}
/> />
)} )}
{knifeIcon && ( {knifeIcon && (
<img <IconImg
src={knifeIcon} src={knifeIcon}
alt={knife?.name ?? 'knife'} alt={knife?.name ?? 'knife'}
title={knife?.name ?? 'knife'} title={knife?.name ?? 'knife'}
className={`h-10 w-10 transition filter ${ w={40}
knifeActive ? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2' : 'grayscale brightness-90 contrast-75 opacity-90' h={40}
}`} className={knifeActive
? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2'
: 'grayscale brightness-90 contrast-75 opacity-90'}
/> />
)} )}
</div> </div>
)} )}
</div> </div>
{/* Granaten: ohne Count; Icon mehrfach je Anzahl */} {/* Granaten */}
<div className={`mt-2 flex items-center gap-1 ${isRight ? 'justify-start' : 'justify-end'}`}> <div className={`mt-2 flex items-center gap-1 ${isRight ? 'justify-start' : 'justify-end'}`}>
{GRENADE_DISPLAY_ORDER.flatMap(k=>{ {GRENADE_DISPLAY_ORDER.flatMap(k=>{
const c = p.grenades?.[k] ?? 0 const c = p.grenades?.[k] ?? 0
if (!c) return [] if (!c) return []
const src = grenadeIconFromKey(k) const src = grenadeIconFromKey(k)
return Array.from({ length: c }, (_,i)=>( // je Anzahl ein Icon return Array.from({ length: c }, (_,i)=>(
<img key={`${k}-${i}`} src={src} alt={k} title={k} className="h-4 w-4 opacity-90" /> <IconImg key={`${k}-${i}`} src={src} alt={k} title={k} w={16} h={16} />
)) ))
})} })}
</div> </div>
{/* HP / Armor Bars (SVG-Icons weiß) */} {/* HP / Armor Bars */}
<div className="mt-2 w-full space-y-2"> <div className="mt-2 w-full space-y-2">
{/* HP */} {/* HP */}
<div <div
@ -393,16 +407,13 @@ export default function TeamSidebar({
> >
<div <div
className={[ className={[
// nur der Füllbalken bekommt ggf. das Blinken
'h-full transition-[width] duration-300 ease-out', 'h-full transition-[width] duration-300 ease-out',
hp > 66 ? 'bg-green-500' : hp > 20 ? 'bg-amber-500' : 'bg-red-500', hp > 66 ? 'bg-green-500' : hp > 20 ? 'bg-amber-500' : 'bg-red-500',
hp > 0 && hp <= 20 ? 'animate-hpPulse' : '' hp > 0 && hp <= 20 ? 'animate-hpPulse' : ''
].join(' ')} ].join(' ')}
style={{ width: `${hp}%` }} style={{ width: `${hp}%` }}
/> />
{/* Ticks */}
<div className="pointer-events-none absolute inset-0 opacity-70 mix-blend-overlay bg-[repeating-linear-gradient(to_right,transparent,transparent_11px,rgba(255,255,255,0.06)_12px)]" /> <div className="pointer-events-none absolute inset-0 opacity-70 mix-blend-overlay bg-[repeating-linear-gradient(to_right,transparent,transparent_11px,rgba(255,255,255,0.06)_12px)]" />
{/* Label */}
<div className="absolute inset-0 flex items-center justify-between px-2 text-[11px] font-semibold text-white/95"> <div className="absolute inset-0 flex items-center justify-between px-2 text-[11px] font-semibold text-white/95">
<span className="flex items-center gap-1 select-none"><HeartIcon /></span> <span className="flex items-center gap-1 select-none"><HeartIcon /></span>
<span className="tabular-nums select-none drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">{hp}</span> <span className="tabular-nums select-none drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">{hp}</span>

View File

@ -42,19 +42,16 @@ export function useOverview(activeMapKey: string | null, playersForAutoFit: {x:n
return () => { cancel = true; }; return () => { cancel = true; };
}, [activeMapKey]); }, [activeMapKey]);
const { folderKey, imageCandidates } = useMemo(() => { const imageCandidates = useMemo(() => {
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }; if (!activeMapKey) return [] as string[];
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey; const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey;
const base = `/assets/img/radar/${activeMapKey}`; const base = `/assets/img/radar/${activeMapKey}`;
return { return [
folderKey: short,
imageCandidates: [
`${base}/de_${short}_radar_psd.png`, `${base}/de_${short}_radar_psd.png`,
`${base}/de_${short}_lower_radar_psd.png`, `${base}/de_${short}_lower_radar_psd.png`,
`${base}/de_${short}_v1_radar_psd.png`, `${base}/de_${short}_v1_radar_psd.png`,
`${base}/de_${short}_radar.png`, `${base}/de_${short}_radar.png`,
], ];
};
}, [activeMapKey]); }, [activeMapKey]);
const currentSrc = imageCandidates[srcIdx]; const currentSrc = imageCandidates[srcIdx];

View File

@ -1,11 +1,29 @@
// /src/app/[locale]/components/radar/hooks/useRadarState.ts // /src/app/[locale]/components/radar/hooks/useRadarState.ts
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '../lib/types'; import {
BombState,
DeathMarker,
Grenade,
PlayerState,
Score,
Trail,
WsStatus,
} from '../lib/types';
import { UI } from '../lib/ui'; import { UI } from '../lib/ui';
import { asNum, mapTeam, steamIdOf } from '../lib/helpers'; import { asNum, mapTeam, steamIdOf } from '../lib/helpers';
import { normalizeGrenades } from '../lib/grenades'; import { normalizeGrenades } from '../lib/grenades';
/* ---------- kleine Safe-Helper ---------- */
type UnknownRecord = Record<string, unknown>;
const isObj = (v: unknown): v is UnknownRecord => !!v && typeof v === 'object';
const getObj = (v: unknown): UnknownRecord | undefined => (isObj(v) ? v : undefined);
const getArr = (v: unknown): unknown[] | undefined => (Array.isArray(v) ? v : undefined);
const num = (v: unknown, def?: number) => {
const n = Number(v);
return Number.isFinite(n) ? n : def;
};
export function useRadarState(mySteamId: string | null) { export function useRadarState(mySteamId: string | null) {
// WS / Map // WS / Map
const [radarWsStatus, setGameWsStatus] = useState<WsStatus>('idle'); const [radarWsStatus, setGameWsStatus] = useState<WsStatus>('idle');
@ -19,7 +37,7 @@ export function useRadarState(mySteamId: string | null) {
// Deaths // Deaths
const deathSeqRef = useRef(0); const deathSeqRef = useRef(0);
const deathSeenRef = useRef<Set<string>>(new Set()); const deathSeenRef = useRef<Set<string>>(new Set());
const lastAlivePosRef = useRef<Map<string, {x:number,y:number}>>(new Map()); const lastAlivePosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
// Grenaden + Trails // Grenaden + Trails
const grenadesRef = useRef<Map<string, Grenade>>(new Map()); const grenadesRef = useRef<Map<string, Grenade>>(new Map());
@ -40,7 +58,11 @@ export function useRadarState(mySteamId: string | null) {
useState<'freezetime' | 'live' | 'bomb' | 'over' | 'warmup' | 'unknown'>('unknown'); useState<'freezetime' | 'live' | 'bomb' | 'over' | 'warmup' | 'unknown'>('unknown');
const roundEndsAtRef = useRef<number | null>(null); const roundEndsAtRef = useRef<number | null>(null);
const bombEndsAtRef = useRef<number | null>(null); const bombEndsAtRef = useRef<number | null>(null);
const defuseRef = useRef<{ by: string|null; hasKit: boolean; endsAt: number|null }>({ by: null, hasKit: false, endsAt: null }); const defuseRef = useRef<{ by: string | null; hasKit: boolean; endsAt: number | null }>({
by: null,
hasKit: false,
endsAt: null,
});
const [score, setScore] = useState<Score>({ ct: 0, t: 0, round: null }); const [score, setScore] = useState<Score>({ ct: 0, t: 0, round: null });
// flush-batching // flush-batching
@ -58,14 +80,21 @@ export function useRadarState(mySteamId: string | null) {
}, 66); }, 66);
}; };
useEffect(() => () => { useEffect(
if (flushTimer.current != null) { window.clearTimeout(flushTimer.current); flushTimer.current = null; } () => () => {
}, []); if (flushTimer.current != null) {
window.clearTimeout(flushTimer.current);
flushTimer.current = null;
}
},
[]
);
// ⚠️ nur von mySteamId abhängig (playersRef.current wird direkt gelesen)
const myTeam = useMemo<'T' | 'CT' | string | null>(() => { const myTeam = useMemo<'T' | 'CT' | string | null>(() => {
if (!mySteamId) return null; if (!mySteamId) return null;
return playersRef.current.get(mySteamId)?.team ?? null; return playersRef.current.get(mySteamId)?.team ?? null;
}, [players, mySteamId]); }, [mySteamId]);
const addDeathMarker = (x: number, y: number, steamId?: string) => { const addDeathMarker = (x: number, y: number, steamId?: string) => {
const now = Date.now(); const now = Date.now();
@ -79,8 +108,8 @@ export function useRadarState(mySteamId: string | null) {
const addDeathMarkerFor = (id: string, xNow: number, yNow: number) => { const addDeathMarkerFor = (id: string, xNow: number, yNow: number) => {
const last = lastAlivePosRef.current.get(id); const last = lastAlivePosRef.current.get(id);
const x = Number.isFinite(last?.x) ? last!.x : xNow; const x = Number.isFinite(last?.x) ? (last as { x: number; y: number }).x : xNow;
const y = Number.isFinite(last?.y) ? last!.y : yNow; const y = Number.isFinite(last?.y) ? (last as { x: number; y: number }).y : yNow;
addDeathMarker(x, y, id); addDeathMarker(x, y, id);
}; };
@ -104,66 +133,87 @@ export function useRadarState(mySteamId: string | null) {
const updateBombFromPlayers = () => { const updateBombFromPlayers = () => {
if (bombRef.current?.status === 'planted') return; if (bombRef.current?.status === 'planted') return;
const carrier = Array.from(playersRef.current.values()).find(p => p.hasBomb); const carrier = Array.from(playersRef.current.values()).find((p) => p.hasBomb);
if (carrier) { if (carrier) {
bombRef.current = { bombRef.current = {
x: carrier.x, y: carrier.y, z: carrier.z, x: carrier.x,
y: carrier.y,
z: carrier.z,
status: 'carried', status: 'carried',
changedAt: bombRef.current?.status === 'carried' changedAt:
? bombRef.current.changedAt bombRef.current?.status === 'carried' ? bombRef.current.changedAt : Date.now(),
: Date.now(),
}; };
} }
}; };
// ---- Player Upsert (gekürzt Logik aus deiner Datei) -------------- /* ---------- Player-Upsert (typsicher) ---------- */
function upsertPlayer(e:any) { function upsertPlayer(e: unknown) {
const id = steamIdOf(e); if (!id) return; const src = getObj(e);
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates; if (!src) return;
const x = asNum(e.x ?? (Array.isArray(pos) ? pos?.[0] : pos?.x));
const y = asNum(e.y ?? (Array.isArray(pos) ? pos?.[1] : pos?.y)); const id = steamIdOf(src);
const z = asNum(e.z ?? (Array.isArray(pos) ? pos?.[2] : pos?.z), 0); if (!id) return;
const pos = src.pos ?? src.position ?? src.location ?? src.coordinates;
const x = asNum(src.x ?? (Array.isArray(pos) ? pos?.[0] : (pos as UnknownRecord | undefined)?.x));
const y = asNum(src.y ?? (Array.isArray(pos) ? pos?.[1] : (pos as UnknownRecord | undefined)?.y));
const z = asNum(src.z ?? (Array.isArray(pos) ? pos?.[2] : (pos as UnknownRecord | undefined)?.z), 0);
if (!Number.isFinite(x) || !Number.isFinite(y)) return; if (!Number.isFinite(x) || !Number.isFinite(y)) return;
const hpProbe = asNum(e.hp ?? e.health ?? e.state?.health, NaN);
const old = playersRef.current.get(id); const old = playersRef.current.get(id);
const nextAlive = Number.isFinite(hpProbe) ? hpProbe > 0 : (old?.alive ?? true);
if ((old?.alive !== false) && nextAlive === false) addDeathMarker(x, y, id);
const hpProbe = asNum(src.hp ?? (getObj(src.state)?.health), NaN);
const nextAlive = Number.isFinite(hpProbe) ? (hpProbe as number) > 0 : (old?.alive ?? true);
if ((old?.alive !== false) && nextAlive === false) addDeathMarker(x, y, id);
if (nextAlive === true) lastAlivePosRef.current.set(id, { x, y }); if (nextAlive === true) lastAlivePosRef.current.set(id, { x, y });
else if (nextAlive === false && (old?.alive !== false)) addDeathMarkerFor(id, x, y); else if (nextAlive === false && (old?.alive !== false)) addDeathMarkerFor(id, x, y);
type WeaponRec = { name?: unknown; state?: unknown } | string;
const weaponsArr = getArr(src.weapons) as WeaponRec[] | undefined;
const activeFromArr =
weaponsArr?.find((w) => isObj(w) && String(w.state ?? '').toLowerCase() === 'active') ?? null;
const activeWeaponName = const activeWeaponName =
(typeof e.activeWeapon === 'string' && e.activeWeapon) || (typeof src.activeWeapon === 'string' && src.activeWeapon) ||
(e.activeWeapon?.name ?? null) || (getObj(src.activeWeapon)?.name as string | undefined) ||
(Array.isArray(e.weapons) (isObj(activeFromArr) ? (activeFromArr.name as string | undefined) : undefined) ||
? (e.weapons.find((w:any) => (w?.state ?? '').toLowerCase() === 'active')?.name ?? null) null;
: null);
const stateObj = getObj(src.state);
playersRef.current.set(id, { playersRef.current.set(id, {
id, id,
name: e.name ?? old?.name ?? null, name: (src.name as string | undefined) ?? old?.name ?? null,
team: mapTeam(e.team ?? old?.team), team: mapTeam(src.team ?? old?.team),
x, y, z, x,
yaw: Number.isFinite(Number(e.yaw)) ? Number(e.yaw) : (old?.yaw ?? null), y,
z,
yaw: Number.isFinite(num(src.yaw)) ? (num(src.yaw) as number) : (old?.yaw ?? null),
alive: nextAlive, alive: nextAlive,
hasBomb: Boolean(e.hasBomb) || Boolean(old?.hasBomb), hasBomb: Boolean(src.hasBomb) || Boolean(old?.hasBomb),
hp: Number.isFinite(hpProbe) ? hpProbe : (old?.hp ?? null), hp: Number.isFinite(hpProbe) ? (hpProbe as number) : (old?.hp ?? null),
armor: Number.isFinite(asNum(e.armor ?? e.state?.armor, NaN)) ? asNum(e.armor ?? e.state?.armor, NaN) : (old?.armor ?? null), armor:
helmet: (e.helmet ?? e.hasHelmet ?? e.state?.helmet) ?? (old?.helmet ?? null), Number.isFinite(asNum(src.armor ?? stateObj?.armor, NaN))
defuse: (e.defuse ?? e.hasDefuse ?? e.hasDefuser ?? e.state?.defusekit) ?? (old?.defuse ?? null), ? (asNum(src.armor ?? stateObj?.armor, NaN) as number)
: (old?.armor ?? null),
helmet: (src.helmet ?? src.hasHelmet ?? stateObj?.helmet) as boolean | null ?? (old?.helmet ?? null),
defuse:
(src.defuse ??
(src as UnknownRecord)['hasDefuse'] ??
(src as UnknownRecord)['hasDefuser'] ??
stateObj?.defusekit) as boolean | null ?? (old?.defuse ?? null),
activeWeapon: activeWeaponName ?? old?.activeWeapon ?? null, activeWeapon: activeWeaponName ?? old?.activeWeapon ?? null,
weapons: Array.isArray(e.weapons) ? e.weapons : (old?.weapons ?? null), weapons: Array.isArray(src.weapons) ? (src.weapons as unknown[]) : (old?.weapons ?? null),
nades: old?.nades ?? null, nades: old?.nades ?? null,
}); });
} }
// ---- Handlers für GameSocket --------------------------------------- /* ---------- Handlers für GameSocket ---------- */
const handlePlayersAll = (msg:any) => { const handlePlayersAll = (msg: unknown) => {
const pcd = msg?.phase ?? msg?.phase_countdowns; const m = getObj(msg) ?? {};
const phase = String(pcd?.phase ?? '').toLowerCase(); const pcd = getObj(m.phase) ?? getObj(m.phase_countdowns);
const phaseStr = String((pcd?.phase ?? '') as string).toLowerCase();
if (phase === 'freezetime' && (deathMarkersRef.current.length || trailsRef.current.size)) { if (phaseStr === 'freezetime' && (deathMarkersRef.current.length || trailsRef.current.size)) {
clearRoundArtifacts(true); clearRoundArtifacts(true);
} }
@ -171,118 +221,180 @@ export function useRadarState(mySteamId: string | null) {
const sec = Number(pcd.phase_ends_in); const sec = Number(pcd.phase_ends_in);
if (Number.isFinite(sec)) { if (Number.isFinite(sec)) {
roundEndsAtRef.current = Date.now() + sec * 1000; roundEndsAtRef.current = Date.now() + sec * 1000;
setRoundPhase(String(pcd.phase ?? 'unknown').toLowerCase() as any); const rp = (['freezetime', 'live', 'bomb', 'over', 'warmup'].includes(phaseStr)
? phaseStr
: 'unknown') as typeof roundPhase;
setRoundPhase(rp);
} }
} else if (pcd?.phase) { } else if (pcd?.phase) {
setRoundPhase(String(pcd.phase).toLowerCase() as any); const rp = (['freezetime', 'live', 'bomb', 'over', 'warmup'].includes(phaseStr)
? phaseStr
: 'unknown') as typeof roundPhase;
setRoundPhase(rp);
} }
if ((pcd?.phase ?? '').toLowerCase() === 'over') { if (phaseStr === 'over') {
roundEndsAtRef.current = null; roundEndsAtRef.current = null;
bombEndsAtRef.current = null; bombEndsAtRef.current = null;
defuseRef.current = { by: null, hasKit: false, endsAt: null }; defuseRef.current = { by: null, hasKit: false, endsAt: null };
} }
// Spieler (gekürzt, robust genug) // Spieler aus allplayers/players
const apObj = msg?.allplayers; const apObj = getObj(m.allplayers);
const apArr = Array.isArray(msg?.players) ? msg.players : null; const apArr = getArr(m.players);
const upsertFromPayload = (p:any) => {
const id = steamIdOf(p); if (!id) return; const upsertFromPayload = (p: unknown) => {
const pos = p.position ?? p.pos ?? p.location ?? p.coordinates ?? p.eye ?? p.pos; const o = getObj(p);
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } if (!o) return;
: typeof pos === 'object' ? pos : { x: p.x, y: p.y, z: p.z }; const id = steamIdOf(o);
const { x=0, y=0, z=0 } = xyz; if (!id) return;
const hpNum = Number(p?.state?.health ?? p?.hp);
const isAlive = Number.isFinite(hpNum) ? hpNum > 0 : (playersRef.current.get(id)?.alive ?? true); const pos =
o.position ?? o.pos ?? o.location ?? o.coordinates ?? o.eye ?? o.pos;
const arr = getArr(pos);
const obj = getObj(pos);
const x = asNum(o.x ?? arr?.[0] ?? obj?.x);
const y = asNum(o.y ?? arr?.[1] ?? obj?.y);
const z = asNum(o.z ?? arr?.[2] ?? obj?.z, 0);
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
const hpNum = num(getObj(o.state)?.health ?? o.hp);
const isAlive = Number.isFinite(hpNum ?? NaN)
? (hpNum as number) > 0
: (playersRef.current.get(id)?.alive ?? true);
if ((playersRef.current.get(id)?.alive ?? true) && !isAlive) addDeathMarker(x, y, id); if ((playersRef.current.get(id)?.alive ?? true) && !isAlive) addDeathMarker(x, y, id);
const prev = playersRef.current.get(id);
playersRef.current.set(id, { playersRef.current.set(id, {
id, id,
name: p?.name ?? playersRef.current.get(id)?.name ?? null, name: (o.name as string | undefined) ?? prev?.name ?? null,
team: mapTeam(p?.team ?? playersRef.current.get(id)?.team), team: mapTeam(o.team ?? prev?.team),
x, y, z, x,
yaw: playersRef.current.get(id)?.yaw ?? null, y,
z,
yaw: prev?.yaw ?? null,
alive: isAlive, alive: isAlive,
hasBomb: Boolean(playersRef.current.get(id)?.hasBomb), hasBomb: Boolean(prev?.hasBomb),
hp: Number.isFinite(hpNum) ? hpNum : (playersRef.current.get(id)?.hp ?? null), hp: Number.isFinite(hpNum ?? NaN) ? (hpNum as number) : (prev?.hp ?? null),
armor: playersRef.current.get(id)?.armor ?? null, armor: prev?.armor ?? null,
helmet: playersRef.current.get(id)?.helmet ?? null, helmet: prev?.helmet ?? null,
defuse: playersRef.current.get(id)?.defuse ?? null, defuse: prev?.defuse ?? null,
activeWeapon: playersRef.current.get(id)?.activeWeapon ?? null, activeWeapon: prev?.activeWeapon ?? null,
weapons: playersRef.current.get(id)?.weapons ?? null, weapons: prev?.weapons ?? null,
nades: playersRef.current.get(id)?.nades ?? null, nades: prev?.nades ?? null,
}); });
}; };
if (apObj && typeof apObj === 'object') for (const k of Object.keys(apObj)) upsertFromPayload(apObj[k]);
if (apObj) for (const k of Object.keys(apObj)) upsertFromPayload(apObj[k as keyof typeof apObj]);
else if (apArr) for (const p of apArr) upsertFromPayload(p); else if (apArr) for (const p of apArr) upsertFromPayload(p);
// Scores (robust, gekürzt) // Scores
const pick = (v:any)=> Number.isFinite(Number(v)) ? Number(v) : null; const pick = (v: unknown) => (Number.isFinite(Number(v)) ? Number(v) : null);
const ct = pick(msg?.score?.ct) ?? pick(msg?.scores?.ct) ?? pick(msg?.map?.team_ct?.score) ?? 0; const ct =
const t = pick(msg?.score?.t) ?? pick(msg?.scores?.t) ?? pick(msg?.map?.team_t?.score) ?? 0; pick(getObj(m.score)?.ct) ??
const rnd= pick(msg?.round) ?? pick(msg?.rounds?.played) ?? pick(msg?.map?.round) ?? null; pick(getObj(m.scores)?.ct) ??
pick(getObj(m.map)?.team_ct && getObj(getObj(m.map)?.team_ct)?.score) ??
0;
const t =
pick(getObj(m.score)?.t) ??
pick(getObj(m.scores)?.t) ??
pick(getObj(m.map)?.team_t && getObj(getObj(m.map)?.team_t)?.score) ??
0;
const rnd =
pick(m.round) ?? pick(getObj(m.rounds)?.played) ?? pick(getObj(m.map)?.round) ?? null;
setScore({ ct, t, round: rnd }); setScore({ ct, t, round: rnd });
scheduleFlush(); scheduleFlush();
}; };
const handleGrenades = (g:any) => { const handleGrenades = (g: unknown) => {
const list = normalizeGrenades(g); const list = normalizeGrenades(g);
const now = Date.now(); const now = Date.now();
// Trails nur für eigene Projektile // Trails nur für eigene Projektile
const mine = mySteamId const mine = mySteamId
? list.filter(n => n.ownerId === mySteamId && n.phase === 'projectile') ? list.filter((n) => n.ownerId === mySteamId && n.phase === 'projectile')
: []; : [];
const seenTrailIds = new Set<string>(); const seenTrailIds = new Set<string>();
for (const it of mine) { for (const it of mine) {
seenTrailIds.add(it.id); seenTrailIds.add(it.id);
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 }; const prev =
trailsRef.current.get(it.id) ?? ({ id: it.id, kind: it.kind, pts: [], lastSeen: 0 } as Trail);
const last = prev.pts[prev.pts.length - 1]; const last = prev.pts[prev.pts.length - 1];
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) { if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
prev.pts.push({ x: it.x, y: it.y }); prev.pts.push({ x: it.x, y: it.y });
if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints); if (prev.pts.length > UI.trail.maxPoints)
prev.pts = prev.pts.slice(-UI.trail.maxPoints);
} }
prev.kind = it.kind; prev.lastSeen = now; prev.kind = it.kind;
prev.lastSeen = now;
trailsRef.current.set(it.id, prev); trailsRef.current.set(it.id, prev);
} }
for (const [id, tr] of trailsRef.current) { for (const [id, tr] of trailsRef.current) {
if (!seenTrailIds.has(id) && now - tr.lastSeen > UI.trail.fadeMs) trailsRef.current.delete(id); if (!seenTrailIds.has(id) && now - tr.lastSeen > UI.trail.fadeMs) {
trailsRef.current.delete(id);
}
} }
// sanftes Mergen // sanftes Mergen + Verfall
const next = new Map<string, Grenade>(grenadesRef.current); const next = new Map<string, Grenade>(grenadesRef.current);
const seenIds = new Set<string>(); const seenIds = new Set<string>();
for (const it of list) { seenIds.add(it.id); next.set(it.id, { ...(next.get(it.id) || {}), ...it }); } for (const it of list) {
seenIds.add(it.id);
next.set(it.id, { ...(next.get(it.id) || {}), ...it });
}
for (const [id, nade] of next) { for (const [id, nade] of next) {
if (!seenIds.has(id) && nade.phase === 'projectile') next.delete(id); if (!seenIds.has(id) && nade.phase === 'projectile') next.delete(id);
if ((nade.phase === 'effect' || nade.phase === 'exploded') && nade.expiresAt != null && nade.expiresAt <= now) next.delete(id); if (
(nade.phase === 'effect' || nade.phase === 'exploded') &&
nade.expiresAt != null &&
nade.expiresAt <= now
)
next.delete(id);
} }
grenadesRef.current = next; grenadesRef.current = next;
scheduleFlush(); scheduleFlush();
}; };
const handleBomb = (normalizeBomb:(b:any)=>BombState|null) => (b:any) => { const handleBomb =
(normalizeBomb: (b: unknown) => BombState | null) =>
(b: unknown) => {
const prev = bombRef.current; const prev = bombRef.current;
const nb = normalizeBomb(b); const nb = normalizeBomb(b);
if (!nb) return; if (!nb) return;
const withPos = { const withPos = {
x: Number.isFinite(nb.x) ? nb.x : (prev?.x ?? 0), x: Number.isFinite(nb.x) ? nb.x : prev?.x ?? 0,
y: Number.isFinite(nb.y) ? nb.y : (prev?.y ?? 0), y: Number.isFinite(nb.y) ? nb.y : prev?.y ?? 0,
z: Number.isFinite(nb.z) ? nb.z : (prev?.z ?? 0), z: Number.isFinite(nb.z) ? nb.z : prev?.z ?? 0,
}; };
const sameStatus = prev && prev.status === nb.status; const sameStatus = prev && prev.status === nb.status;
bombRef.current = { ...withPos, status: nb.status, changedAt: sameStatus ? prev!.changedAt : Date.now() }; bombRef.current = {
...withPos,
status: nb.status,
changedAt: sameStatus ? prev!.changedAt : Date.now(),
};
scheduleFlush(); scheduleFlush();
}; };
return { return {
// state // state
radarWsStatus, setGameWsStatus, radarWsStatus,
activeMapKey, setActiveMapKey, setGameWsStatus,
players, playersRef, hoveredPlayerId, setHoveredPlayerId, activeMapKey,
grenades, trails, deathMarkers, setActiveMapKey,
players,
playersRef,
hoveredPlayerId,
setHoveredPlayerId,
grenades,
trails,
deathMarkers,
bomb, bomb,
roundPhase, roundEndsAtRef, bombEndsAtRef, defuseRef, roundPhase,
roundEndsAtRef,
bombEndsAtRef,
defuseRef,
score, score,
myTeam, myTeam,

View File

@ -31,16 +31,22 @@ export function defaultLifeMs(kind: Grenade['kind'], phase: Grenade['phase'] | n
/* ───────── Normalisierung ───────── */ /* ───────── Normalisierung ───────── */
type UnknownRecord = Record<string, unknown>;
const getObj = (v: unknown): UnknownRecord | undefined =>
v != null && typeof v === 'object' ? (v as UnknownRecord) : undefined;
const getArr = (v: unknown): unknown[] | undefined =>
Array.isArray(v) ? v : undefined;
const KIND_MAP: Record<string, Grenade['kind']> = { const KIND_MAP: Record<string, Grenade['kind']> = {
smoke: 'smoke', smokegrenade: 'smoke', smoke: 'smoke', smokegrenade: 'smoke',
molotov: 'molotov', incendiary: 'incendiary', incgrenade: 'incendiary', molotov: 'molotov', incendiary: 'incendiary', incgrenade: 'incendiary',
inferno: 'molotov', fire: 'molotov', firebomb: 'molotov', // häufige Synonyme inferno: 'molotov', fire: 'molotov', firebomb: 'molotov',
he: 'he', hegrenade: 'he', frag: 'he', explosive: 'he', he: 'he', hegrenade: 'he', frag: 'he', explosive: 'he',
flash: 'flash', flashbang: 'flash', flash: 'flash', flashbang: 'flash',
decoy: 'decoy' decoy: 'decoy'
}; };
const asNum = (n: any, d = NaN) => { const asNum = (n: unknown, d = NaN) => {
const v = Number(n); const v = Number(n);
return Number.isFinite(v) ? v : d; return Number.isFinite(v) ? v : d;
}; };
@ -51,19 +57,26 @@ const parseVec3String = (str?: string) => {
return { x, y, z }; return { x, y, z };
}; };
const parsePos = (g: any) => { const parsePos = (g: UnknownRecord) => {
// akzeptiert {x,y,z}, [x,y,z], "x, y, z" oder einzelne Felder // akzeptiert {x,y,z}, [x,y,z], "x, y, z" oder einzelne Felder
const pos = g.pos ?? g.position ?? g.location ?? g.coordinates ?? g.origin ?? [g.x, g.y, g.z]; const posSrc =
if (Array.isArray(pos)) { g.pos ?? g.position ?? g.location ?? g.coordinates ?? g.origin ?? [g.x, g.y, g.z];
return { x: asNum(pos[0]), y: asNum(pos[1]), z: asNum(pos[2], 0) };
const arr = getArr(posSrc);
if (arr) {
return { x: asNum(arr[0]), y: asNum(arr[1]), z: asNum(arr[2], 0) };
} }
if (typeof pos === 'string') {
const v = parseVec3String(pos); if (typeof posSrc === 'string') {
const v = parseVec3String(posSrc);
return { x: v.x, y: v.y, z: asNum(v.z, 0) }; return { x: v.x, y: v.y, z: asNum(v.z, 0) };
} }
if (pos && typeof pos === 'object') {
return { x: asNum(pos.x), y: asNum(pos.y), z: asNum(pos.z, 0) }; const o = getObj(posSrc);
if (o) {
return { x: asNum(o.x), y: asNum(o.y), z: asNum(o.z, 0) };
} }
return { x: asNum(g.x), y: asNum(g.y), z: asNum(g.z, 0) }; return { x: asNum(g.x), y: asNum(g.y), z: asNum(g.z, 0) };
}; };
@ -78,13 +91,18 @@ export function teamOfGrenade(
} }
/** Liefert eine normalisierte Liste von Grenades. */ /** Liefert eine normalisierte Liste von Grenades. */
export function normalizeGrenades(raw: any): Grenade[] { export function normalizeGrenades(raw: unknown): Grenade[] {
const arr = Array.isArray(raw) ? raw : Object.values(raw ?? {}); const src = getObj(raw);
const arr = Array.isArray(raw) ? (raw as unknown[]) : Object.values(src ?? {});
const out: Grenade[] = []; const out: Grenade[] = [];
for (const g of arr) { for (const gi of arr) {
const g = getObj(gi) ?? {};
// Kind // Kind
const kindRaw = String(g.kind ?? g.type ?? g.weapon ?? g.name ?? g.nade ?? 'unknown').toLowerCase(); const kindRaw = String(
(g.kind ?? g.type ?? g.weapon ?? g.name ?? g.nade ?? 'unknown') as string
).toLowerCase();
const kind: Grenade['kind'] = KIND_MAP[kindRaw] ?? 'unknown'; const kind: Grenade['kind'] = KIND_MAP[kindRaw] ?? 'unknown';
// Position // Position
@ -92,45 +110,54 @@ export function normalizeGrenades(raw: any): Grenade[] {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue; if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
// Phase // Phase
const phaseRaw = String(g.phase ?? g.state ?? g.status ?? '').toLowerCase(); const phaseRaw = String((g.phase ?? g.state ?? g.status ?? '') as string).toLowerCase();
const hasEffectHints = typeof g.effectTimeSec === 'number' || typeof g.lifeElapsedMs === 'number' || typeof g.expiresAt === 'number'; const hasEffectHints =
let phase: Grenade['phase'] = typeof g.effectTimeSec === 'number' ||
phaseRaw.includes('effect') || hasEffectHints ? 'effect' typeof g.lifeElapsedMs === 'number' ||
: phaseRaw.includes('explode') ? 'exploded' typeof g.expiresAt === 'number';
const phase: Grenade['phase'] =
phaseRaw.includes('effect') || hasEffectHints
? 'effect'
: phaseRaw.includes('explode')
? 'exploded'
: 'projectile'; : 'projectile';
// Heading (aus velocity/forward) // Heading (aus velocity/forward)
let headingRad: number | null = null; let headingRad: number | null = null;
const vel = g.vel ?? g.velocity ?? g.dir ?? g.forward; const vel = getObj(g.vel) ?? getObj(g.velocity) ?? getObj(g.dir) ?? getObj(g.forward);
if (vel && Number.isFinite(vel.x) && Number.isFinite(vel.y)) { if (vel && Number.isFinite(asNum(vel.x)) && Number.isFinite(asNum(vel.y))) {
headingRad = Math.atan2(Number(vel.y), Number(vel.x)); headingRad = Math.atan2(Number(vel.y), Number(vel.x));
} else if (Number.isFinite(g.headingRad)) { } else if (Number.isFinite(asNum(g.headingRad))) {
headingRad = Number(g.headingRad); headingRad = Number(g.headingRad);
} }
// ID (stabil genug; Engine-ID bevorzugen) // ID (stabil genug; Engine-ID bevorzugen)
const id = String(g.id ?? g.entityid ?? g.entindex ?? `${kind}#${Math.round(x)}:${Math.round(y)}:${Math.round(z)}`); const id = String(
(g.id ?? g.entityid ?? g.entindex ??
`${kind}#${Math.round(x)}:${Math.round(y)}:${Math.round(z)}`) as string
);
// Meta // Meta
const team = g.team === 'T' || g.team === 'CT' ? g.team : null; const team = g.team === 'T' || g.team === 'CT' ? (g.team as 'T' | 'CT') : null;
const radius = Number.isFinite(Number(g.radius)) ? Number(g.radius) : null; const radius = Number.isFinite(asNum(g.radius)) ? Number(g.radius) : null;
const spawnedAt = Number.isFinite(Number(g.spawnedAt ?? g.t)) ? Number(g.spawnedAt ?? g.t) : Date.now(); const spawnedAt = Number.isFinite(asNum(g.spawnedAt ?? g.t))
const ownerId = g.ownerId ?? g.owner ?? g.thrower ?? g.player ?? g.userid ?? null; ? Number(g.spawnedAt ?? g.t)
: Date.now();
const ownerRaw =
g.ownerId ?? g.owner ?? g.thrower ?? g.player ?? g.userid ?? null;
const ownerId = ownerRaw != null ? String(ownerRaw) : null;
// Zeit-/Effektfelder vom Server (optional) // Zeit-/Effektfelder vom Server (optional)
let effectTimeSec = typeof g.effectTimeSec === 'number' ? g.effectTimeSec : undefined; let effectTimeSec =
let lifeElapsedMs = typeof g.lifeElapsedMs === 'number' ? g.lifeElapsedMs : undefined; typeof g.effectTimeSec === 'number' ? (g.effectTimeSec as number) : undefined;
let lifeLeftMs = typeof g.lifeLeftMs === 'number' ? g.lifeLeftMs : undefined; let lifeElapsedMs =
let expiresAt = typeof g.expiresAt === 'number' ? g.expiresAt : undefined; typeof g.lifeElapsedMs === 'number' ? (g.lifeElapsedMs as number) : undefined;
let lifeLeftMs =
typeof g.lifeLeftMs === 'number' ? (g.lifeLeftMs as number) : undefined;
let expiresAt =
typeof g.expiresAt === 'number' ? (g.expiresAt as number) : undefined;
/* Smoke lokal um +2s verlängern /* ── Smoke lokal um +2s verlängern ─────────────────────────────── */
Strategie:
- Wir definieren eine *lokale* Gesamtdauer = default(21s) + 2s Linger.
- Wenn der Server effectTimeSec liefert, drehen wir die Zeit zurück
(elapsed -= 2s), wodurch rechnerisch 2s mehr Restzeit bleiben.
- Andernfalls erhöhen wir die Restzeit bzw. schieben expiresAt nach hinten.
- Wir setzen expiresAt mindestens auf jetzt + lifeLeftMs, damit der
Renderer die Smoke nicht vorzeitig entfernt. */
if (kind === 'smoke' && phase === 'effect') { if (kind === 'smoke' && phase === 'effect') {
const base = defaultLifeMs('smoke', 'effect'); // 21_000 const base = defaultLifeMs('smoke', 'effect'); // 21_000
const total = base + SMOKE_LINGER_MS; // 23_000 const total = base + SMOKE_LINGER_MS; // 23_000
@ -138,7 +165,7 @@ export function normalizeGrenades(raw: any): Grenade[] {
if (typeof effectTimeSec === 'number') { if (typeof effectTimeSec === 'number') {
const elapsedRaw = Math.max(0, Math.round(effectTimeSec * 1000)) + SMOKE_LINGER_MS; const elapsedRaw = Math.max(0, Math.round(effectTimeSec * 1000)) + SMOKE_LINGER_MS;
const elapsedAdj = Math.max(0, elapsedRaw); // „+2s länger“ const elapsedAdj = Math.max(0, elapsedRaw);
const left = Math.max(0, total - elapsedAdj); const left = Math.max(0, total - elapsedAdj);
lifeElapsedMs = elapsedAdj; lifeElapsedMs = elapsedAdj;
@ -148,20 +175,16 @@ export function normalizeGrenades(raw: any): Grenade[] {
const expLocal = now + left; const expLocal = now + left;
expiresAt = Math.max(expiresAt ?? 0, expLocal); expiresAt = Math.max(expiresAt ?? 0, expLocal);
} else if (typeof lifeElapsedMs === 'number') { } else if (typeof lifeElapsedMs === 'number') {
// Wir kennen die verstrichene Zeit → Rest = total - elapsed
const left = Math.max(0, total - lifeElapsedMs); const left = Math.max(0, total - lifeElapsedMs);
lifeLeftMs = Math.max(lifeLeftMs ?? 0, left); lifeLeftMs = Math.max(lifeLeftMs ?? 0, left);
expiresAt = Math.max(expiresAt ?? 0, now + (lifeLeftMs ?? 0)); expiresAt = Math.max(expiresAt ?? 0, now + (lifeLeftMs ?? 0));
} else if (typeof lifeLeftMs === 'number') { } else if (typeof lifeLeftMs === 'number') {
// Wir kennen nur die Restzeit → +2s addieren
lifeLeftMs = Math.max(0, lifeLeftMs + SMOKE_LINGER_MS); lifeLeftMs = Math.max(0, lifeLeftMs + SMOKE_LINGER_MS);
expiresAt = Math.max(expiresAt ?? 0, now + lifeLeftMs); expiresAt = Math.max(expiresAt ?? 0, now + lifeLeftMs);
} else if (typeof expiresAt === 'number') { } else if (typeof expiresAt === 'number') {
// Nur expiresAt bekannt → um +2s schieben
expiresAt = expiresAt + SMOKE_LINGER_MS; expiresAt = expiresAt + SMOKE_LINGER_MS;
lifeLeftMs = Math.max(0, expiresAt - now); lifeLeftMs = Math.max(0, expiresAt - now);
} else { } else {
// Keine Zeitangaben → aus Spawn + total ableiten
const exp = spawnedAt + total; const exp = spawnedAt + total;
expiresAt = exp; expiresAt = exp;
lifeElapsedMs = Math.max(0, now - spawnedAt); lifeElapsedMs = Math.max(0, now - spawnedAt);
@ -171,14 +194,21 @@ export function normalizeGrenades(raw: any): Grenade[] {
} }
out.push({ out.push({
id, kind, x, y, z: Number.isFinite(z) ? z : 0, id,
kind,
x,
y,
z: Number.isFinite(z) ? z : 0,
radius, radius,
expiresAt: expiresAt ?? null, expiresAt: expiresAt ?? null,
team, phase, headingRad, spawnedAt, team,
ownerId: ownerId ? String(ownerId) : null, phase,
headingRad,
spawnedAt,
ownerId,
effectTimeSec, effectTimeSec,
lifeElapsedMs, lifeElapsedMs,
lifeLeftMs lifeLeftMs,
}); });
} }

View File

@ -2,51 +2,82 @@
import { Mapper, Overview } from './types'; import { Mapper, Overview } from './types';
type UnknownRecord = Record<string, unknown>;
const getObj = (v: unknown): UnknownRecord | undefined =>
v != null && typeof v === 'object' ? (v as UnknownRecord) : undefined;
export const RAD2DEG = 180 / Math.PI; export const RAD2DEG = 180 / Math.PI;
export const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:'); export const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:');
export const asNum = (n: any, def=0) => { const v = Number(n); return Number.isFinite(v) ? v : def }; export const asNum = (n: unknown, def = 0): number => {
const v = Number(n);
return Number.isFinite(v) ? v : def;
};
export function contrastStroke(hex: string) { export function contrastStroke(hex: string) {
const h = hex.replace('#', ''); const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16) / 255; const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255; const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255; const b = parseInt(h.slice(4, 6), 16) / 255;
const toL = (c:number) => (c<=0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4)); const toL = (c: number) =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
const L = 0.2126 * toL(r) + 0.7152 * toL(g) + 0.0722 * toL(b); const L = 0.2126 * toL(r) + 0.7152 * toL(g) + 0.0722 * toL(b);
return L > 0.6 ? '#111111' : '#ffffff'; return L > 0.6 ? '#111111' : '#ffffff';
} }
export function mapTeam(t: any): 'T' | 'CT' | string { export function mapTeam(t: unknown): 'T' | 'CT' | string {
if (t === 2 || t === 'T' || t === 't') return 'T'; if (t === 2 || t === 'T' || t === 't') return 'T';
if (t === 3 || t === 'CT' || t === 'ct') return 'CT'; if (t === 3 || t === 'CT' || t === 'ct') return 'CT';
return String(t ?? ''); return String(t ?? '');
} }
const isFiniteXY = (x:any,y:any) => Number.isFinite(x) && Number.isFinite(y) && !(x===0 && y===0); const isFiniteXY = (x: unknown, y: unknown) =>
Number.isFinite(x) && Number.isFinite(y) && !(x === 0 && y === 0);
export function pickVec2Loose(v:any): {x:number,y:number}|null { export function pickVec2Loose(
v: unknown
): { x: number; y: number } | null {
if (!v) return null; if (!v) return null;
if (Array.isArray(v)) { if (Array.isArray(v)) {
const x = Number(v[0]), y = Number(v[1]); const x = Number(v[0]),
return isFiniteXY(x,y) ? {x,y} : null; y = Number(v[1]);
}
if (typeof v === 'string') {
const [xs,ys] = v.split(','); const x = Number(xs), y = Number(ys);
return isFiniteXY(x,y) ? {x,y} : null;
}
const x = Number(v?.x), y = Number(v?.y);
return isFiniteXY(x, y) ? { x, y } : null; return isFiniteXY(x, y) ? { x, y } : null;
} }
export const steamIdOf = (src: any): string | null => { if (typeof v === 'string') {
const raw = src?.steamId ?? src?.steam_id ?? src?.steamid ?? src?.id ?? src?.entityId ?? src?.entindex; const [xs, ys] = v.split(',');
const s = raw != null ? String(raw) : ''; const x = Number(xs),
if (/^\d{17}$/.test(s)) return s; y = Number(ys);
const name = (src?.name ?? src?.playerName ?? '').toString().trim(); return isFiniteXY(x, y) ? { x, y } : null;
}
const o = getObj(v);
const x = Number(o?.x),
y = Number(o?.y);
return isFiniteXY(x, y) ? { x, y } : null;
}
export const steamIdOf = (src: unknown): string | null => {
const s = getObj(src);
const raw =
s?.steamId ??
s?.steam_id ??
s?.steamid ??
s?.id ??
s?.entityId ??
s?.entindex;
const rawStr = raw != null ? String(raw) : '';
if (/^\d{17}$/.test(rawStr)) return rawStr;
const name = String(
(s?.name ?? s?.playerName ?? '') as string
).trim();
if (name) return `BOT:${name}`; if (name) return `BOT:${name}`;
if (s && s !== '0' && s.toUpperCase() !== 'BOT') return s;
if (rawStr && rawStr !== '0' && rawStr.toUpperCase() !== 'BOT') return rawStr;
return null; return null;
}; };
@ -62,20 +93,27 @@ export function defaultWorldToPx(imgSize: {w:number;h:number}|null): Mapper {
}; };
} }
export function parseOverviewJson(j: any): Overview | null { export function parseOverviewJson(j: unknown): Overview | null {
const posX = Number(j?.posX ?? j?.pos_x); const o = getObj(j) ?? {};
const posY = Number(j?.posY ?? j?.pos_y); const posX = Number(o?.posX ?? (o as UnknownRecord)['pos_x']);
const scale = Number(j?.scale); const posY = Number(o?.posY ?? (o as UnknownRecord)['pos_y']);
const rotate = Number(j?.rotate ?? 0); const scale = Number(o?.scale);
const rotate = Number(o?.rotate ?? 0);
if (![posX, posY, scale].every(Number.isFinite)) return null; if (![posX, posY, scale].every(Number.isFinite)) return null;
return { posX, posY, scale, rotate }; return { posX, posY, scale, rotate };
} }
export function parseValveKvOverview(txt: string): Overview | null { export function parseValveKvOverview(txt: string): Overview | null {
const clean = txt.replace(/\/\/.*$/gm, ''); const clean = txt.replace(/\/\/.*$/gm, '');
const pick = (k: string) => { const m = clean.match(new RegExp(`"\\s*${k}\\s*"\\s*"([^"]+)"`)); return m ? Number(m[1]) : NaN; }; const pick = (k: string) => {
const posX = pick('pos_x'), posY = pick('pos_y'), scale = pick('scale'); const m = clean.match(new RegExp(`"\\s*${k}\\s*"\\s*"([^"]+)"`));
const r = pick('rotate'); const rotate = Number.isFinite(r) ? r : 0; return m ? Number(m[1]) : NaN;
};
const posX = pick('pos_x'),
posY = pick('pos_y'),
scale = pick('scale');
const r = pick('rotate');
const rotate = Number.isFinite(r) ? r : 0;
if (![posX, posY, scale].every(Number.isFinite)) return null; if (![posX, posY, scale].every(Number.isFinite)) return null;
return { posX, posY, scale, rotate }; return { posX, posY, scale, rotate };
} }

View File

@ -1,8 +1,10 @@
// /src/app/[locale]/components/settings/account/AppearanceSettings.tsx
'use client' 'use client'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslations, useLocale } from 'next-intl' import { useTranslations } from 'next-intl'
import Image from 'next/image'
export default function AppearanceSettings() { export default function AppearanceSettings() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
@ -18,44 +20,60 @@ export default function AppearanceSettings() {
if (!mounted) return null if (!mounted) return null
const options = [ const options = [
{ id: 'system', label: tSettings("sections.account.page.AppearanceSettings.theme.system"), img: 'account-system-image.svg' }, {
{ id: 'light', label: tSettings("sections.account.page.AppearanceSettings.theme.light"), img: 'account-light-image.svg' }, id: 'system',
{ id: 'dark', label: tSettings("sections.account.page.AppearanceSettings.theme.dark"), img: 'account-dark-image.svg' }, label: tSettings('sections.account.page.AppearanceSettings.theme.system'),
] img: 'account-system-image.svg',
},
{
id: 'light',
label: tSettings('sections.account.page.AppearanceSettings.theme.light'),
img: 'account-light-image.svg',
},
{
id: 'dark',
label: tSettings('sections.account.page.AppearanceSettings.theme.dark'),
img: 'account-dark-image.svg',
},
] as const
return ( return (
<div className="py-3 sm:py-4 space-y-5"> <div className="py-3 sm:py-4 space-y-5">
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5"> <div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
<div className="sm:col-span-4 2xl:col-span-2"> <div className="sm:col-span-4 2xl:col-span-2">
<label className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500"> <label className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings("sections.account.page.AppearanceSettings.name")} {tSettings('sections.account.page.AppearanceSettings.name')}
</label> </label>
</div> </div>
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5"> <div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
<p className="text-sm text-gray-500 dark:text-neutral-500"> <p className="text-sm text-gray-500 dark:text-neutral-500">
{tSettings("sections.account.page.AppearanceSettings.description")} {tSettings('sections.account.page.AppearanceSettings.description')}
</p> </p>
<h3 className="mt-3 text-sm font-semibold text-gray-800 dark:text-neutral-200"> <h3 className="mt-3 text-sm font-semibold text-gray-800 dark:text-neutral-200">
{tSettings("sections.account.page.AppearanceSettings.theme-mode")} {tSettings('sections.account.page.AppearanceSettings.theme-mode')}
</h3> </h3>
<p className="text-sm text-gray-500 dark:text-neutral-500"> <p className="text-sm text-gray-500 dark:text-neutral-500">
{tSettings("sections.account.page.AppearanceSettings.theme-mode-description")} {tSettings('sections.account.page.AppearanceSettings.theme-mode-description')}
</p> </p>
<div className="mt-5"> <div className="mt-5">
<div className="grid grid-cols-3 gap-x-2 sm:gap-x-4"> <div className="grid grid-cols-3 gap-x-2 sm:gap-x-4">
{options.map(({ id, label, img }) => { {options.map(({ id, label, img }) => {
const isChecked = theme === id const isChecked = theme === id
const src = img ? `/assets/img/themes/${img}` : '/assets/img/logos/cs2.webp'
return ( return (
<label <label
key={id} key={id}
htmlFor={`theme-${id}`} htmlFor={`theme-${id}`}
className={`w-full sm:w-auto flex flex-col bg-white text-center cursor-pointer rounded-xl ring-1 ring-gray-200 dark:bg-neutral-800 dark:text-neutral-200 dark:ring-neutral-700 className={[
${isChecked ? 'ring-1 ring-blue-600 dark:ring-blue-500' : 'ring-1 ring-gray-200 dark:ring-neutral-700'}`} 'w-full sm:w-auto flex flex-col bg-white text-center cursor-pointer rounded-xl ring-1',
'dark:bg-neutral-800 dark:text-neutral-200',
isChecked ? 'ring-blue-600 dark:ring-blue-500' : 'ring-gray-200 dark:ring-neutral-700',
].join(' ')}
> >
<input <input
type="radio" type="radio"
@ -66,13 +84,25 @@ export default function AppearanceSettings() {
checked={isChecked} checked={isChecked}
onChange={() => setTheme(id)} onChange={() => setTheme(id)}
/> />
<img className="rounded-t-[14px] -mt-px" src={img ? `/assets/img/themes/${img}` : '/assets/img/logos/cs2.webp'} alt={label} loading="lazy" />
<div className="relative w-full aspect-[16/9] rounded-t-[14px] overflow-hidden -mt-px">
<Image
src={src}
alt={label}
fill
sizes="(min-width: 1024px) 220px, (min-width: 640px) 33vw, 100vw"
className="object-cover"
// Falls deine Assets SVGs sind und du keine Optimierung willst:
// unoptimized={src.endsWith('.svg')}
priority={false}
/>
</div>
<span <span
className={`py-3 px-2 text-sm font-semibold rounded-b-xl className={[
${isChecked 'py-3 px-2 text-sm font-semibold rounded-b-xl',
? 'bg-blue-600 text-white' isChecked ? 'bg-blue-600 text-white' : 'text-gray-800 dark:text-neutral-200',
: 'text-gray-800 dark:text-neutral-200' ].join(' ')}
}`}
> >
{label} {label}
</span> </span>

View File

@ -2,11 +2,11 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import Popover from '../../Popover' import Popover from '../../Popover'
import Button from '../../Button' import Button from '../../Button'
import { useTranslations, useLocale } from 'next-intl' import { useTranslations } from 'next-intl'
export default function AuthCodeSettings() { export default function AuthCodeSettings() {
const [authCode, setAuthCode] = useState('') const [authCode, setAuthCode] = useState('')

View File

@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import Popover from '../../Popover' import Popover from '../../Popover'
import Button from '../../Button' import Button from '../../Button'
import { useTranslations, useLocale } from 'next-intl' import { useTranslations } from 'next-intl'
export default function LatestKnownCodeSettings() { export default function LatestKnownCodeSettings() {
const [lastKnownShareCode, setLastKnownShareCode] = useState('') const [lastKnownShareCode, setLastKnownShareCode] = useState('')

View File

@ -1,10 +1,9 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Popover from '../../Popover'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
// Versuche, native Liste zu nehmen; fallback auf gängige Auswahl. // Fallback-Liste für Umgebungen ohne Intl.supportedValuesOf
const FALLBACK_TIMEZONES = [ const FALLBACK_TIMEZONES = [
'UTC', 'UTC',
'Europe/Berlin', 'Europe/Vienna', 'Europe/Zurich', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Zurich',
@ -14,16 +13,21 @@ const FALLBACK_TIMEZONES = [
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'America/Sao_Paulo', 'America/Sao_Paulo',
'Asia/Tokyo', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Kolkata', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Kolkata',
'Australia/Sydney' 'Australia/Sydney',
] ]
function getTimeZones(): string[] { function getTimeZones(): string[] {
// @ts-ignore Node/Browser, je nach Runtime verfügbar // typ-sicher prüfen, ohne ts-ignore
if (typeof Intl.supportedValuesOf === 'function') { const maybeSupportedValuesOf = (Intl as unknown as { supportedValuesOf?: (k: string) => unknown[] }).supportedValuesOf
if (typeof maybeSupportedValuesOf === 'function') {
try { try {
// @ts-ignore const list = maybeSupportedValuesOf('timeZone')
return Intl.supportedValuesOf('timeZone') as string[] if (Array.isArray(list) && list.every((z) => typeof z === 'string')) {
} catch {} return list as string[]
}
} catch {
// ignore and fall back
}
} }
return FALLBACK_TIMEZONES return FALLBACK_TIMEZONES
} }
@ -50,7 +54,7 @@ export default function UserSettings() {
try { try {
const res = await fetch('/api/user/timezone', { cache: 'no-store' }) const res = await fetch('/api/user/timezone', { cache: 'no-store' })
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
const tz = data?.timeZone ?? null const tz = (data as { timeZone?: string })?.timeZone ?? null
setTimeZone(tz) setTimeZone(tz)
setInitialTz(tz) setInitialTz(tz)
} catch (e) { } catch (e) {
@ -61,9 +65,8 @@ export default function UserSettings() {
})() })()
}, []) }, [])
// Helper: Speichern (mit Abort bei schnellen Wechseln) // Speichern (mit Abort bei schnellen Wechseln)
const persist = async (tz: string | null) => { const persist = async (tz: string | null) => {
// laufende Anfrage abbrechen
inFlight.current?.abort() inFlight.current?.abort()
const ctrl = new AbortController() const ctrl = new AbortController()
inFlight.current = ctrl inFlight.current = ctrl
@ -77,22 +80,23 @@ export default function UserSettings() {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timeZone: tz }), body: JSON.stringify({ timeZone: tz }),
signal: ctrl.signal signal: ctrl.signal,
}) })
if (!res.ok) { if (!res.ok) {
const j = await res.json().catch(() => ({})) const j = await res.json().catch(() => ({}))
throw new Error(j?.message || `HTTP ${res.status}`) throw new Error((j as { message?: string })?.message || `HTTP ${res.status}`)
} }
setInitialTz(tz) setInitialTz(tz)
setSavedOk(true) setSavedOk(true)
setTouched(false) // <- hinzu setTouched(false)
// kleines Auto-Reset des „Gespeichert“-Hinweises
window.setTimeout(() => setSavedOk(null), 2000) window.setTimeout(() => setSavedOk(null), 2000)
} catch (e: any) { } catch (e: unknown) {
if (e?.name === 'AbortError') return // Abort sauber behandeln
if (e instanceof DOMException && e.name === 'AbortError') return
const msg = e instanceof Error ? e.message : 'Save failed'
console.error('[UserSettings] Speichern fehlgeschlagen:', e) console.error('[UserSettings] Speichern fehlgeschlagen:', e)
setSavedOk(false) setSavedOk(false)
setErrorMsg(e?.message ?? 'Save failed') setErrorMsg(msg)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@ -105,7 +109,7 @@ export default function UserSettings() {
if (debounceTimer.current) window.clearTimeout(debounceTimer.current) if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
debounceTimer.current = window.setTimeout(() => { debounceTimer.current = window.setTimeout(() => {
persist(timeZone ?? null) void persist(timeZone ?? null)
}, 400) as unknown as number }, 400) as unknown as number
return () => { return () => {
@ -116,34 +120,29 @@ export default function UserSettings() {
return ( return (
<div className="py-3 sm:py-4 space-y-5"> <div className="py-3 sm:py-4 space-y-5">
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5"> <div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
{/* Label + Hilfe */}
<div className="sm:col-span-4 2xl:col-span-2"> <div className="sm:col-span-4 2xl:col-span-2">
<label htmlFor="user-timezone" className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500"> <label htmlFor="user-timezone" className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings('sections.user.timezone-label')} {tSettings('sections.user.timezone-label')}
</label> </label>
</div> </div>
{/* Eingabe */}
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5"> <div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
id="user-timezone" id="user-timezone"
value={timeZone ?? ''} // bleibt kontrolliert value={timeZone ?? ''} // kontrollierter Input
onChange={(e) => { setTimeZone(e.target.value || null); setTouched(true) }} onChange={(e) => { setTimeZone(e.target.value || null); setTouched(true) }}
disabled={saving || loading} // während Lade-/Speicherphase sperren disabled={saving || loading}
aria-busy={loading ? 'true' : undefined} // a11y aria-busy={loading ? 'true' : undefined}
className="max-w-md rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm disabled:opacity-70" className="max-w-md rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm disabled:opacity-70"
> >
{loading ? ( {loading ? (
// Nur während des Ladens:
<option value="" disabled> <option value="" disabled>
{tCommon("loading")}... {tCommon('loading')}
</option> </option>
) : ( ) : (
<> <>
<option value=""> <option value="">{tSettings('sections.user.timezone-system')}</option>
{tSettings('sections.user.timezone-system')}
</option>
{timeZones.map((tz) => ( {timeZones.map((tz) => (
<option key={tz} value={tz}>{tz}</option> <option key={tz} value={tz}>{tz}</option>
))} ))}
@ -151,7 +150,6 @@ export default function UserSettings() {
)} )}
</select> </select>
{/* Live-Status rechts (optional) */}
<span className="text-xs min-w-[80px] text-right"> <span className="text-xs min-w-[80px] text-right">
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving')}</span>} {saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving')}</span>}
{savedOk === true && <span className="text-teal-600"> {tCommon('saved')}</span>} {savedOk === true && <span className="text-teal-600"> {tCommon('saved')}</span>}

View File

@ -1,4 +1,4 @@
// app/[locale]/settings/_components/PrivacySettings.tsx // /src/app/[locale]/components/settings/privacy/PrivacySettings.tsx
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@ -26,7 +26,10 @@ export default function PrivacySettings() {
try { try {
const res = await fetch('/api/user/privacy', { cache: 'no-store' }) const res = await fetch('/api/user/privacy', { cache: 'no-store' })
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
const value = typeof data?.canBeInvited === 'boolean' ? data.canBeInvited : true const value =
typeof (data as { canBeInvited?: unknown })?.canBeInvited === 'boolean'
? (data as { canBeInvited: boolean }).canBeInvited
: true
setCanBeInvited(value) setCanBeInvited(value)
setInitial(value) setInitial(value)
} catch (e) { } catch (e) {
@ -52,20 +55,24 @@ export default function PrivacySettings() {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ canBeInvited: value }), body: JSON.stringify({ canBeInvited: value }),
signal: ctrl.signal signal: ctrl.signal,
}) })
if (!res.ok) { if (!res.ok) {
const j = await res.json().catch(() => ({})) const j = await res.json().catch(() => ({}))
throw new Error(j?.message || `HTTP ${res.status}`) const msg =
(j as { message?: string })?.message || `HTTP ${res.status}`
throw new Error(msg)
} }
setInitial(value) setInitial(value)
setSavedOk(true) setSavedOk(true)
window.setTimeout(() => setSavedOk(null), 2000) window.setTimeout(() => setSavedOk(null), 2000)
} catch (e: any) { } catch (e: unknown) {
if (e?.name === 'AbortError') return // Abort sauber behandeln
if (e instanceof DOMException && e.name === 'AbortError') return
const msg = e instanceof Error ? e.message : 'Save failed'
console.error('[PrivacySettings] save failed:', e) console.error('[PrivacySettings] save failed:', e)
setSavedOk(false) setSavedOk(false)
setErrorMsg(e?.message ?? 'Save failed') setErrorMsg(msg)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@ -76,7 +83,7 @@ export default function PrivacySettings() {
if (canBeInvited === initial) return if (canBeInvited === initial) return
if (debounceTimer.current) window.clearTimeout(debounceTimer.current) if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
debounceTimer.current = window.setTimeout(() => { debounceTimer.current = window.setTimeout(() => {
persist(canBeInvited) void persist(canBeInvited)
}, 400) as unknown as number }, 400) as unknown as number
return () => { return () => {
if (debounceTimer.current) window.clearTimeout(debounceTimer.current) if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
@ -85,28 +92,23 @@ export default function PrivacySettings() {
return ( return (
<div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700"> <div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700">
{/* Zeile: alles vertikal mittig */}
<div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-center"> <div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-center">
{/* Label-Spalte */}
<div className="sm:col-span-4 2xl:col-span-2 flex items-center"> <div className="sm:col-span-4 2xl:col-span-2 flex items-center">
<span className="inline-block text-sm text-gray-500 dark:text-neutral-500"> <span className="inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings('sections.privacy.invites.label')} {tSettings('sections.privacy.invites.label')}
</span> </span>
</div> </div>
{/* Inhalt-Spalte */}
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5"> <div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
{/* Switch + Hilfstext rechts → vertikal mittig */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Toggle */}
<button <button
type="button" type="button"
disabled={loading || saving} disabled={loading || saving}
onClick={() => setCanBeInvited(v => !v)} onClick={() => setCanBeInvited((v) => !v)}
className={[ className={[
'relative inline-flex h-6 w-11 items-center rounded-full transition', 'relative inline-flex h-6 w-11 items-center rounded-full transition',
canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700', canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700',
'disabled:opacity-60 disabled:cursor-not-allowed' 'disabled:opacity-60 disabled:cursor-not-allowed',
].join(' ')} ].join(' ')}
aria-pressed={canBeInvited} aria-pressed={canBeInvited}
aria-label={tSettings('sections.privacy.invites.label')} aria-label={tSettings('sections.privacy.invites.label')}
@ -119,15 +121,12 @@ export default function PrivacySettings() {
/> />
</button> </button>
{/* Rechts: Hilfs-Text + Status NEBENeinander */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{/* Hilfstext links, darf umbrechen */}
<p className="m-0 text-sm text-gray-500 dark:text-neutral-400 min-w-0 flex-1"> <p className="m-0 text-sm text-gray-500 dark:text-neutral-400 min-w-0 flex-1">
{tSettings('sections.privacy.invites.help')} {tSettings('sections.privacy.invites.help')}
</p> </p>
{/* Status rechts daneben, bleibt in einer Zeile */}
<div className="ml-auto text-xs whitespace-nowrap" aria-live="polite"> <div className="ml-auto text-xs whitespace-nowrap" aria-live="polite">
{loading && ( {loading && (
<span className="text-gray-500 dark:text-neutral-400"> <span className="text-gray-500 dark:text-neutral-400">
@ -140,9 +139,7 @@ export default function PrivacySettings() {
</span> </span>
)} )}
{savedOk === true && ( {savedOk === true && (
<span className="text-teal-600"> <span className="text-teal-600"> {tCommon('saved') ?? 'Gespeichert'}</span>
{tCommon('saved') ?? 'Gespeichert'}
</span>
)} )}
{savedOk === false && ( {savedOk === false && (
<span className="text-red-600"> <span className="text-red-600">

View File

@ -1,62 +0,0 @@
// /src/app/dashboard/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl'
export default function Dashboard() {
const { data: session, status } = useSession()
const [teams, setTeams] = useState<string[]>([])
const [selectedTeam, setSelectedTeam] = useState('')
const router = useRouter()
const pathname = usePathname()
const locale = useLocale()
const tDashboard = useTranslations('dashboard')
// Teams laden (robust)
useEffect(() => {
let abort = false
async function fetchTeams() {
try {
const res = await fetch('/api/teams', { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
let json: any = null
try { json = await res.json() } catch {}
const teamsArr: any[] =
Array.isArray(json?.teams) ? json.teams :
Array.isArray(json?.data) ? json.data :
Array.isArray(json) ? json :
[]
if (!abort) {
setTeams(teamsArr.map((t) => t?.teamname ?? t?.name ?? 'Unbenannt'))
}
} catch (error) {
console.error('Fehler beim Laden der Teams:', error)
if (!abort) setTeams([])
}
}
fetchTeams()
return () => { abort = true }
}, [])
return (
<>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">
{tDashboard('title')}
</h1>
{/* Beispiel: Teams anzeigen (optional) */}
{/* <pre className="text-xs opacity-70">{JSON.stringify(teams, null, 2)}</pre> */}
</>
)
}

View File

@ -15,7 +15,7 @@ async function loadMatch(matchId: string): Promise<Match | null> {
const base = host ? `${proto}://${host}` : (process.env.NEXTAUTH_URL ?? 'http://localhost:3000') const base = host ? `${proto}://${host}` : (process.env.NEXTAUTH_URL ?? 'http://localhost:3000')
const insecure = new Agent({ connect: { rejectUnauthorized: false } }) const insecure = new Agent({ connect: { rejectUnauthorized: false } })
const init: any = { cache: 'no-store' } const init: RequestInit & { dispatcher?: Agent } = { cache: 'no-store' }
if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') { if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') {
init.dispatcher = insecure init.dispatcher = insecure
} }

View File

@ -1,7 +1,242 @@
export default function Page() { // /src/app/[locale]/page.tsx
return ( 'use client'
<>
<h1>Home</h1> import { useEffect, useState } from 'react'
</> import { useTranslations } from 'next-intl'
); import Image from 'next/image'
import Link from 'next/link'
// ---- minimal types (no any)
type TeamLike = { id?: string; name?: string; teamname?: string; logo?: string | null }
type TeamsJson = { teams?: TeamLike[] } | { data?: TeamLike[] } | TeamLike[] | unknown
function parseTeams(json: TeamsJson): TeamLike[] {
if (Array.isArray(json)) return json as TeamLike[]
if (typeof json === 'object' && json !== null) {
const o = json as Record<string, unknown>
if (Array.isArray(o.teams)) return o.teams as TeamLike[]
if (Array.isArray(o.data)) return o.data as TeamLike[]
}
return []
}
export default function Dashboard() {
const t = useTranslations('dashboard')
const [loading, setLoading] = useState(true)
const [teams, setTeams] = useState<TeamLike[]>([])
useEffect(() => {
let aborted = false
;(async () => {
try {
const res = await fetch('/api/teams', { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json: unknown = await res.json().catch(() => ({}))
if (!aborted) setTeams(parseTeams(json))
} catch {
if (!aborted) setTeams([])
} finally {
if (!aborted) setLoading(false)
}
})()
return () => {
aborted = true
}
}, [])
return (
<div className="space-y-8">
{/* HERO */}
<section className="relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-6 sm:p-8 dark:border-neutral-800 dark:bg-neutral-900">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute -top-24 right-0 h-64 w-64 rounded-full blur-3xl opacity-30 bg-gradient-to-br from-indigo-500 to-blue-400 dark:opacity-20" />
<div className="absolute -bottom-24 left-0 h-64 w-64 rounded-full blur-3xl opacity-30 bg-gradient-to-tr from-rose-500 to-amber-400 dark:opacity-20" />
</div>
<div className="flex flex-col-reverse items-start gap-6 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
{t('title')}
</h1>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-neutral-300">
Organisiere Matches, lade Mitspieler ein und analysiere Demos alles an einem Ort.
</p>
{/* CTAs */}
<div className="mt-5 flex flex-wrap gap-3">
<Link
href="/team"
className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900"
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
<path d="M10 3a1 1 0 0 1 1 1v5h5a1 1 0 1 1 0 2h-5v5a1 1 0 1 1-2 0v-5H4a1 1 0 1 1 0-2h5V4a1 1 0 0 1 1-1z" />
</svg>
Team erstellen
</Link>
<Link
href="/teams"
className="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700"
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M7 10a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm10 0a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM7 12c-3.866 0-7 2.239-7 5v1a1 1 0 0 0 1 1h12v-2c0-2.761-3.134-5-7-5Zm10 0c-.7 0-1.374.088-2 .248 2.39 1.023 4 2.96 4 5.252v2h4a1 1 0 0 0 1-1v-1c0-2.761-3.134-5-7-5Z" />
</svg>
Team finden & beitreten
</Link>
<Link
href="/schedule"
className="inline-flex items-center rounded-md border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100 dark:border-indigo-900/40 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50"
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M7 2a1 1 0 0 1 1 1v1h8V3a1 1 0 1 1 2 0v1h1a2 2 0 0 1 2 2v3H2V6a2 2 0 0 1 2-2h1V3a1 1 0 0 1 2 0v1ZM2 10h20v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8Zm5 3a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H7Z" />
</svg>
Match planen
</Link>
</div>
</div>
{/* small visual on the right */}
<div className="shrink-0">
<div className="relative h-28 w-28 overflow-hidden rounded-xl ring-1 ring-gray-200 dark:ring-neutral-800">
<Image
src="/assets/img/logos/cs2.webp"
alt="CS2"
fill
className="object-cover"
sizes="112px"
priority
/>
</div>
</div>
</div>
</section>
{/* GRID: Teams + Activity + Promo */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Your Teams */}
<div className="lg:col-span-2 space-y-4">
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Deine Teams</h2>
<Link
href="/teams"
className="text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-300"
>
Alle ansehen
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="animate-pulse rounded-lg border border-gray-200 p-4 dark:border-neutral-800"
>
<div className="mb-3 h-10 w-10 rounded-full bg-gray-200 dark:bg-neutral-800" />
<div className="h-4 w-2/3 rounded bg-gray-200 dark:bg-neutral-800" />
<div className="mt-2 h-3 w-1/2 rounded bg-gray-100 dark:bg-neutral-800/80" />
</div>
))}
</div>
) : teams.length === 0 ? (
<div className="rounded-md border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-neutral-800 dark:text-neutral-400">
Du hast noch kein Team.{' '}
<Link className="text-indigo-600 hover:underline dark:text-indigo-300" href="/team/create">
Jetzt erstellen
</Link>{' '}
oder{' '}
<Link className="text-indigo-600 hover:underline dark:text-indigo-300" href="/teams">
beitreten
</Link>.
</div>
) : (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{teams.map((t) => {
const label = t.teamname ?? t.name ?? 'Unbenannt'
const logoPath = t.logo ? `/assets/img/logos/${t.logo}` : '/assets/img/logos/cs2.webp'
return (
<li
key={t.id ?? label}
className="group rounded-lg border border-gray-200 p-4 transition hover:shadow-sm dark:border-neutral-800"
>
<div className="flex items-center gap-3">
<div className="relative h-10 w-10 overflow-hidden rounded-full ring-1 ring-gray-200 dark:ring-neutral-700">
<Image src={logoPath} alt={label} fill className="object-cover" sizes="40px" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{label}</div>
<div className="text-xs text-gray-500 dark:text-neutral-400">Team</div>
</div>
</div>
<div className="mt-3">
<Link
href="/team"
className="inline-flex items-center text-xs font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-300"
>
Team öffnen
</Link>
</div>
</li>
)
})}
</ul>
)}
</div>
{/* Recent Activity */}
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Aktivität</h2>
<Link
href="/matches"
className="text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-300"
>
Matches ansehen
</Link>
</div>
<div className="text-sm text-gray-500 dark:text-neutral-400">
Hier erscheinen demnächst Match-Updates, Map-Votes & Einladungen in Echtzeit.
</div>
</div>
</div>
{/* Promo / Feature highlight */}
<aside className="space-y-4">
<div className="rounded-xl border border-amber-200/60 bg-amber-50 p-5 dark:border-amber-900/40 dark:bg-amber-900/20">
<div className="mb-2 flex items-center gap-2">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-amber-400/90 text-white shadow-sm dark:bg-amber-500">
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
<path d="M10 2l1.5 4.5L16 8l-4.5 1.5L10 14l-1.5-4.5L4 8l4.5-1.5L10 2zm-6 10l.9 2.7L8 16l-2.7.9L4 20l-.9-2.7L0 16l2.7-.9L4 12zM16 10l.6 1.8L19 12l-1.4 1.1L18 15l-1.6-.8L15 15l.4-1.9L14 12l2.4-.2L16 10z" />
</svg>
</span>
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Pro-Workflow</h3>
</div>
<p className="text-sm leading-6 text-amber-900/90 dark:text-amber-200/90">
Map-Vote, Server-Connect Banner & Demo-Parsing optimiere deinen Ablauf in wenigen Klicks.
</p>
<div className="mt-3 flex gap-2">
<Link href="/schedule" className="text-xs font-semibold text-amber-900 underline underline-offset-4 dark:text-amber-200">
Match planen
</Link>
<Link href="/upload" className="text-xs font-semibold text-amber-900 underline underline-offset-4 dark:text-amber-200">
Demo importieren
</Link>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<h3 className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">Tipps</h3>
<ul className="space-y-2 text-sm text-gray-600 dark:text-neutral-300">
<li> Lade dein Team ein und setze Rollen (Leader / Spieler).</li>
<li> Teile den Share-Code, um Demos automatisch zu analysieren.</li>
<li> Aktiviere Browser-Benachrichtigungen für Map-Vote-Updates.</li>
</ul>
</div>
</aside>
</section>
</div>
)
} }

View File

@ -35,7 +35,6 @@ export default function ProfileHeader({ user: u }: Props) {
const showGameBan = (u.numberOfGameBans ?? 0) > 0 const showGameBan = (u.numberOfGameBans ?? 0) > 0
const showComm = !!u.communityBanned const showComm = !!u.communityBanned
const showEcon = !!u.economyBan && u.economyBan !== 'none' const showEcon = !!u.economyBan && u.economyBan !== 'none'
const showLastBan = typeof u.daysSinceLastBan === 'number'
const hasAnyBan = showVac || showGameBan || showComm || showEcon const hasAnyBan = showVac || showGameBan || showComm || showEcon
const hasFaceit = !!u.faceitUrl const hasFaceit = !!u.faceitUrl
@ -60,7 +59,7 @@ export default function ProfileHeader({ user: u }: Props) {
aria-label="Faceit-Profil öffnen" title={`Faceit-Profil von ${u.faceitNickname ?? u.name ?? ''}`} aria-label="Faceit-Profil öffnen" title={`Faceit-Profil von ${u.faceitNickname ?? u.name ?? ''}`}
className="inline-flex size-9 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10" className="inline-flex size-9 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
> >
<img src="/assets/img/logos/faceit.svg" alt="" className="h-5 w-5" aria-hidden /> <Image src="/assets/img/logos/faceit.svg" alt="" width={16} height={16} aria-hidden />
</Link> </Link>
)} )}
</div> </div>

View File

@ -1,7 +1,6 @@
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
export default async function ProfileRedirectPage() { export default async function ProfileRedirectPage() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)

View File

@ -1,11 +1,6 @@
'use client' 'use client'
import Link from 'next/link'
import Image from 'next/image'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Switch from '../components/Switch'
import Button from '../components/Button'
import CommunityMatchList from '../components/CommunityMatchList' import CommunityMatchList from '../components/CommunityMatchList'
import Card from '../components/Card' import Card from '../components/Card'
@ -18,9 +13,7 @@ type Match = {
} }
export default function MatchesPage() { export default function MatchesPage() {
const { data: session } = useSession() const [, setMatches] = useState<Match[]>([])
const [matches, setMatches] = useState<Match[]>([])
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
useEffect(() => { useEffect(() => {
fetch('/api/schedule') fetch('/api/schedule')
@ -32,12 +25,6 @@ export default function MatchesPage() {
}) })
}, []) }, [])
const filteredMatches = onlyOwnTeam && session?.user?.team
? matches.filter(
(m) => m.teamA.id === session.user.team || m.teamB.id === session.user.team
)
: matches
return ( return (
<Card maxWidth='auto'> <Card maxWidth='auto'>
<CommunityMatchList matchType="community" /> <CommunityMatchList matchType="community" />

View File

@ -1,7 +1,5 @@
// app/[locale]/settings/_sections/AccountSection.tsx // /src/app/[locale]/settings/_sections/AccountSection.tsx
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import AuthCodeSettings from '../../components/settings/account/AuthCodeSettings' import AuthCodeSettings from '../../components/settings/account/AuthCodeSettings'
import LatestKnownCodeSettings from '../../components/settings/account/ShareCodeSettings' import LatestKnownCodeSettings from '../../components/settings/account/ShareCodeSettings'
@ -9,17 +7,6 @@ import LatestKnownCodeSettings from '../../components/settings/account/ShareCode
export default async function AccountSection() { export default async function AccountSection() {
const tSettings = await getTranslations('settings') const tSettings = await getTranslations('settings')
// Session laden (passe das an deine authOptions an)
const session = await getServerSession(/* authOptions */)
const steamId = (session as any)?.user?.id ?? null
const user = steamId
? await prisma.user.findUnique({
where: { steamId },
select: { faceitId: true, faceitNickname: true, faceitAvatar: true },
})
: null
return ( return (
<section id="account" className="scroll-mt-16 pb-10"> <section id="account" className="scroll-mt-16 pb-10">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white"> <h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">

View File

@ -6,7 +6,7 @@ import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { decrypt, encrypt } from '@/lib/crypto' import { decrypt, encrypt } from '@/lib/crypto'
export async function GET(req: NextRequest) { export async function GET() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId const steamId = session?.user?.steamId

View File

@ -7,7 +7,7 @@ import { prisma } from '@/lib/prisma'
const EXPIRY_DAYS = 30 const EXPIRY_DAYS = 30
export async function GET(req: NextRequest) { export async function GET() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId const steamId = session?.user?.steamId

View File

@ -50,18 +50,12 @@ export async function GET(req: NextRequest) {
const steamId = /* session?.user?.id o.ä. */ null const steamId = /* session?.user?.id o.ä. */ null
if (!steamId) return NextResponse.redirect('/settings?faceit=no_user') if (!steamId) return NextResponse.redirect('/settings?faceit=no_user')
const expires = token.expires_in ? new Date(Date.now() + token.expires_in * 1000) : null
await prisma.user.update({ await prisma.user.update({
where: { steamId }, where: { steamId },
data: { data: {
faceitId: me.guid ?? null, faceitId: me.guid ?? null,
faceitNickname: me.nickname ?? null, faceitNickname: me.nickname ?? null,
faceitAvatar: me.avatar ?? null, faceitAvatar: me.avatar ?? null,
// Tokens nur speichern, wenn nötig:
// faceitAccessToken: token.access_token,
// faceitRefreshToken: token.refresh_token ?? null,
// faceitTokenExpiresAt: expires,
}, },
}) })

View File

@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@ -42,7 +42,7 @@ async function findCurrentMatch() {
return upcoming return upcoming
} }
export async function GET(_req: NextRequest) { export async function GET() {
const match = await findCurrentMatch() const match = await findCurrentMatch()
if (!match) { if (!match) {
return NextResponse.json({ matchId: null, steamIds: [], total: 0 }, { headers: { 'Cache-Control': 'no-store' } }) return NextResponse.json({ matchId: null, steamIds: [], total: 0 }, { headers: { 'Cache-Control': 'no-store' } })

View File

@ -19,7 +19,7 @@ export async function POST(req: NextRequest) {
} }
// ✅ Notifications für aktuellen User laden // ✅ Notifications für aktuellen User laden
export async function GET(_req: NextRequest) { export async function GET() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const meSteamId = (session?.user as { steamId?: string })?.steamId const meSteamId = (session?.user as { steamId?: string })?.steamId
@ -36,7 +36,7 @@ export async function GET(_req: NextRequest) {
} }
// ✅ Alle Notifications auf "gelesen" setzen // ✅ Alle Notifications auf "gelesen" setzen
export async function PUT(_req: NextRequest) { export async function PUT() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const meSteamId = (session?.user as { steamId?: string })?.steamId const meSteamId = (session?.user as { steamId?: string })?.steamId

View File

@ -1,13 +1,13 @@
// app/api/notifications/mark-all-read/route.ts // /src/app/api/notifications/mark-all-read/route.ts
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse } from 'next/server'
import { sendServerSSEMessage } from '@/lib/sse-server-client' import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST() {
try { try {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId const steamId = session?.user?.steamId

View File

@ -2,9 +2,9 @@
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { NextRequest, NextResponse } from 'next/server' import { NextResponse } from 'next/server'
export async function GET(req: NextRequest) { export async function GET() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) { if (!session?.user?.steamId) {

View File

@ -46,7 +46,6 @@ export async function GET() {
const formatted = matches.map(m => { const formatted = matches.map(m => {
const matchDate = const matchDate =
m.demoDate ?? m.demoDate ??
// @ts-ignore falls du optional noch ein „date“-Feld hast
(m as any).date ?? (m as any).date ??
m.createdAt m.createdAt

View File

@ -1,13 +1,13 @@
// /src/app/api/stats/[steamId]/route.ts // /src/app/api/stats/[steamId]/route.ts
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import type { AsyncParams } from '@/types/next' // ← nutzt deinen Typ import type { AsyncParams } from '@/types/next'
export async function GET( export async function GET(
_req: Request, _req: Request,
ctx: AsyncParams<{ steamId: string }> ctx: AsyncParams<{ steamId: string }>
) { ) {
const { steamId } = await ctx.params // ← params auflösen const { steamId } = await ctx.params
if (!steamId) { if (!steamId) {
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 }) return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
@ -59,13 +59,15 @@ export async function GET(
orderBy: { match: { demoDate: 'asc' } }, orderBy: { match: { demoDate: 'asc' } },
}) })
// kleiner Helper ohne `any`
const readRounds = (v: unknown): number | null => {
const r = (v as { rounds?: unknown } | null | undefined)?.rounds
return typeof r === 'number' && Number.isFinite(r) ? r : null
}
const FallbackDate = new Date(0).toISOString().split('T')[0] // "1970-01-01" const FallbackDate = new Date(0).toISOString().split('T')[0] // "1970-01-01"
const stats = matches.map((entry) => { const stats = matches.map((entry) => {
const rounds = const rounds = readRounds(entry.stats /* | { roundCount?: unknown } */)
(entry.stats as any)?.rounds ??
// (entry.match as any)?.roundCount ??
null
return { return {
date: entry.match?.demoDate?.toISOString().split('T')[0] ?? FallbackDate, date: entry.match?.demoDate?.toISOString().split('T')[0] ?? FallbackDate,
kills: entry.stats?.kills ?? 0, kills: entry.stats?.kills ?? 0,

View File

@ -1,8 +1,8 @@
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse } from 'next/server'
export async function GET(req: NextRequest) { export async function GET() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
if (!session || !session.user?.steamId) { if (!session || !session.user?.steamId) {

View File

@ -18,8 +18,30 @@ export async function GET(
const team = await prisma.team.findUnique({ const team = await prisma.team.findUnique({
where: { id: teamId }, where: { id: teamId },
include: { include: {
leader: true, leader: {
invites: { include: { user: true } }, select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
isAdmin: true,
},
},
invites: {
include: {
user: {
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
isAdmin: true,
},
},
},
},
}, },
}) })
@ -44,7 +66,18 @@ export async function GET(
}) })
: [] : []
const toPlayer = (u: any): Player => ({ // 1) Gemeinsamer Typ für alle "User"-Objekte, die wir in Player mappen
type UserLike = {
steamId: string
name: string | null
avatar: string | null
location: string | null
premierRank: number | null
isAdmin: boolean | null
}
// 2) Ein Helper: Prisma -> Player
const toPlayer = (u: UserLike): Player => ({
steamId: u.steamId, steamId: u.steamId,
name: u.name ?? 'Unbekannt', name: u.name ?? 'Unbekannt',
avatar: u.avatar ?? '/assets/img/avatars/default.png', avatar: u.avatar ?? '/assets/img/avatars/default.png',

View File

@ -30,12 +30,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 }) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
} }
/* ▸ Alle Teammitglieder (alt + neu) als Zielgruppe ------------------------- */
const allPlayers = [
...team.activePlayers,
...team.inactivePlayers,
]
/* ▸ SSE-Push --------------------------------------------------------------- */ /* ▸ SSE-Push --------------------------------------------------------------- */
await sendServerSSEMessage({ await sendServerSSEMessage({
type : 'team-member-joined', type : 'team-member-joined',

View File

@ -5,6 +5,12 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// Minimaler Type-Guard für Prisma KnownRequestError
type KnownPrismaError = { code: string; meta?: Record<string, unknown> }
function isKnownPrismaError(e: unknown): e is KnownPrismaError {
return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string'
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json() const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
@ -33,13 +39,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 }) return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 })
} }
// user dem Team zuordnen
await prisma.user.update({ await prisma.user.update({
where: { steamId: leader }, where: { steamId: leader },
data: { teamId: newTeam.id }, data: { teamId: newTeam.id },
}) })
// 🔔 (optional) persistente Notification
const note = await prisma.notification.create({ const note = await prisma.notification.create({
data: { data: {
steamId: leader, steamId: leader,
@ -50,7 +54,6 @@ export async function POST(req: NextRequest) {
}, },
}) })
// ➜ Sofort an Notification-Center
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'notification', type: 'notification',
targetUserIds: [leader], targetUserIds: [leader],
@ -61,14 +64,12 @@ export async function POST(req: NextRequest) {
createdAt: note.createdAt.toISOString(), createdAt: note.createdAt.toISOString(),
}) })
// ✅ ➜ HIER: Self-Refresh für den Ersteller
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'self-updated', // <— stelle sicher, dass dein Client darauf hört type: 'self-updated',
targetUserIds: [leader], targetUserIds: [leader],
}) })
} }
// (Optional) Broadcasts
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-created', type: 'team-created',
title: 'Team erstellt', title: 'Team erstellt',
@ -85,8 +86,8 @@ export async function POST(req: NextRequest) {
{ message: 'Team erstellt', team: newTeam }, { message: 'Team erstellt', team: newTeam },
{ headers: { 'Cache-Control': 'no-store' } }, { headers: { 'Cache-Control': 'no-store' } },
) )
} catch (error: any) { } catch (error: unknown) {
if (error?.code === 'P2002') { if (isKnownPrismaError(error) && error.code === 'P2002') {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 }) return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
} }
console.error('❌ Fehler beim Team erstellen:', error) console.error('❌ Fehler beim Team erstellen:', error)

View File

@ -5,9 +5,26 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// kleiner Body-Parser für sichere Typen
function parseBody(v: unknown): { teamId?: string; newName?: string } {
if (!v || typeof v !== 'object') return {}
const r = v as Record<string, unknown>
return {
teamId : typeof r.teamId === 'string' ? r.teamId : undefined,
newName: typeof r.newName === 'string' ? r.newName : undefined,
}
}
// Prisma-Fehler-Shape (ohne Import von Prisma-Typen)
type KnownPrismaError = { code: string; meta?: Record<string, unknown> }
function isKnownPrismaError(e: unknown): e is KnownPrismaError {
return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string'
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamId, newName } = await req.json() const raw = await req.json().catch(() => null)
const { teamId, newName } = parseBody(raw)
const name = (newName ?? '').trim() const name = (newName ?? '').trim()
if (!teamId || !name) { if (!teamId || !name) {
@ -24,19 +41,19 @@ export async function POST(req: NextRequest) {
} }
// Umbenennen (Unique-Constraint beachten) // Umbenennen (Unique-Constraint beachten)
let updated const updated = await prisma.team.update({
try {
updated = await prisma.team.update({
where: { id: teamId }, where: { id: teamId },
data: { name }, data: { name },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true }, select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
}) }).catch((e: unknown) => {
} catch (e: any) { if (isKnownPrismaError(e) && e.code === 'P2002') {
if (e?.code === 'P2002') {
return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 }) return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 })
} }
throw e throw e
} })
// Wenn wir oben bereits mit NextResponse zurückgekehrt sind, ist updated ein Response
if (updated instanceof NextResponse) return updated
// Zielnutzer (Leader + aktive + inaktive) für persistente Notifications // Zielnutzer (Leader + aktive + inaktive) für persistente Notifications
const targets = Array.from(new Set( const targets = Array.from(new Set(
@ -49,7 +66,7 @@ export async function POST(req: NextRequest) {
const text = `Team wurde umbenannt in "${updated.name}".` const text = `Team wurde umbenannt in "${updated.name}".`
// Persistente Notifications an Team-Mitglieder + Live-Zustellung (nur an diese Nutzer) // Persistente Notifications + Live-Zustellung
if (targets.length) { if (targets.length) {
const created = await Promise.all( const created = await Promise.all(
targets.map(steamId => targets.map(steamId =>
@ -80,25 +97,24 @@ export async function POST(req: NextRequest) {
) )
} }
// ✅ Globale Team-Events (Broadcast, KEIN targetUserIds) für alle Clients // Broadcast-Events
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-renamed', type: 'team-renamed',
teamId, teamId,
message: text, message: text,
newName: updated.name, newName: updated.name,
}) })
await sendServerSSEMessage({ type: 'team-updated', teamId })
// Optionaler Failsafe-Reload als Broadcast
await sendServerSSEMessage({
type: 'team-updated',
teamId,
})
return NextResponse.json( return NextResponse.json(
{ success: true, team: { id: updated.id, name: updated.name } }, { success: true, team: { id: updated.id, name: updated.name } },
{ headers: { 'Cache-Control': 'no-store' } }, { headers: { 'Cache-Control': 'no-store' } },
) )
} catch (err) { } catch (err: unknown) {
// Fallback: P2002 nochmals abfangen, falls es außerhalb des catch-Blocks auftritt
if (isKnownPrismaError(err) && err.code === 'P2002') {
return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 })
}
console.error('Fehler beim Umbenennen:', err) console.error('Fehler beim Umbenennen:', err)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
} }

View File

@ -5,9 +5,31 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
type KnownPrismaErrorShape = { code: string; meta?: Record<string, unknown> };
// kleines Body-Parser-Helper
function parseBody(v: unknown): { teamId?: string; newLeaderSteamId?: string } {
if (!v || typeof v !== 'object') return {}
const r = v as Record<string, unknown>
return {
teamId: typeof r.teamId === 'string' ? r.teamId : undefined,
newLeaderSteamId: typeof r.newLeaderSteamId === 'string' ? r.newLeaderSteamId : undefined,
}
}
// Type Guard für PrismaClientKnownRequestError
function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
return !!e
&& typeof e === 'object'
&& 'code' in e
&& typeof (e as { code: unknown }).code === 'string';
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamId, newLeaderSteamId } = await req.json() const raw = await req.json().catch(() => null)
const { teamId, newLeaderSteamId } = parseBody(raw)
if (!teamId || !newLeaderSteamId) { if (!teamId || !newLeaderSteamId) {
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 }) return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
} }
@ -18,7 +40,7 @@ export async function POST(req: NextRequest) {
}) })
if (!team) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 }) if (!team) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
// Bereits Leader eines anderen Teams? // Bereits Leader eines anderen Teams?
const otherLedTeam = await prisma.team.findFirst({ const otherLedTeam = await prisma.team.findFirst({
where: { leaderId: newLeaderSteamId, NOT: { id: teamId } }, where: { leaderId: newLeaderSteamId, NOT: { id: teamId } },
select: { id: true, name: true } select: { id: true, name: true }
@ -45,7 +67,7 @@ export async function POST(req: NextRequest) {
data : { leaderId: newLeaderSteamId }, data : { leaderId: newLeaderSteamId },
}) })
// --- Benachrichtigung & SSE unverändert --- // Benachrichtigungen & SSE
const newLeader = await prisma.user.findUnique({ const newLeader = await prisma.user.findUnique({
where : { steamId: newLeaderSteamId }, where : { steamId: newLeaderSteamId },
select: { name: true }, select: { name: true },
@ -109,15 +131,20 @@ export async function POST(req: NextRequest) {
} }
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' }) return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
} catch (error: any) { } catch (err: unknown) {
// Falls du zusätzlich im Prisma-Schema @@unique([leaderId]) gesetzt hast: // unique-Constraint auf leaderId behandeln (falls im Schema vorhanden)
if (error?.code === 'P2002' && error?.meta?.target?.includes('leaderId')) { if (isKnownPrismaError(err) && err.code === 'P2002') {
const target = err.meta?.target; // type: unknown
if (Array.isArray(target) && target.includes('leaderId')) {
return NextResponse.json( return NextResponse.json(
{ message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' }, { message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' },
{ status: 400 } { status: 400 }
) );
} }
console.error('Fehler beim Leaderwechsel:', error) }
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
console.error('Fehler beim Leaderwechsel:', err);
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 });
} }
} }

View File

@ -2,19 +2,29 @@
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' // ⬅️ hier umstellen import { sessionAuthOptions } from '@/lib/auth'
import { sendServerSSEMessage } from '@/lib/sse-server-client' import { sendServerSSEMessage } from '@/lib/sse-server-client'
import type { TeamJoinPolicy } from '@/types/team' import type { TeamJoinPolicy } from '@/types/team'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const ALLOWED = ['REQUEST', 'INVITE_ONLY'] as const // Einmal zentral definieren und später benutzen
type AllowedPolicy = (typeof ALLOWED)[number] const ALLOWED: readonly TeamJoinPolicy[] = ['REQUEST', 'INVITE_ONLY'] as const
type Body = { teamId?: string; joinPolicy?: TeamJoinPolicy }
function parseBody(v: unknown): Body {
if (!v || typeof v !== 'object') return {}
const r = v as Record<string, unknown>
return {
teamId: typeof r.teamId === 'string' ? r.teamId : undefined,
joinPolicy: typeof r.joinPolicy === 'string' ? (r.joinPolicy as TeamJoinPolicy) : undefined,
}
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
// ⬇️ statt getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const meId = session?.user?.steamId const meId = session?.user?.steamId
@ -22,14 +32,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
} }
const body = await req.json().catch(() => ({} as any)) const raw: unknown = await req.json().catch(() => null)
const teamId: string | undefined = body?.teamId const { teamId, joinPolicy } = parseBody(raw)
const joinPolicy: TeamJoinPolicy | undefined = body?.joinPolicy
if (!teamId) { if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 }) return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
} }
if (!joinPolicy || !ALLOWED.includes(joinPolicy as AllowedPolicy)) { // Nutzung des zentralen ALLOWED, kein Alias nötig
if (!joinPolicy || !ALLOWED.includes(joinPolicy)) {
return NextResponse.json({ message: 'Ungültige joinPolicy' }, { status: 400 }) return NextResponse.json({ message: 'Ungültige joinPolicy' }, { status: 400 })
} }
@ -64,7 +74,6 @@ export async function POST(req: NextRequest) {
select: { id: true, joinPolicy: true }, select: { id: true, joinPolicy: true },
}) })
// Fire-and-forget SSE
Promise.resolve().then(() => Promise.resolve().then(() =>
sendServerSSEMessage({ sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',

View File

@ -1,11 +1,11 @@
// /src/app/api/user/activity/route.ts // /src/app/api/user/activity/route.ts
import { NextResponse, NextRequest } from 'next/server' import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client' import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) { export async function POST() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId // <-- hier definieren const steamId = session?.user?.steamId // <-- hier definieren

View File

@ -1,11 +1,11 @@
// /src/app/api/user/away/route.ts // /src/app/api/user/away/route.ts
import { NextResponse, NextRequest } from 'next/server' import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client' import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) { export async function POST() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId const steamId = session?.user?.steamId

View File

@ -13,12 +13,15 @@ export async function POST(
const { action } = await ctx.params const { action } = await ctx.params
try { try {
const body = await req.json().catch(() => ({} as any)) const body = (await req.json().catch(() => ({}))) as Partial<{
invitationId: string
teamId: string
steamId: string
}>
// NEU: neben invitationId auch teamId+steamId als Fallback akzeptieren const incomingInvitationId = body.invitationId?.trim() || undefined
const incomingInvitationId: string | undefined = body.invitationId const fallbackTeamId = body.teamId?.trim() || undefined
const fallbackTeamId: string | undefined = body.teamId const fallbackSteamId = body.steamId?.trim() || undefined
const fallbackSteamId: string | undefined = body.steamId
// Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId) // Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId)
const invitation = const invitation =
@ -281,8 +284,9 @@ export async function POST(
} }
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 }) return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
} catch (error) { } catch (error: unknown) {
console.error('Fehler bei Einladung:', error) const err = error instanceof Error ? error : new Error(String(error))
console.error('Fehler bei Einladung:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
} }
} }

View File

@ -1,10 +1,10 @@
// /src/app/api/user/invitations/route.ts // /src/app/api/user/invitations/route.ts
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) { export async function GET() {
try { try {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId const steamId = session?.user?.steamId

View File

@ -1,11 +1,11 @@
// /src/app/api/user/offline/route.ts // /src/app/api/user/offline/route.ts
import { NextResponse, NextRequest } from 'next/server' import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client' import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) { export async function POST() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId const steamId = session?.user?.steamId

View File

@ -4,7 +4,7 @@ import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth' import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) { export async function GET() {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) { if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -1,5 +1,5 @@
// /src/app/api/user/route.ts // /src/app/api/user/route.ts
import { NextResponse, type NextRequest } from 'next/server'; import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { sessionAuthOptions } from '@/lib/auth'; import { sessionAuthOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
@ -17,7 +17,7 @@ type SlimPlayer = {
// Hilfstyp für Session, damit TS weiß, dass es user?.steamId gibt // Hilfstyp für Session, damit TS weiß, dass es user?.steamId gibt
type SessionShape = { user?: { steamId?: string } } | null; type SessionShape = { user?: { steamId?: string } } | null;
export async function GET(_req: NextRequest) { export async function GET() {
const session = (await getServerSession(sessionAuthOptions)) as SessionShape; const session = (await getServerSession(sessionAuthOptions)) as SessionShape;
const steamId = session?.user?.steamId; const steamId = session?.user?.steamId;

View File

@ -10,15 +10,13 @@ function isValidIanaOrNull(v: unknown): v is string | null {
if (v === null) return true if (v === null) return true
if (typeof v !== 'string' || v.trim() === '') return false if (typeof v !== 'string' || v.trim() === '') return false
// Validate via Intl.supportedValuesOf if available // Validate via Intl.supportedValuesOf if available
// @ts-ignore
const list: string[] | undefined = typeof Intl.supportedValuesOf === 'function' const list: string[] | undefined = typeof Intl.supportedValuesOf === 'function'
// @ts-ignore
? Intl.supportedValuesOf('timeZone') ? Intl.supportedValuesOf('timeZone')
: undefined : undefined
return list ? list.includes(v) : true // wenn kein Support: großzügig erlauben return list ? list.includes(v) : true // wenn kein Support: großzügig erlauben
} }
export async function GET(req: NextRequest) { export async function GET() {
try { try {
const session = await getServerSession(sessionAuthOptions) const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) { if (!session?.user?.steamId) {
@ -29,8 +27,9 @@ export async function GET(req: NextRequest) {
select: { timeZone: true } select: { timeZone: true }
}) })
return NextResponse.json({ timeZone: user?.timeZone ?? null }) return NextResponse.json({ timeZone: user?.timeZone ?? null })
} catch (e: any) { } catch (e: unknown) {
console.error('[TZ][GET] failed', e) const err = e instanceof Error ? e : new Error(String(e))
console.error('[TZ][GET] failed:', err.message, err)
return NextResponse.json({ message: 'Internal error' }, { status: 500 }) return NextResponse.json({ message: 'Internal error' }, { status: 500 })
} }
} }
@ -55,8 +54,9 @@ export async function PUT(req: NextRequest) {
}) })
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
} catch (e: any) { } catch (e: unknown) {
console.error('[TZ][PUT] failed', e) const err = e instanceof Error ? e : new Error(String(e))
console.error('[TZ][PUT] failed:', err.message, err)
return NextResponse.json({ message: 'Internal error' }, { status: 500 }) return NextResponse.json({ message: 'Internal error' }, { status: 500 })
} }
} }

View File

@ -1,5 +1,6 @@
// /src/app/api/user/winrate/route.ts // /src/app/api/user/winrate/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { MAP_OPTIONS } from '@/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
@ -202,5 +203,6 @@ export async function POST(req: NextRequest) {
if (typeof body.onlyActive === 'boolean') { if (typeof body.onlyActive === 'boolean') {
params.searchParams.set('onlyActive', String(body.onlyActive)) params.searchParams.set('onlyActive', String(body.onlyActive))
} }
return GET(new Request(params.toString()) as any) const nextReq = new NextRequest(params.toString(), { method: 'GET' })
return GET(nextReq)
} }

View File

@ -417,7 +417,7 @@ const config = {
"value": "prisma-client-js" "value": "prisma-client-js"
}, },
"output": { "output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null "fromEnvVar": null
}, },
"config": { "config": {
@ -431,7 +431,7 @@ const config = {
} }
], ],
"previewFeatures": [], "previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {

View File

@ -418,7 +418,7 @@ const config = {
"value": "prisma-client-js" "value": "prisma-client-js"
}, },
"output": { "output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null "fromEnvVar": null
}, },
"config": { "config": {
@ -432,7 +432,7 @@ const config = {
} }
], ],
"previewFeatures": [], "previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {

View File

@ -417,7 +417,7 @@ const config = {
"value": "prisma-client-js" "value": "prisma-client-js"
}, },
"output": { "output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null "fromEnvVar": null
}, },
"config": { "config": {
@ -431,7 +431,7 @@ const config = {
} }
], ],
"previewFeatures": [], "previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {

View File

@ -142,7 +142,7 @@ export const buildAuthOptions = (req: NextRequest): NextAuthOptions => ({
const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`) const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`)
const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`) const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`)
if (isSignOut) return `${baseUrl}/` if (isSignOut) return `${baseUrl}/`
if (isSignIn || url === baseUrl) return `${baseUrl}/dashboard` if (isSignIn || url === baseUrl) return `${baseUrl}/`
return url.startsWith(baseUrl) ? url : baseUrl return url.startsWith(baseUrl) ? url : baseUrl
}, },
}, },

View File

@ -125,8 +125,13 @@
"day": "Tag" "day": "Tag"
}, },
"mapvote": { "mapvote": {
"open": "offen", "mode": "Modus",
"opens-in": "öffnet in" "open": "Offen",
"open-small": "offen",
"opens-in": "Öffnet in",
"completed": "Voting abgeschlossen!",
"vote-now": "vote",
"to-match-start": "zum Matchbeginn"
}, },
"notifications": { "notifications": {
"title": "Benachrichtigungen", "title": "Benachrichtigungen",

View File

@ -125,8 +125,13 @@
"day": "Day" "day": "Day"
}, },
"mapvote": { "mapvote": {
"open": "open", "mode": "Mode",
"opens-in": "opens in" "open": "Open",
"open-small": "open",
"opens-in": "Opens in",
"completed": "Voting completed!",
"vote-now": "vote",
"to-match-start": "to match start"
}, },
"notifications": { "notifications": {
"title": "Notifications", "title": "Notifications",

View File

@ -28,7 +28,7 @@ function stripLeadingLocale(pathname: string, locales: readonly string[]) {
} }
function isProtectedPath(pathnameNoLocale: string) { function isProtectedPath(pathnameNoLocale: string) {
return ( return (
pathnameNoLocale.startsWith('/dashboard') || pathnameNoLocale.startsWith('/') ||
pathnameNoLocale.startsWith('/settings') || pathnameNoLocale.startsWith('/settings') ||
pathnameNoLocale.startsWith('/matches') || pathnameNoLocale.startsWith('/matches') ||
pathnameNoLocale.startsWith('/team') || pathnameNoLocale.startsWith('/team') ||
@ -73,7 +73,7 @@ export default async function middleware(req: NextRequest) {
if (!isAdmin) { if (!isAdmin) {
const currentLocale = getCurrentLocaleFromPath(pathname, locales, defaultLocale); const currentLocale = getCurrentLocaleFromPath(pathname, locales, defaultLocale);
const redirectUrl = url.clone(); const redirectUrl = url.clone();
redirectUrl.pathname = `/${currentLocale}/dashboard`; redirectUrl.pathname = `/${currentLocale}/`;
return NextResponse.redirect(redirectUrl); return NextResponse.redirect(redirectUrl);
} }
} }