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'
// Wichtig: Promises wie in .next/types
type PageProps = {
params?: Promise<{ locale?: string }>
searchParams?: Promise<Record<string, string | string[] | undefined>>
}
async function ensureConfig() {
return prisma.serverConfig.upsert({
where: { id: 'default' },
@ -29,16 +23,20 @@ async function ensureConfig() {
})
}
export default async function AdminServerPage(_props: PageProps) {
// Falls du locale brauchst:
// const { locale } = (await _props.params) ?? {}
export default async function AdminServerPage() {
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('/')
}
const me = meUnknown // ab hier getypt: AdminUser
const [cfg, meUser] = await Promise.all([
ensureConfig(),
@ -73,7 +71,7 @@ export default async function AdminServerPage(_props: PageProps) {
if (clientApiKey) {
await tx.user.update({
where: { steamId: me?.steamId },
where: { steamId: me.steamId },
data: { pterodactylClientApiKey: clientApiKey },
})
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
// components/Chart.tsx
// /src/app/[locale]/components/Chart.tsx
'use client';
import React, {
@ -16,6 +16,8 @@ import {
type ChartData,
type ChartOptions,
type Plugin,
type ChartConfiguration,
type RadialLinearScaleOptions,
CategoryScale,
LinearScale,
RadialLinearScale,
@ -120,7 +122,7 @@ function getImage(src: string): HTMLImageElement {
return img;
}
function _Chart<TType extends ChartJSType = ChartJSType>(
function ChartInner<TType extends ChartJSType = ChartJSType>(
props: BaseProps<TType>,
ref: React.Ref<ChartHandle>
) {
@ -141,16 +143,15 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
onReady,
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,
radarIconLabels = false,
radarIconLabelFont = '12px Inter, system-ui, sans-serif',
radarIconLabelColor = '#ffffff',
radarIconLabelMargin = 6,
radarHideTicks = false,
radarMax,
radarStepSize,
radarAddRingOffset = false,
} = props;
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@ -161,7 +162,10 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const baseData = useMemo<ChartData<TType> | undefined>(() => {
if (data) return data;
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]);
// ▼ 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 (type !== 'radar') return baseData;
// flache Kopie, Datensätze klonen und data +20
const cloned: any = {
...baseData,
datasets: (baseData.datasets ?? []).map((ds: any) => {
// Nur numerische Arrays anfassen
const d = Array.isArray(ds.data)
? ds.data.map((v: any) =>
typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v
)
: ds.data;
// datasets generisch transformieren (ohne `any`)
const dsArray =
(baseData.datasets ?? []) as unknown as Array<Record<string, unknown>>;
const dsShifted = dsArray.map((ds) => {
const d = (ds.data as unknown[] | undefined)?.map((v) =>
typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v
);
return { ...ds, data: d } as Record<string, unknown>;
});
return { ...ds, data: d };
}),
};
return cloned;
return {
labels: baseData.labels as ChartData<TType>['labels'],
datasets: dsShifted as unknown as NonNullable<ChartData<TType>['datasets']>,
} as ChartData<TType>;
}, [baseData, type]);
/* ---------- Radar Scale ---------- */
// ---------- Radar Scale ----------
const radarScaleOpts = useMemo(() => {
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 ticks: any = {
beginAtZero: true,
showLabelBackdrop: false,
const ticks: NonNullable<RadialLinearScaleOptions['ticks']> = {
display: true,
color: 'rgba(255,255,255,0.6)',
font: { size: 12 },
padding: 0,
backdropColor: 'transparent',
...(radarHideTicks ? { display: false } : {}),
backdropPadding: 0,
showLabelBackdrop: false,
textStrokeColor: 'transparent',
textStrokeWidth: 0,
z: 0,
major: { enabled: false },
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
callback: (value) => String(value),
};
const r: any = {
suggestedMin: 0,
grid: { color: gridColor, lineWidth: 1 },
angleLines: { display: true, color: angleColor, lineWidth: 1 },
const pointLabels: NonNullable<RadialLinearScaleOptions['pointLabels']> = {
display: false,
color: '#ffffff',
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,
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') {
r.max = radarMax + RADAR_OFFSET;
r.suggestedMax = radarMax + RADAR_OFFSET;
}
return { r };
}, [type, radarHideTicks, radarStepSize, radarMax]);
}, [type, radarStepSize, radarMax]);
/* ---------- Radar Icons Plugin ---------- */
const [radarPlugin] = useState<Plugin<'radar'>>(() => ({
id: 'radarIconsPlugin',
afterDatasetsDraw(chart) {
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;
const lbls = chart.data.labels as string[] | undefined;
@ -233,14 +266,14 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const icons = props.radarIcons ?? [];
ctx.save();
(ctx as any).resetTransform?.();
(ctx as unknown as { resetTransform?: () => void }).resetTransform?.();
ctx.beginPath();
ctx.rect(0, 0, chart.width, chart.height);
ctx.clip();
const ca = (chart as any).chartArea as { left:number; right:number; top:number; bottom:number } | undefined;
const cx0 = scale.xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width as number) / 2);
const cy0 = scale.yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2);
const ca = (chart as unknown as { chartArea?: { left: number; right: number; top: number; bottom: number } }).chartArea;
const cx0 = (scale as unknown as { xCenter?: number }).xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width 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 gap = Math.max(4, props.radarIconLabelMargin ?? 6);
@ -251,9 +284,9 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
ctx.fillStyle = props.radarIconLabelColor ?? '#ffffff';
for (let i = 0; i < lbls.length; i++) {
const p = scale.getPointPositionForValue(i, scale.max);
const px = p.x as number;
const py = p.y as number;
const p = scale.getPointPositionForValue(i, (scale as unknown as { max: number }).max);
const px = p.x;
const py = p.y;
const dx = px - cx0;
const dy = py - cy0;
@ -298,16 +331,18 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
if (type === 'radar') {
// Scales zusammenführen
(o as any).scales = {
(o as unknown as { scales?: Record<string, unknown> }).scales = {
...(radarScaleOpts ?? {}),
...(options?.scales as any),
...(options?.scales as unknown as Record<string, unknown>),
};
// Tooltip: echten Wert (ohne Offset) anzeigen
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 },
title: { display: false },
...options?.plugins,
@ -315,14 +350,15 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
...userTooltip,
callbacks: {
...userTooltip?.callbacks,
label: function (this: any, ctx: any) {
const shifted = Number(ctx.raw);
label: function (this: unknown, rawCtx: unknown) {
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 shown = Math.round(original); // ⬅️ hier auf ganze Zahlen runden
const shown = Math.round(original);
if (typeof userLabelCb === 'function') {
const clone = { ...ctx, raw: shown, parsed: { r: shown } };
return (userLabelCb as (this: any, c: any) => any).call(this, clone);
const clone = { ...(ctx as Record<string, unknown>), raw: shown, parsed: { r: shown } };
return userLabelCb.call(this, clone);
}
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)
const fontPx = (() => {
@ -342,18 +378,18 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
(radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) +
6
);
const currentPadding = (o as any).layout?.padding ?? {};
(o as any).layout = {
...(o as any).layout,
const currentPadding = (o as unknown as { layout?: { padding?: Record<string, number> } }).layout?.padding ?? {};
(o as unknown as { layout?: { padding?: Record<string, number> } }).layout = {
...(o as unknown as { layout?: Record<string, unknown> }).layout,
padding: {
top: Math.max(pad, currentPadding.top ?? 0),
right: Math.max(pad, currentPadding.right ?? 0),
bottom: Math.max(pad, currentPadding.bottom ?? 0),
left: Math.max(pad, currentPadding.left ?? 0),
top: Math.max(pad, (currentPadding as Record<string, number>).top ?? 0),
right: Math.max(pad, (currentPadding as Record<string, number>).right ?? 0),
bottom: Math.max(pad, (currentPadding as Record<string, number>).bottom ?? 0),
left: Math.max(pad, (currentPadding as Record<string, number>).left ?? 0),
},
};
} 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;
@ -371,7 +407,7 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const mergedPlugins = useMemo<Plugin<TType>[]>(() => {
const list: Plugin<TType>[] = [];
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;
}, [plugins, type, radarPlugin]);
@ -379,7 +415,10 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
const config = useMemo(
() => ({
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,
plugins: mergedPlugins,
}),
@ -395,7 +434,8 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
if (mustRecreate) {
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;
onReady?.(chartRef.current);
return () => {
@ -403,14 +443,16 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
chartRef.current = null;
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, type, redraw, onReady]);
useEffect(() => {
const c = chartRef.current;
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();
}, [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> }
) => ReturnType<typeof _Chart>;
) => ReturnType<typeof ChartInner>;
export default Chart;

View File

@ -1,15 +1,21 @@
// /src/app/[locale]/components/ComboBox.tsx
'use client'
import { useState } from 'react'
type ComboItem = { id: string; label: string }
type ComboBoxProps = {
value: string // ausgewählte ID
items: ComboItem[] // { id, label }
value: string
items: ComboItem[]
onSelect: (id: string) => void
}
export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
const [isOpen, setIsOpen] = useState(false)
const selected = items.find(i => i.id === value)
const listboxId = 'combo-listbox'
return (
<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"
type="text"
role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-controls={listboxId}
aria-expanded={isOpen}
value={selected?.label ?? ''}
data-hs-combo-box-input=""
readOnly
/>
<div
<button
type="button"
className="absolute top-1/2 end-3 -translate-y-1/2"
aria-expanded="false"
role="button"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-label="Auswahl öffnen"
data-hs-combo-box-toggle=""
onClick={() => setIsOpen(o => !o)}
>
<svg
className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500"
@ -40,53 +51,58 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</div>
</button>
</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"
style={{ display: 'none' }}
style={{ display: isOpen ? 'block' : 'none' }}
role="listbox"
data-hs-combo-box-output=""
>
{items.map((item) => (
<div
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"
role="option"
tabIndex={0}
data-hs-combo-box-output-item=""
data-hs-combo-box-item-stored-data={JSON.stringify({ id: item.id, name: item.label })}
onClick={() => onSelect(item.id)}
>
<div className="flex justify-between items-center w-full">
<span
data-hs-combo-box-search-text={item.label}
data-hs-combo-box-value=""
>
{item.label}
</span>
{item.id === value && (
<span className="hidden hs-combo-box-selected:block">
<svg
className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
{items.map((item) => {
const selectedOpt = item.id === value
return (
<div
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"
role="option"
aria-selected={selectedOpt}
tabIndex={0}
data-hs-combo-box-output-item=""
data-hs-combo-box-item-stored-data={JSON.stringify({ id: item.id, name: item.label })}
onClick={() => { onSelect(item.id); setIsOpen(false) }}
>
<div className="flex justify-between items-center w-full">
<span
data-hs-combo-box-search-text={item.label}
data-hs-combo-box-value=""
>
{item.label}
</span>
)}
{selectedOpt && (
<span className="hidden hs-combo-box-selected:block">
<svg
className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
)}
</div>
</div>
</div>
))}
)
})}
</div>
</div>
)

View File

@ -4,12 +4,9 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl'
import Link from 'next/link'
import Image from 'next/image'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Switch from '../components/Switch'
import Button from './Button'
import Modal from './Modal'
@ -22,12 +19,33 @@ type Props = { matchType?: string }
const getTeamLogo = (logo?: string | null) =>
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 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) */
function combineLocalDateTime(dateStr: string, timeStr: string) {
const [y, m, d] = dateStr.split('-').map(Number)
@ -64,14 +82,6 @@ type ZonedParts = {
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 {
const d = typeof date === 'string' ? new Date(date) : date;
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) */
function matchBanInfo(m: Match): { hasBan: boolean; count: number; tooltip: string } {
// a) neues Shape (teamA.players / teamB.players)
const playersA = (m as any)?.teamA?.players ?? []
const playersB = (m as any)?.teamB?.players ?? []
const teamA = (m.teamA as unknown as TeamMaybeWithPlayers);
const teamB = (m.teamB as unknown as TeamMaybeWithPlayers);
// b) Fallback: flaches players-Array (falls API alt)
const flat = (m as any)?.players ?? []
const playersA = Array.isArray(teamA?.players) ? teamA.players! : [];
const playersB = Array.isArray(teamB?.players) ? teamB.players! : [];
const all = Array.isArray(playersA) || Array.isArray(playersB)
? [...(playersA ?? []), ...(playersB ?? [])]
: Array.isArray(flat) ? flat : []
// Fallback: flaches players-Array (ältere API)
const flat = (m as unknown as { players?: PlayerLike[] | null }).players ?? [];
let count = 0
const lines: string[] = []
const all: PlayerLike[] =
playersA.length || playersB.length ? [...playersA, ...playersB] : Array.isArray(flat) ? flat : [];
let count = 0;
const lines: string[] = [];
for (const p of all) {
const user = p?.user ?? p // (Fallback falls p schon der User ist)
const name = user?.name ?? 'Unbekannt'
const b: BanStatus | undefined = user?.banStatus
const user = (p?.user as unknown as { name?: string | null; banStatus?: BanStatus | null }) ?? {};
const name = user?.name ?? 'Unbekannt';
const b = user?.banStatus ?? null;
if (isBanStatusFlagged(b)) {
count++
const parts: string[] = []
if (b?.vacBanned) parts.push('VAC aktiv')
if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`)
if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`)
if (b?.communityBanned) parts.push('Community')
if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`)
if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`)
lines.push(`${name}: ${parts.join(' · ')}`)
count++;
const parts: string[] = [];
if (b?.vacBanned) parts.push('VAC aktiv');
if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`);
if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`);
if (b?.communityBanned) parts.push('Community');
if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`);
if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`);
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) {
const { data: session } = useSession()
const router = useRouter()
const pathname = usePathname()
const locale = useLocale()
const userTZ = useUserTimeZone([session?.user?.steamId])
const weekdayFmt = useMemo(() =>
@ -211,26 +219,36 @@ export default function CommunityMatchList({ matchType }: Props) {
const mySteamId = session?.user?.steamId
const isOwnMatch = useCallback((m: any) => {
if (!mySteamId) return false
const isOwnMatch = useCallback((m: Match) => {
if (!mySteamId) return false;
// a) Neues Shape: teamA.players / teamB.players -> p.user.steamId
const inTeamA = m?.teamA?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false
const inTeamB = m?.teamB?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false
if (inTeamA || inTeamB) return true
const teamA = (m.teamA as unknown as TeamMaybeWithPlayers);
const teamB = (m.teamB as unknown as TeamMaybeWithPlayers);
// b) Manchmal flaches players-Array (falls noch vorhanden)
const inFlat = m?.players?.some((p: any) =>
p?.user?.steamId === mySteamId || p?.steamId === mySteamId
) ?? false
if (inFlat) return true
const inTeamA =
Array.isArray(teamA?.players) &&
teamA.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
// 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 =
!!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
}, [mySteamId, session?.user?.team])
return byTeamMembership;
}, [mySteamId, session?.user?.team]);
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
@ -305,23 +323,23 @@ export default function CommunityMatchList({ matchType }: Props) {
credentials: 'same-origin', // wichtig: Cookies mitnehmen
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 raw =
Array.isArray(json?.teams) ? json.teams :
Array.isArray(json?.data) ? json.data :
Array.isArray(json?.items) ? json.items :
Array.isArray(json) ? json :
[]
const arr: UnknownRec[] = Array.isArray(raw) ? (raw as UnknownRec[]) : [];
const opts: TeamOption[] = raw
.map((t: any) => ({
id: t.id ?? t._id ?? t.teamId ?? t.uuid ?? '',
name: t.name ?? t.title ?? t.displayName ?? t.tag ?? 'Unbenanntes Team',
logo: t.logo ?? t.logoUrl ?? t.image ?? null,
}))
.filter((t: TeamOption) => !!t.id && !!t.name)
const opts: TeamOption[] = arr
.map((r) => {
const id = firstString(r.id, r._id, r.teamId, r.uuid);
const name = firstString(r.name, r.title, r.displayName, r.tag) || 'Unbenanntes Team';
const logo = toStringOrNull(r.logo ?? r.logoUrl ?? r.image) ?? null;
return { id, name, logo };
})
.filter((t) => !!t.id && !!t.name);
if (!ignore) setTeams(opts)
} catch (e) {

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/CreateTeamButton.tsx
'use client'
import { useState, forwardRef } from 'react'
@ -6,10 +8,22 @@ import Modal from './Modal'
import Button from './Button'
type CreateTeamButtonProps = {
/** Optional: Parent kann damit eine Liste refreshen */
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>(
({ setRefetchKey }, ref) => {
const { data: session } = useSession()
@ -23,8 +37,9 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
const closeCreateModalAndCleanup = () => {
const modalEl = document.getElementById('modal-create-team')
if (modalEl && (window as any).HSOverlay?.close) {
;(window as any).HSOverlay.close(modalEl)
const win = window as WindowWithHSOverlay;
if (modalEl && win.HSOverlay?.close) {
win.HSOverlay.close(modalEl);
}
setShowModal(false)
@ -56,7 +71,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
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')
setStatus('success')
@ -71,9 +86,10 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
setRefetchKey?.(Date.now().toString())
})
}, 800)
} catch (err: any) {
setStatus('error')
setMessage(err.message || 'Fehler beim Erstellen des Teams')
} catch (err: unknown) {
setStatus('error');
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(() => {
const newDate = new Date(year, month, value.getDate(), hour, minute);
onChange(newDate);
}, [hour, minute, year, month]);
}, [onChange, value, hour, minute, year, month]);
useEffect(() => {
if (showPicker && buttonRef.current) {

View File

@ -1,22 +1,38 @@
// /src/app/[locale]/components/EditButton.tsx
'use client'
import { useSession } from 'next-auth/react'
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 me = session?.user?.steamId ?? null
const isLeader =
session?.user?.steamId &&
(session.user.steamId === match.teamA.leader ||
session.user.steamId === match.teamB.leader)
const leaderA = leaderIdOf(match.teamA?.leader)
const leaderB = leaderIdOf(match.teamB?.leader)
const isLeader = !!me && (me === leaderA || me === leaderB)
if (!isLeader) return null
return (
<div className="mt-6 text-center">
<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"
>
Match bearbeiten

View File

@ -1,4 +1,4 @@
// /src/app/components/EditMatchMetaModal.tsx
// /src/app/[locale]/components/EditMatchMetaModal.tsx
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
@ -13,6 +13,14 @@ type ZonedParts = {
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 = {
show: boolean
onClose: () => void
@ -78,27 +86,16 @@ export default function EditMatchMetaModal({
defaultTeamAName,
defaultTeamBName,
defaultDateISO,
// defaultMap,
defaultVoteLeadMinutes = 60,
onSaved,
defaultBestOf = 3,
}: Props) {
/* ───────── Utils ───────── */
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 ───────── */
const [title, setTitle] = useState(defaultTitle ?? '')
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
const userTZ = getUserTimeZone();
const initDT = isoToLocalDateTimeStrings(defaultDateISO, userTZ);
@ -150,11 +147,12 @@ export default function EditMatchMetaModal({
;(async () => {
try {
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
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? [])
setTeams((list ?? []).filter((t: any) => t?.id && t?.name))
} catch (e: any) {
const listUnknown: unknown = Array.isArray(dataUnknown) ? dataUnknown : (isRecord(dataUnknown) ? dataUnknown.teams : []);
const list = Array.isArray(listUnknown) ? listUnknown.filter(isTeamOption) : [];
setTeams(list)
} catch (e: unknown) {
if (!alive) return
console.error('[EditMatchMetaModal] load teams failed:', e)
setTeams([])
@ -197,10 +195,7 @@ export default function EditMatchMetaModal({
const dt = isoToLocalDateTimeStrings(j?.matchDate ?? j?.demoDate ?? null, userTZ);
setMatchDateStr(dt.dateStr);
setMatchTimeStr(dt.timeStr);
const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes))
? Number(j.mapVote.leadMinutes)
: 60;
setVoteLead(leadMin);
const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes)) ? Number(j.mapVote.leadMinutes) : 60;
// Vote-Open = MatchStart - leadMin
const matchISO = combineLocalDateTime(dt.dateStr, dt.timeStr);
@ -213,10 +208,10 @@ export default function EditMatchMetaModal({
setBestOf(boFromMeta)
setMetaBestOf(boFromMeta)
setSaved(false)
} catch (e: any) {
} catch (e: unknown) {
if (!alive) return
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 {
if (alive) {
setLoadingMeta(false)
@ -229,7 +224,7 @@ export default function EditMatchMetaModal({
alive = false
metaFetchedRef.current = false
}
}, [show, matchId])
}, [show, matchId, userTZ])
/* ───────── Optionen für Selects ───────── */
const teamOptionsA = useMemo(
@ -241,9 +236,6 @@ export default function EditMatchMetaModal({
[teams, teamAId]
)
/* ───────── Hinweis-Flag nur vs. /meta ───────── */
const showBoChangedHint = metaBestOf !== null && bestOf !== metaBestOf
/* ───────── Validation ───────── */
const canSave = useMemo(() => {
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 (teamAId && teamBId && teamAId === teamBId) return false
return true
}, [saving, loadingMeta, teamAId, teamBId])
}, [saving, loadingMeta, teamAId, teamBId, matchDateStr, matchTimeStr, voteOpenDateStr, voteOpenTimeStr])
/* ───────── Save ───────── */
const handleSave = async () => {
@ -298,9 +290,9 @@ export default function EditMatchMetaModal({
setSaved(true)
onClose()
setTimeout(() => onSaved?.(), 0)
} catch (e: any) {
} catch (e: unknown) {
console.error('[EditMatchMetaModal] save error:', e)
setError(e?.message || 'Speichern fehlgeschlagen')
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}

View File

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

View File

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

View File

@ -1,3 +1,5 @@
// //src/app/[locale]/components/GameBanner.tsx
'use client'
import React, {useEffect, useMemo, useRef, useState} from 'react'
@ -25,11 +27,9 @@ type Variant = 'connected' | 'disconnected'
/** ✅ NEU: Props, alle optional als Overrides */
type GameBannerProps = {
variant?: Variant
/** true => Banner anzeigen (überschreibt interne Sichtbarkeitslogik), false => nie anzeigen */
visible?: boolean
zIndex?: number
inline?: boolean
serverLabel?: string
mapKey?: string
mapLabel?: string
bgUrl?: string
@ -38,15 +38,59 @@ type GameBannerProps = {
score?: string
connectedCount?: number
totalExpected?: number
missingCount?: number
onReconnect?: () => 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())
/* ---------- 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 pickMapImageFromOptions = (mapKey?: string) => {
if (!mapKey) return null
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
return opt.images[idx] ?? null
}
const pickMapIcon = (mapKey?: string) => {
if (!mapKey) return null
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
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) {
const h = (host ?? '').trim() || '127.0.0.1'
const p = (port ?? '').trim() || '8081'
@ -80,7 +146,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
visible: visibleProp,
zIndex: zIndexProp,
inline = false,
serverLabel, // aktuell nicht gerendert, nur angenommen
mapKey: mapKeyProp,
mapLabel: mapLabelProp,
bgUrl: bgUrlProp,
@ -89,7 +154,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
score: scoreProp,
connectedCount: connectedCountProp,
totalExpected: totalExpectedProp,
missingCount: _missingCountProp, // aktuell nicht gerendert, nur angenommen
onReconnect,
onDisconnect,
} = props
@ -178,38 +242,45 @@ export default function GameBanner(props: GameBannerProps = {}) {
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
const msg = parseWsData(ev.data)
if (!msg) return
if (msg.type === 'players' && Array.isArray(msg.players)) {
const ids = msg.players.map(sidOf).filter(Boolean)
if ('type' in msg && msg.type === 'players' && Array.isArray((msg as PlayersMsg).players)) {
const ids = (msg as PlayersMsg).players.map(sidOf).filter(Boolean)
setTelemetrySet(toSet(ids))
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 })
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 })
return
}
if (msg.type === 'score') {
const a = Number(msg.team1 ?? msg.ct)
const b = Number(msg.team2 ?? msg.t)
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
if ('type' in msg && msg.type === 'score') {
const m = msg as ScoreMsg
const a = toFiniteOrNull(m.team1 ?? m.ct)
const b = toFiniteOrNull(m.team2 ?? m.t)
setScore({ a, b })
return
}
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 })
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
}
// Phase ignorieren
}
}
@ -230,7 +301,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
// Prop kann Sichtbarkeit erzwingen/abschalten
const canShowInternal = meIsParticipant && notExpired
if (visibleProp === false) return null
const canShow = visibleProp === true ? true : canShowInternal
// Connected Count + Variant

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/GameBannerController.tsx
'use client'
import {useEffect, useMemo, useRef, useState} from 'react'
@ -16,6 +18,43 @@ type LiveCfg = {
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())
/* ---------- WS helpers ---------- */
@ -31,8 +70,26 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string)
return `${proto}://${h}${portPart}${pa}`
}
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)))
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() {
const { data: session } = useSession()
@ -153,44 +210,45 @@ export default function GameBannerController() {
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
const msg = parseWsData(ev.data)
if (!msg) return
if (msg.type === 'players' && Array.isArray(msg.players)) {
const ids = msg.players.map(sidOf).filter(Boolean)
if ('type' in msg && msg.type === 'players' && Array.isArray((msg as PlayersMsg).players)) {
const ids = (msg as PlayersMsg).players.map(sidOf).filter(Boolean)
setTelemetrySet(toSet(ids))
return
}
if (msg.type === 'player_join' && msg.player) {
const sid = sidOf(msg.player)
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 })
return
}
if (msg.type === 'player_leave') {
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '')
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 })
return
}
if (msg.type === 'score') {
const a = Number(msg.team1 ?? msg.ct)
const b = Number(msg.team2 ?? msg.t)
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
return
}
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 })
if ('type' in msg && msg.type === 'score') {
const m = msg as ScoreMsg
const a = toFiniteOrNull(m.team1 ?? m.ct)
const b = toFiniteOrNull(m.team2 ?? m.t)
setScore({ a, b })
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'
@ -22,6 +22,50 @@ type Props = {
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) {
const { data: session } = useSession()
const steamId = session?.user?.steamId
@ -39,7 +83,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const [onlyFree, setOnlyFree] = useState(false)
// Sanftes UI-Update
const [isPending, startTransition] = useTransition()
const [, startTransition] = useTransition()
const [isFetching, setIsFetching] = useState(false)
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 }) {
try {
abortRef.current?.abort()
const ctrl = new AbortController()
abortRef.current = ctrl
abortRef.current?.abort();
const ctrl = new AbortController();
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(() => {
setSpinnerVisible(true)
spinnerShownAt.current = Date.now()
}, SPINNER_DELAY_MS)
setSpinnerVisible(true);
spinnerShownAt.current = Date.now();
}, SPINNER_DELAY_MS);
const qs = new URLSearchParams({ teamId: team.id })
if (onlyFree) qs.set('onlyFree', 'true')
const qs = new URLSearchParams({ teamId: team.id });
if (onlyFree) qs.set('onlyFree', 'true');
const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
signal: ctrl.signal,
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(() => {
setAllUsers(data.users || [])
setAllUsers(users);
setKnownUsers(prev => {
const next = { ...prev }
for (const u of (data.users || [])) next[u.steamId] = u
return next
})
const next = { ...prev };
for (const u of users) next[u.steamId] = u;
return next;
});
if (opts.resetLayout) {
setSelectedIds([])
setInvitedIds([])
setIsSuccess(false)
setSelectedIds([]);
setInvitedIds([]);
setIsSuccess(false);
}
})
} catch (e: any) {
if (e?.name !== 'AbortError') console.error('Fehler beim Laden der Benutzer:', e)
});
} catch (e: unknown) {
if (isRecord(e) && e.name === 'AbortError') return;
console.error('Fehler beim Laden der Benutzer:', e);
} finally {
setIsFetching(false)
abortRef.current = null
@ -194,67 +240,60 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
}
const handleInvite = async () => {
if (isInviting) return
if (selectedIds.length === 0 || !steamId) return
const ids = [...selectedIds]
if (isInviting) return;
if (selectedIds.length === 0 || !steamId) return;
const ids = [...selectedIds];
try {
setIsInviting(true)
const url = directAdd ? '/api/team/add-players' : '/api/team/invite'
setIsInviting(true);
const url = directAdd ? '/api/team/add-players' : '/api/team/invite';
const body = directAdd
? { teamId: team.id, steamIds: ids }
: { teamId: team.id, userIds: ids, invitedBy: steamId }
: { teamId: team.id, userIds: ids, invitedBy: steamId };
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
});
let json: any = null
try { json = await res.clone().json() } catch {}
let jsonUnknown: unknown = null;
try { jsonUnknown = await res.clone().json(); } catch { /* ignore */ }
let results: { steamId: string; ok: boolean }[] = []
let results: ApiResultItem[] | null = parseResultsFromJson(jsonUnknown);
if (directAdd) {
if (json?.results && Array.isArray(json.results)) {
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.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 }))
// Fallback, falls API keine detailierten results liefert
if (!results) {
results = ids.map(id => ({ steamId: id, ok: res.ok }));
}
const nextStatus: Record<string, InviteStatus> = {}
let okCount = 0
const nextStatus: Record<string, InviteStatus> = {};
let okCount = 0;
for (const r of results) {
const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed'
nextStatus[r.steamId] = st
if (r.ok) okCount++
const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed';
nextStatus[r.steamId] = st;
if (r.ok) okCount++;
}
setInvitedStatus(prev => ({ ...prev, ...nextStatus }))
setInvitedIds(ids)
setSentCount(okCount)
setIsSuccess(true)
setSelectedIds([])
if (okCount > 0) onSuccess()
} catch (err) {
console.error('Fehler beim Einladen:', err)
setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(selectedIds.map(id => [id, 'failed'])) }))
setInvitedIds(selectedIds)
setSentCount(0)
setIsSuccess(true)
setInvitedStatus(prev => ({ ...prev, ...nextStatus }));
setInvitedIds(ids);
setSentCount(okCount);
setIsSuccess(true);
setSelectedIds([]);
if (okCount > 0) onSuccess();
} catch (err: unknown) {
console.error('Fehler beim Einladen:', err);
setInvitedStatus(prev => ({
...prev,
...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus])),
}));
setInvitedIds(selectedIds);
setSentCount(0);
setIsSuccess(true);
} finally {
setIsInviting(false)
setIsInviting(false);
}
}
};
useEffect(() => { setCurrentPage(1) }, [searchTerm])
@ -407,7 +446,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions
rank={user.premierRank}
/>
@ -480,12 +519,13 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
avatar={user.avatar}
location={user.location}
selected={false}
onSelect={handleSelect}
draggable={false}
onSelect={handleSelect}
currentUserSteamId={steamId!}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions
rank={user.premierRank}
invitedStatus={invitedStatus[user.steamId]}
/>
</motion.div>
))}
@ -511,7 +551,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
selected={false}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions
rank={user.premierRank}
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 [newLeaderId, setNewLeaderId] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [, setIsSubmitting] = useState(false)
useEffect(() => {
if (show && team.leader?.steamId) {

View File

@ -1,4 +1,4 @@
// /src/app/components/MapVoteBanner.tsx
// /src/app/[locale]/components/MapVoteBanner.tsx
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
@ -8,12 +8,52 @@ import { useSSEStore } from '@/lib/useSSEStore'
import type { MapVoteState } from '../../../types/mapvote'
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 = {
match: any
initialNow: number
matchBaseTs: number | null
sseOpensAtTs?: number | null
sseLeadMinutes?: number | null
match: MatchLite;
initialNow: number;
matchBaseTs: number | null;
sseOpensAtTs?: 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) {
@ -25,18 +65,28 @@ function formatCountdown(ms: number) {
const pad = (n:number)=>String(n).padStart(2,'0')
return `${h}:${pad(m)}:${pad(s)}`
}
function formatLead(minutes: number) {
if (!Number.isFinite(minutes) || minutes <= 0) return 'zum Matchbeginn'
const h = Math.floor(minutes / 60)
const m = minutes % 60
if (h > 0 && m > 0) return `${h}h ${m}min`
if (h > 0) return `${h}h`
return `${m}min`
function formatLead(minutes: number, tMapvote: TMapvote) {
if (!Number.isFinite(minutes) || minutes <= 0) return tMapvote('to-match-start');
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h > 0 && m > 0) return `${h}h ${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({
match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes,
}: Props) {
const tMapvote = useTranslations<'mapvote'>('mapvote');
const router = useRouter()
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
@ -51,26 +101,30 @@ export default function MapVoteBanner({
const [now, setNow] = useState(initialNow)
useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, [])
// Übersetzungen
const tCommon = useTranslations('common')
const load = useCallback(async () => {
const ac = new AbortController();
try {
setError(null)
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
setError(null);
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store', signal: ac.signal });
if (!r.ok) {
const j = await r.json().catch(() => ({}))
throw new Error(j?.message || 'Laden fehlgeschlagen')
let message = 'Laden fehlgeschlagen';
const parsed = (await r.json().catch(() => null)) as unknown;
if (isObject(parsed) && typeof parsed.message === 'string') {
message = parsed.message;
}
throw new Error(message);
}
const json = await r.json()
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')
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');
}
}, [match.id])
return () => ac.abort();
}, [match.id]);
useEffect(() => { load() }, [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])
useEffect(() => {
if (!lastEvent) return
const { type } = lastEvent as any
const evt = (lastEvent as any).payload ?? lastEvent
if (evt?.matchId !== match.id) return
if (!lastEvent) return;
const { type, payload } = unwrapEvent(lastEvent);
if (payload.matchId !== match.id) return;
if (!type || !RELOAD_TYPES.has(type as ReloadEventType)) return;
const RELOAD_TYPES = new Set([
'map-vote-updated','map-vote-reset','map-vote-locked','map-vote-unlocked',
'match-updated','match-lineup-updated',
])
if (!RELOAD_TYPES.has(type)) return
const rawLead = evt?.leadMinutes
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
const rawLead = payload.leadMinutes;
const parsedLead = Number.isFinite(rawLead) ? Number(rawLead) : undefined;
const nextOpensAtISO = payload.opensAt
? (typeof payload.opensAt === 'string'
? payload.opensAt
: new Date(payload.opensAt).toISOString())
: undefined;
if (nextOpensAtISO) {
setOpensAtOverride(new Date(nextOpensAtISO).getTime())
} else if (Number.isFinite(parsedLead) && matchDateTs != null) {
setOpensAtOverride(matchDateTs - (parsedLead as number) * 60_000)
setOpensAtOverride(new Date(nextOpensAtISO).getTime());
} else if (parsedLead !== undefined && matchDateTs != null) {
setOpensAtOverride(matchDateTs - parsedLead * 60_000);
}
if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number)
if (parsedLead !== undefined) setLeadOverride(parsedLead);
if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) {
setState(prev => ({
...(prev ?? {} as any),
...(nextOpensAtISO !== undefined ? { opensAt: nextOpensAtISO } : {}),
...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}),
}) as any)
if (nextOpensAtISO !== undefined || parsedLead !== undefined) {
setState(prev => {
if (!prev) return prev; // bleibt null, bis ein kompletter State geladen ist
const patch: Partial<MapVoteState> = {};
if (nextOpensAtISO !== undefined) patch.opensAt = nextOpensAtISO;
if (parsedLead !== undefined) patch.leadMinutes = parsedLead;
return { ...prev, ...patch }; // => MapVoteState
});
} else {
load()
load();
}
}, [lastEvent, match.id, matchDateTs, load])
}, [lastEvent, match.id, matchDateTs, load]);
const stateOpensAt = state?.opensAt;
const stateLeadMinutes = state?.leadMinutes;
const opensAt = useMemo(() => {
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs
if (opensAtOverride != null) return opensAtOverride
if (state?.opensAt) return new Date(state.opensAt).getTime()
if (matchDateTs == null) return new Date(initialNow).getTime()
const lead = (typeof sseLeadMinutes === 'number')
? sseLeadMinutes
: (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? (state!.leadMinutes as number) : 60))
return matchDateTs - lead * 60_000
}, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes])
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs;
if (opensAtOverride != null) return opensAtOverride;
if (stateOpensAt) return new Date(stateOpensAt).getTime();
if (matchDateTs == null) return new Date(initialNow).getTime();
const lead =
typeof sseLeadMinutes === 'number'
? sseLeadMinutes
: (leadOverride ?? (Number.isFinite(stateLeadMinutes) ? (stateLeadMinutes as number) : 60));
return matchDateTs - lead * 60_000;
}, [
sseOpensAtTs,
opensAtOverride,
stateOpensAt,
matchDateTs,
initialNow,
sseLeadMinutes,
leadOverride,
stateLeadMinutes,
]);
const leadMinutes = useMemo(() => {
if (matchDateTs != null && opensAt != null) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000))
if (typeof sseLeadMinutes === 'number') return sseLeadMinutes
if (leadOverride != null) return leadOverride
if (Number.isFinite(state?.leadMinutes)) return state!.leadMinutes as number
return 60
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes])
if (matchDateTs != null && opensAt != null) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000));
if (typeof sseLeadMinutes === 'number') return sseLeadMinutes;
if (leadOverride != null) return leadOverride;
if (Number.isFinite(stateLeadMinutes)) return stateLeadMinutes as number;
return 60;
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, stateLeadMinutes]);
const isOpen = mounted && now >= opensAt
const msToOpen = Math.max(opensAt - now, 0)
@ -185,7 +249,7 @@ export default function MapVoteBanner({
onClick={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}`}
aria-label="Map-Vote öffnen"
aria-label={`Mapvote ${tMapvote("open-small")}`}
>
{(isVotingOpen || isLocked) && (
<>
@ -205,12 +269,12 @@ export default function MapVoteBanner({
<div className="min-w-0">
<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">
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
{tMapvote("mode")}: BO{match.bestOf ?? state?.bestOf ?? 3}
{isEnded
? ' • Auswahl fixiert'
: isLive
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
: ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`}
: ` • startet ${formatLead(leadMinutes, tMapvote)} vor Matchbeginn`}
</div>
{error && <div className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</div>}
</div>
@ -219,16 +283,16 @@ export default function MapVoteBanner({
<div className="shrink-0">
{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">
Voting abgeschlossen
{tMapvote("completed")}
</span>
) : 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">
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
{iCanAct ? tMapvote("vote-now") : `Mapvote ${tMapvote("open")}`}
</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"
suppressHydrationWarning>
Öffnet in {mounted ? formatCountdown(msToOpen) : '::'}
{tMapvote('opens-in')} {mounted ? formatCountdown(msToOpen) : '::'} {formatLead(leadMinutes, tMapvote)}
</span>
)}
</div>

View File

@ -1,4 +1,4 @@
// MapVotePanel.tsx
// /src/app/[locale]/components/MapVotePanel.tsx
'use client'
@ -19,11 +19,47 @@ import { MAP_OPTIONS } from '@/lib/mapOptions'
import { Tabs } from './Tabs'
import Chart from './Chart'
import { MAPVOTE_REFRESH } from '@/lib/sseEvents'
import Image from 'next/image'
/* =================== Utilities & constants =================== */
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 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 WinrateApi = {
byPlayer?: Record<string, { byMap?: Record<string, { pct?: number | string }> }>;
};
/** Liest /api/user/winrate und normiert auf 0..100 (Float), NICHT ×10 */
async function fetchWinratesBatch(
steamIds: string[],
@ -56,36 +96,43 @@ async function fetchWinratesBatch(
if (!ids.length) return {};
const q = new URLSearchParams();
// steamIds als CSV in EINEM Param
q.set('steamIds', ids.join(','));
// types als wiederholte Parameter
(opts?.types ?? []).forEach(t => q.append('types', t));
if (opts?.onlyActive === false) q.append('onlyActive', 'false');
const r = await fetch(`/api/user/winrate?${q.toString()}`, { cache: 'no-store' });
if (!r.ok) return {};
const json = await r.json().catch(() => null);
const out: BatchByPlayer = {};
const json = (await r.json().catch(() => null)) as WinrateApi | null;
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 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);
if (Number.isFinite(pctX10)) normalized[mapKey] = pctX10 / 10;
}
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;
}
/* =================== 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) {
/* -------- External stores / env -------- */
const router = useRouter()
@ -100,7 +147,6 @@ export default function MapVotePanel({ match }: Props) {
const [adminEditMode, setAdminEditMode] = useState(false)
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
const [tab, setTab] = useState<'pool' | 'winrate'>('pool')
/* -------- Timers / open window -------- */
@ -154,18 +200,18 @@ export default function MapVotePanel({ match }: Props) {
}, [overlayOpen, overlayIsForThisMatch])
useEffect(() => {
if (!lastEvent) return
if (lastEvent.type !== 'match-ready') return
if (lastEvent.payload?.matchId !== match.id) return
if (!isMatchReadyEvent(lastEvent)) return;
if (lastEvent.payload.matchId !== match.id) return;
const fm = lastEvent.payload.firstMap; // FirstMap | undefined
const fm = lastEvent.payload?.firstMap ?? {}
showWithDelay({
matchId: match.id,
mapLabel: fm?.label ?? 'Erste Map',
mapBg: fm?.bg ?? '/assets/img/maps/lobby_mapveto_png.webp',
nextHref: `/match-details/${match.id}/radar`,
}, 3000)
}, [lastEvent, match.id, showWithDelay])
}, 3000);
}, [lastEvent, match.id, showWithDelay]);
/* -------- Data load (initial + SSE refresh) -------- */
const load = useCallback(async () => {
@ -174,15 +220,15 @@ export default function MapVotePanel({ match }: Props) {
try {
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
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')
}
const json = await r.json()
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
setState(json)
} catch (e: any) {
} catch (e: unknown) {
setState(null)
setError(e?.message ?? 'Unbekannter Fehler')
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
@ -226,9 +272,14 @@ export default function MapVotePanel({ match }: Props) {
)
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 ?? []) {
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
}, [state?.steps])
@ -249,24 +300,24 @@ export default function MapVotePanel({ match }: Props) {
body: JSON.stringify({ adminEdit: enabled }),
})
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')
}
return r.json()
}
const handlePickOrBan = async (map: string) => {
if (!isMyTurn || !currentStep) return
const handlePickOrBan = useCallback(async (map: string) => {
if (!isMyTurn || !currentStep) return;
try {
const r = await fetch(`/api/matches/${match.id}/mapvote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ map }),
})
});
if (!r.ok) {
const j = await r.json().catch(() => ({}))
alert(j.message ?? 'Aktion fehlgeschlagen')
return
const j = await r.json().catch(() => ({} as { message?: string }));
alert(j.message ?? 'Aktion fehlgeschlagen');
return;
}
setState(prev =>
prev
@ -277,11 +328,11 @@ export default function MapVotePanel({ match }: Props) {
),
}
: prev
)
);
} catch {
alert('Netzwerkfehler')
alert('Netzwerkfehler');
}
}
}, [isMyTurn, currentStep, match.id, setState]);
/* -------- Press-and-hold logic -------- */
const rafRef = useRef<number | null>(null)
@ -369,17 +420,22 @@ export default function MapVotePanel({ match }: Props) {
}, [state?.steps])
/* -------- 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 teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined
const teamPlayers = m.teamA?.players
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
const all = (match as any).players as MatchPlayer[] | undefined
const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined
const all = m.players
const teamAUsers = m.teamAUsers
if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) {
const setA = new Set(teamAUsers.map(u => u.steamId))
return all.filter(p => setA.has(p.user.steamId))
}
if (Array.isArray(all) && match.teamA?.id) {
return all.filter(p => (p as any).team?.id === match.teamA?.id)
if (Array.isArray(all) && m.teamA?.id) {
return all.filter(p => (p as unknown as MaybeTeam).team?.id === m.teamA?.id)
}
const votePlayers = state?.teams?.teamA?.players as
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
@ -395,19 +451,19 @@ export default function MapVotePanel({ match }: Props) {
}))
}
return []
}, [match, state?.teams?.teamA?.players])
}, [m, state?.teams?.teamA?.players])
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
const all = (match as any).players as MatchPlayer[] | undefined
const teamBUsers = (match as any).teamBUsers as { steamId: string }[] | undefined
const all = m.players
const teamBUsers = m.teamBUsers
if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) {
const setB = new Set(teamBUsers.map(u => u.steamId))
return all.filter(p => setB.has(p.user.steamId))
}
if (Array.isArray(all) && match.teamB?.id) {
return all.filter(p => (p as any).team?.id === match.teamB?.id)
if (Array.isArray(all) && m.teamB?.id) {
return all.filter(p => (p as unknown as MaybeTeam).team?.id === m.teamB?.id)
}
const votePlayers = state?.teams?.teamB?.players as
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
@ -423,14 +479,14 @@ export default function MapVotePanel({ match }: Props) {
}))
}
return []
}, [match, state?.teams?.teamB?.players])
}, [m, state?.teams?.teamB?.players])
const teamAPlayersForRank = useMemo(
() => playersA.map(p => ({ premierRank: p.user.premierRank ?? 0 })) as any,
() => playersA.map(p => ({ premierRank: p.user.premierRank ?? 0 })),
[playersA]
)
const teamBPlayersForRank = useMemo(
() => playersB.map(p => ({ premierRank: p.user.premierRank ?? 0 })) as any,
() => playersB.map(p => ({ premierRank: p.user.premierRank ?? 0 })),
[playersB]
)
@ -490,10 +546,6 @@ export default function MapVotePanel({ match }: Props) {
[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
let teamLeftKey: 'teamA' | 'teamB' = 'teamA'
let teamRightKey: 'teamA' | 'teamB' = 'teamB'
@ -501,23 +553,16 @@ export default function MapVotePanel({ match }: Props) {
teamLeftKey = 'teamB'
teamRightKey = 'teamA'
}
// Farben an Radar anlehnen
const LEFT_RING = 'ring-2 ring-green-500/70 shadow-[0_10px_30px_rgba(34,197,94,0.25)]';
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 teamLeft = teamLeftKey === 'teamA' ? m.teamA : m.teamB
const teamRight = teamRightKey === 'teamA' ? m.teamA : m.teamB
const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB
const playersRight = teamRightKey === 'teamA' ? playersA : playersB
const rankLeft = teamLeftKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank
const rankRight = teamRightKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank
const leftTeamId = state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id
const rightTeamId = state?.teams?.[teamRightKey]?.id ?? teamRight?.id
const leftTeamId = state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id ?? null
const rightTeamId = state?.teams?.[teamRightKey]?.id ?? teamRight?.id ?? null
const leftIsActiveTurn =
!!currentStep?.teamId &&
@ -568,28 +613,15 @@ export default function MapVotePanel({ match }: Props) {
// 4) exakt diese Liste fürs Radar verwenden
const activeMapKeys = sortedMapPool;
const activeMapLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf]);
// Helper: Durchschnitt (nur finite Werte)
function avg(values: number[]) {
const valid = values.filter(v => Number.isFinite(v))
if (!valid.length) return 0
return valid.reduce((a, b) => a + b, 0) / valid.length
}
// Labels nur aus keys ableiten → stabil solange keys gleich bleiben
const radarLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf])
// 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 [teamRadarRight, setTeamRadarRight] = useState<number[]>(activeMapKeys.map(() => 0))
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)
const idsKey = useMemo(() => {
const s = new Set<string>();
@ -604,12 +636,6 @@ export default function MapVotePanel({ match }: Props) {
[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
const radarDatasets = useMemo(() => ([
{
@ -631,98 +657,63 @@ export default function MapVotePanel({ match }: Props) {
// Icons ebenfalls stabilisieren
const radarIcons = useMemo(
() => activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`),
[mapsKey] // reicht, solange keys unverändert
[activeMapKeys]
)
useEffect(() => {
if (!lastEvent) return
if (!lastEvent) return;
const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e
const evt = unwrap(lastEvent)
const type = lastEvent.type ?? evt?.type
const evt = unwrapEvent(lastEvent);
const type = (isRecord(lastEvent) && isString((lastEvent as Record<string, unknown>).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 evtTeamId : string | null = evt?.teamId ?? null
const actionType: string | null = evt?.actionType ?? null
const actionData: string | null = evt?.actionData ?? null // z.B. newLeaderSteamId
const evtMatchId = isString((evt as Record<string, unknown>).matchId) ? (evt as Record<'matchId', string>).matchId : null;
const evtTeamId = isString((evt as Record<string, unknown>).teamId) ? (evt as Record<'teamId', string>).teamId : null;
const actionType = isString((evt as Record<string, unknown>).actionType) ? (evt as Record<'actionType', string>).actionType : null;
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 = !!evtMatchId && evtMatchId === match.id;
const isForThisMatchByTeamId = !!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id);
const isForThisMatchByTeamId =
!!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id)
// 2) Notifications abdecken (changed + self)
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 = isLeaderChangeNotification && !!actionData && teamSteamIds.has(actionData);
const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId;
// 4) Relevanz
const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId
// 5) Offensichtliche Leader-Änderungen -> hart refreshen, auch ohne Relevanzbeweis
const forceRefresh =
type === 'team-leader-changed' ||
type === 'team-updated' ||
isLeaderChangeNotification
type === 'team-leader-changed' || type === 'team-updated' || isLeaderChangeNotification;
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 (!isRelevant && !forceRefresh) return;
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) {
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;
} else if (Number.isFinite(leadMinRaw ?? NaN) && matchBaseTs != null) {
const ts = matchBaseTs - (leadMinRaw as number) * 60_000;
setOpensAtOverrideTs(ts);
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) {
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
useEffect(() => {
@ -766,7 +757,7 @@ export default function MapVotePanel({ match }: Props) {
})();
return () => { aborted = true; };
}, [tab, idsKey, mapsKey]);
}, [tab, idsKey, mapsKey, activeMapKeys, playersLeft, playersRight]);
/* =================== Render =================== */
@ -805,9 +796,9 @@ export default function MapVotePanel({ match }: Props) {
try {
await postAdminEdit(next)
await load()
} catch (e: any) {
} catch (e: unknown) {
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 {
const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' })
if (!r.ok) {
const j = await r.json().catch(() => ({}))
const j = await r.json().catch(() => ({} as { message?: string }))
alert(j.message ?? 'Reset fehlgeschlagen')
return
}
@ -866,7 +857,9 @@ export default function MapVotePanel({ match }: Props) {
🔒 Admin-Edit aktiv Voting pausiert
{(() => {
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?.teamB?.leader)
;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe)
@ -967,7 +960,7 @@ export default function MapVotePanel({ match }: Props) {
].join(' ')}
>
<div className="flex items-center gap-3">
<img
<Image
src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'}
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}
onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId}
isActiveTurn={leftIsActiveTurn}
/>
))}
</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 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 =
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
@ -1056,7 +1037,7 @@ export default function MapVotePanel({ match }: Props) {
return (
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
{pickedByLeft ? (
<img
<Image
src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'}
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)}
onMouseUp={() => cancelOrSubmitIfComplete(map)}
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
onTouchCancel={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
onTouchStart={onTouchStart(map, isAvailable)}
onTouchEnd={onTouchEnd(map)}
onTouchCancel={onTouchEnd(map)}
>
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
{showProgress && (
@ -1117,7 +1098,7 @@ export default function MapVotePanel({ match }: Props) {
</Button>
{pickedByRight ? (
<img
<Image
src={getTeamLogo(teamRight?.logo)}
alt={teamRight?.name ?? 'Team'}
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="font-bold text-lg truncate">{teamRight?.name ?? 'Team'}</div>
</div>
<img
<Image
src={getTeamLogo(teamRight?.logo)}
alt={teamRight?.name ?? 'Team'}
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}
onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId}
isActiveTurn={rightIsActiveTurn}
/>
))}
</div>
@ -1279,7 +1259,7 @@ export default function MapVotePanel({ match }: Props) {
>
{/* Hintergrundbild */}
{bg && (
<img
<Image
src={bg}
alt={label}
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) */}
{cornerLogo && (
<img
<Image
src={cornerLogo}
alt="Picker-Team"
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">
{mapLogo ? (
<>
<img
<Image
src={mapLogo}
alt={label}
className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg"

View File

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

View File

@ -5,8 +5,6 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Table from './Table'
import PremierRankBadge from './PremierRankBadge'
import CompRankBadge from './CompRankBadge'
@ -34,17 +32,6 @@ const KPR_CAP = 1.2
const APR_CAP = 0.6
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 = {
level: number|null
elo: number|null
@ -214,21 +201,22 @@ function hasVacBan(p: MatchPlayer): boolean {
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"
const norm = (m?: unknown): string => {
if (!m) return ''
if (typeof m === 'string') return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
if (typeof m === 'object') {
if (typeof m === 'string') {
return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
}
if (typeof m === 'object' && m) {
const o = m as Record<string, unknown>
// häufige Felder, die den Mapkey tragen:
const cand =
o.key ?? o.map ?? o.name ?? o.id ?? (o as any)?.value ?? (o as any)?.slug
return typeof cand === 'string'
? cand.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
: ''
const candidates: unknown[] = [
o['key'], o['map'], o['name'], o['id'], o['value'], o['slug']
]
for (const c of candidates) {
if (typeof c === 'string') {
return c.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
}
}
}
return ''
}
@ -246,27 +234,33 @@ const mapLabelFromKey = (key?: string) => {
// 3) Final-Maps extrahieren deckt Strings/Objekte & verschiedene Felder ab
function extractSeriesMaps(match: Match, bestOf: number): string[] {
const n = Math.max(1, bestOf)
// a) Steps (nur PICK/DECIDER)
const stepsRaw = Array.isArray(match.mapVote?.steps) ? match.mapVote!.steps as unknown[] : []
type StepRec = { action?: unknown; order?: unknown; map?: unknown }
const steps = stepsRaw
.map(s => (typeof s === 'object' && s ? s as StepRec : {}))
.filter(s => s.action === 'PICK' || s.action === 'DECIDER')
.sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0))
.map(s => s.map)
// a) klassische Steps (nur PICK/DECIDER)
const fromSteps: unknown[] =
(match.mapVote?.steps ?? [])
.filter((s: any) => s && (s.action === 'PICK' || s.action === 'DECIDER'))
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0))
.map((s: any) => s.map)
// b) Ergebnis-Felder
const mv = match.mapVote as unknown as Record<string, unknown> | undefined
const mvResult = (mv?.['result'] && typeof mv['result'] === 'object') ? mv['result'] as Record<string, unknown> : undefined
const mvFinal = (mv?.['final'] && typeof mv['final'] === 'object') ? mv['final'] as Record<string, unknown> : undefined
// b) häufige Ergebnis-Felder
const mv: any = match.mapVote ?? {}
const candidates: unknown[] = [
mv.result?.maps,
mv.result?.picks, // [{map: '...'}] / [{key:'...'}]
mv.result?.series, // ['de_mirage', ...] oder [{key:...}]
mv.final?.maps,
(match as any)?.series?.maps,
(match as any)?.maps,
].flat().filter(Boolean)
const getArr = (v: unknown): unknown[] => Array.isArray(v) ? v : []
const picks = getArr(mvResult?.['picks']).map(x => (typeof x === 'object' && x ? (x as Record<string, unknown>)['map'] ?? (x as Record<string, unknown>)['key'] : x))
const series = getArr(mvResult?.['series'])
const resMaps = getArr(mvResult?.['maps'])
const finMaps = getArr(mvFinal?.['maps'])
const more: unknown[] = [
...resMaps, ...picks, ...series, ...finMaps
]
// c) flach ziehen + normalisieren + entduplizieren (stabile Reihenfolge)
const chain = [...fromSteps, ...candidates]
const chain = [...steps, ...more]
.map(norm)
.filter(Boolean)
@ -388,14 +382,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const [playerFaceits, setPlayerFaceits] = useState<Record<string, PrefetchedFaceit | null>>({})
// ⬇️ 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
)
// Alle Maps der Serie (BO3/BO5) abhängig von bestOf-State
const allMaps = useMemo(
() => extractSeriesMaps(match, bestOf),
[match.mapVote?.steps, (match as any)?.mapVote?.result?.maps, match.map, bestOf]
[match, bestOf]
)
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 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"?
const mySteamId = session?.user?.steamId
const mySide: 'A' | 'B' | null = mySteamId
@ -472,8 +475,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [match.teamA?.players, match.teamB?.players])
}, [match.teamA?.players, match.teamB?.players, playerFaceits, playerSummaries])
// beim mount user-tz aus DB laden
useEffect(() => {
@ -487,7 +489,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const sp = new URLSearchParams(window.location.search)
const m = Number(sp.get('m'))
if (Number.isFinite(m) && m >= 0 && m < allMaps.length) setActiveMapIdx(m)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allMaps.length])
const setActive = (idx: number) => {
@ -519,7 +520,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const fromVote = extractSeriesMaps(match, bestOf)
const n = Math.max(1, bestOf)
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({length: n - fromVote.length}, () => '')]
}, [bestOf, match.mapVote?.steps?.length])
}, [match, bestOf])
// Ticker für Mapvote-Zeitfenster
useEffect(() => {
@ -541,7 +542,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
? leadOverride
: (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
return matchBaseTs - lead * 60_000
}, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride])
}, [opensAtOverride, match.mapVote, matchBaseTs, leadOverride])
const sseOpensAtTs = voteOpensAtTs
const sseLeadMinutes = leadOverride
@ -554,26 +555,41 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
// SSE-Listener (nur relevante Events)
useEffect(() => {
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 evt = base?.payload ?? base
if (!evt?.matchId || evt.matchId !== match.id) return
const outer = lastEvent as unknown
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
lastHandledKeyRef.current = key
if (type === 'map-vote-updated') {
if (evt?.opensAt) setOpensAtOverride(new Date(evt.opensAt).getTime())
if (Number.isFinite(evt?.leadMinutes)) {
const lead = Number(evt.leadMinutes)
const opensAt = opensAtRaw
if (opensAt) setOpensAtOverride(new Date(opensAt as string | number | Date).getTime())
const leadNum = getNumber(leadMinutesRaw)
if (Number.isFinite(leadNum)) {
const lead = leadNum as number
setLeadOverride(lead)
if (!evt?.opensAt) {
if (!opensAt) {
const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
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'])
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])
useEffect(() => {
@ -600,7 +616,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const url = `${window.location.pathname}?${sp.toString()}${window.location.hash}`
window.history.replaceState(null, '', url)
}
}, [currentMapKey, allMaps, bestOf, isPickBanPhase]) // ← Dependencies
}, [currentMapKey, allMaps, bestOf, isPickBanPhase, activeMapIdx])
// Löschen
const handleDelete = async () => {
@ -896,7 +912,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<div className="min-w-0">
<div className="flex items-center gap-3">
{isCommunity && match.teamA?.logo && (
<img
<Image
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
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"
@ -936,7 +952,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
</div>
</div>
{isCommunity && match.teamB?.logo && (
<img
<Image
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
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"
@ -1037,7 +1053,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<MiniPlayerCard
open={!!hoverPlayer}
player={hoverPlayer}
anchor={null}
onClose={() => {
// Nicht schließen, wenn entweder Anchor ODER die Card selbst gerade gehovert ist
const anchorHovered = !!anchorEl?.matches(':hover')

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/MatchPlayerCard.tsx
import Table from './Table'
import Image from 'next/image'
import { MatchPlayer } from '../../../types/match'
@ -6,7 +8,32 @@ type Props = {
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) {
const adr = getAdr(player.stats)
const hsPercent = getHsPercent(player.stats)
return (
<Table.Row>
<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?.deaths ?? '-'}</Table.Cell>
<Table.Cell hoverable>{player.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell hoverable>{(player.stats as any)?.adr ?? '-'}</Table.Cell>
<Table.Cell hoverable>{(player.stats as any)?.adr ?? '-'}</Table.Cell>
<Table.Cell hoverable>{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">
<button

View File

@ -1,4 +1,4 @@
// MatchReadyOverlay.tsx
// /src/app/[locale]/components/MatchReadyOverlay.tsx
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -7,6 +7,7 @@ import { useSSEStore } from '@/lib/useSSEStore'
import { useSession } from 'next-auth/react'
import LoadingSpinner from './LoadingSpinner'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import Image from 'next/image'
type Props = {
open: boolean
@ -22,6 +23,27 @@ type Props = {
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) {
const sec = Math.max(0, Math.ceil(ms / 1000))
const m = Math.floor(sec / 60)
@ -78,7 +100,6 @@ export default function MatchReadyOverlay({
const shouldRender = Boolean(isVisibleBase && iAmAllowed)
// Ready-Status
type Participant = { steamId: string; name: string; avatar: string; team: 'A' | 'B' | null }
const [participants, setParticipants] = useState<Participant[]>([])
const [readyMap, setReadyMap] = useState<Record<string, string>>({})
const [total, setTotal] = useState(0)
@ -90,36 +111,59 @@ export default function MatchReadyOverlay({
// ----- AUDIO -----
const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null)
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 {
if (typeof (sound as any).ensureUnlocked === 'function') { await (sound as any).ensureUnlocked(); return true }
if (typeof (sound as any).unlock === 'function') { await (sound as any).unlock(); return true }
const ctx = (sound as any).ctx || (sound as any).audioContext
if (typeof S.ensureUnlocked === 'function') { await S.ensureUnlocked(); return true }
if (typeof S.unlock === 'function') { await S.unlock(); return true }
// 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()
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(() => {
if (finished) return
stopBeeps()
setFinished(true)
setConnecting(true)
try { sound.play('loading') } catch {}
try { (sound as unknown as MaybeSound).play?.('loading') } catch {}
const doConnect = () => {
try { window.location.href = effectiveConnectHref }
@ -134,7 +178,7 @@ export default function MatchReadyOverlay({
}
try { onTimeout?.() } catch {}
}
setTimeout(doConnect, 200)
window.setTimeout(doConnect, 200)
}, [finished, effectiveConnectHref, onTimeout])
// Ready-API nur nach Accept
@ -142,8 +186,8 @@ export default function MatchReadyOverlay({
try {
const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' })
if (!r.ok) return
const j = await r.json()
const parts: Participant[] = j.participants ?? []
const j = (await r.json()) as ReadyResponse
const parts: Participant[] = Array.isArray(j.participants) ? j.participants : []
setParticipants(parts)
setReadyMap(j.ready ?? {})
setTotal(j.total ?? 0)
@ -152,7 +196,9 @@ export default function MatchReadyOverlay({
// Team-Guard füttern
const ids = parts.map(p => String(p.steamId)).filter(Boolean)
if (ids.length) setAllowedIds(ids)
} catch {}
} catch {
// ignore
}
}, [matchId])
// Accept
@ -178,7 +224,7 @@ export default function MatchReadyOverlay({
}
}
// „Es lädt nicht?“ nach 30s
// „Es lädt nicht?“ nach 10s
useEffect(() => {
let id: number | null = null
if (connecting) {
@ -192,26 +238,39 @@ export default function MatchReadyOverlay({
useEffect(() => {
if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return }
setShowBackdrop(true)
const id = setTimeout(() => setShowContent(true), 300)
return () => clearTimeout(id)
const id = window.setTimeout(() => setShowContent(true), 300)
return () => window.clearTimeout(id)
}, [shouldRender])
// Nach Accept kurzer Refresh
useEffect(() => {
if (!accepted) return
const id = setTimeout(loadReady, 250)
return () => clearTimeout(id)
const id = window.setTimeout(loadReady, 250)
return () => window.clearTimeout(id)
}, [accepted, loadReady])
// SSE
useEffect(() => {
if (!lastEvent) return
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
const payload = (lastEvent as any).payload?.payload ?? (lastEvent as any).payload ?? lastEvent
const ev = lastEvent as unknown as SseEventLoose
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)
const payloadParticipants: string[] | undefined = Array.isArray(payload?.participants)
? payload.participants.map((sid: any) => String(sid)).filter(Boolean)
const rawParticipants = payload?.participants
const payloadParticipants: string[] | undefined = Array.isArray(rawParticipants)
? (rawParticipants as unknown[]).map(sid => String(sid)).filter(Boolean)
: undefined
if (payloadParticipants && payloadParticipants.length) {
setAllowedIds(payloadParticipants)
@ -219,21 +278,26 @@ export default function MatchReadyOverlay({
if (type === 'ready-updated' && payload?.matchId === matchId) {
if (accepted) {
const otherSteamId = payload?.steamId as string | undefined
const otherSteamId = (payload?.steamId as string | undefined) ?? undefined
if (otherSteamId && otherSteamId !== mySteamId) playMenuAccept()
}
loadReady()
void loadReady()
return
}
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
if (steamId && (status === 'online' || status === 'away' || status === 'offline')) {
setStatusMap(prev => (prev[steamId] === status ? prev : { ...prev, [steamId]: status }))
}
}
}, [accepted, lastEvent, matchId, mySteamId, loadReady])
}, [accepted, lastEvent, matchId, mySteamId, loadReady, playMenuAccept])
// Mount-Animation
const [fadeIn, setFadeIn] = useState(false)
@ -269,8 +333,8 @@ export default function MatchReadyOverlay({
window.addEventListener('keydown', once, { once: true })
}
}
const id = setTimeout(tryPlay, 0)
return () => clearTimeout(id)
const id = window.setTimeout(tryPlay, 0)
return () => window.clearTimeout(id)
}, [shouldRender, forceGif, prefersReducedMotion])
useEffect(() => {
@ -311,7 +375,7 @@ export default function MatchReadyOverlay({
})()
return () => { cleanup(); stopBeeps() }
}, [showContent])
}, [showContent, ensureAudioUnlocked, startBeeps])
// Auto-Connect wenn alle bereit
useEffect(() => {
@ -342,7 +406,7 @@ export default function MatchReadyOverlay({
}
rafRef.current = requestAnimationFrame(step)
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
}, [shouldRender, effectiveDeadline, accepted, finished, onTimeout, startConnectingNow])
}, [shouldRender, effectiveDeadline, accepted, finished, startConnectingNow])
// Map-Icon
const mapIconUrl = useMemo(() => {
@ -383,19 +447,21 @@ export default function MatchReadyOverlay({
>
{p ? (
<>
<img
<Image
src={p.avatar}
alt={p.name}
fill
sizes="36px"
className={[
'w-full h-full object-cover rounded-[2px] transition-opacity',
isReady ? '' : 'opacity-40 filter grayscale'
'object-cover rounded-[2px] transition-opacity',
isReady ? '' : 'opacity-40 grayscale'
].join(' ')}
/>
{!isReady && <div className="absolute inset-0 bg-black/30 pointer-events-none" />}
</>
) : (
<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" />
</svg>
</div>
@ -433,21 +499,26 @@ export default function MatchReadyOverlay({
].join(' ')}
>
{/* Map */}
<img
<Image
src={mapBg}
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 */}
{useGif ? (
<div className="absolute inset-0 opacity-50 pointer-events-none">
<img
<Image
src="/assets/vids/overlay_cs2_accept.webp"
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"
loading="eager"
priority
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
</div>
@ -477,7 +548,7 @@ export default function MatchReadyOverlay({
{/* Icon + Label */}
<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>
</div>

View File

@ -1,11 +1,10 @@
// MiniCard.tsx
// /src/app/[locale]/components/MiniCard.tsx
'use client'
import Button from './Button'
import Image from 'next/image'
import PremierRankBadge from './PremierRankBadge'
import { motion, AnimatePresence } from 'framer-motion'
import UserAvatarWithStatus from './UserAvatarWithStatus'
import React from 'react'
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
@ -22,7 +21,8 @@ type MiniCardProps = {
teamLeaderSteamId?: string | null
location?: string
rank?: number
dragListeners?: any
/** optionale Event-/DOM-Attribute (z. B. von dnd-kit: listeners/attributes) */
dragListeners?: React.HTMLAttributes<HTMLDivElement>
hoverEffect?: boolean
onPromote?: (steamId: string) => void
hideActions?: boolean
@ -42,10 +42,8 @@ export default function MiniCard({
onSelect,
onKick,
isLeader = false,
draggable,
currentUserSteamId,
teamLeaderSteamId,
location,
rank,
dragListeners,
hoverEffect = false,
@ -56,25 +54,26 @@ export default function MiniCard({
isAdmin = false,
invitedStatus,
isInvite = false,
invitationId
}: MiniCardProps) {
//const isSelectable = typeof onSelect === 'function'
const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
const canEdit =
(isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
const statusBg =
invitedStatus === 'sent' ? 'bg-green-500 dark:bg-green-700' :
invitedStatus === 'added' ? 'bg-teal-500 dark:bg-teal-700' :
invitedStatus === 'failed' ? 'bg-red-500 dark:bg-red-700' :
invitedStatus === 'pending' ? 'bg-yellow-500 dark:bg-yellow-700':
'bg-white dark:bg-neutral-800'
invitedStatus === 'sent'
? 'bg-green-500 dark:bg-green-700'
: invitedStatus === 'added'
? 'bg-teal-500 dark:bg-teal-700'
: 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
? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400'
: invitedStatus
? 'border-transparent' // kein grüner/roter Rand, Fokus liegt auf dem BG
: 'border-gray-200 dark:border-neutral-700'
const baseBorder = selected
? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400'
: invitedStatus
? 'border-transparent'
: 'border-gray-200 dark:border-neutral-700'
const cardClasses = `
relative flex flex-col items-center p-4 border rounded-lg transition
@ -112,26 +111,47 @@ export default function MiniCard({
}
const statusLabel =
invitedStatus === 'sent' ? 'Eingeladen' :
invitedStatus === 'added' ? 'Hinzugefügt' :
invitedStatus === 'failed'? 'Fehlgeschlagen' :
invitedStatus === 'pending'? 'Wird gesendet…' : null
invitedStatus === 'sent'
? 'Eingeladen'
: invitedStatus === 'added'
? 'Hinzugefügt'
: invitedStatus === 'failed'
? 'Fehlgeschlagen'
: invitedStatus === 'pending'
? 'Wird gesendet…'
: null
const statusPillClasses =
invitedStatus === 'sent' ? 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-200' :
invitedStatus === 'added' ? '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': '';
invitedStatus === 'sent'
? 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-200'
: invitedStatus === 'added'
? '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 (
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
{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 ${
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>
<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'
}`}
>
<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}>
<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>
{typeof onPromote === 'function' && (
<div className="pointer-events-auto" onPointerDown={stopDrag}>
@ -166,14 +186,6 @@ export default function MiniCard({
) : (
<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>
)

View File

@ -2,6 +2,7 @@
'use client'
import { useDroppable } from "@dnd-kit/core"
import Image from "next/image"
type MiniCardDummyProps = {
title: string
@ -11,7 +12,7 @@ type MiniCardDummyProps = {
}
export default function MiniCardDummy({ title, onClick, children, zoneId }: MiniCardDummyProps) {
const { setNodeRef, isOver } = useDroppable({
const { setNodeRef } = useDroppable({
id: `${zoneId ?? 'dummy'}-drop`,
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
) : (
<img
<Image
src="https://via.placeholder.com/64x64.png?text=+"
alt="Dummy Avatar"
className="w-16 h-16 object-cover"

View File

@ -2,10 +2,18 @@
'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 { useRouter } from '@/i18n/navigation'
import Link from 'next/link'
import Image from 'next/image'
import type { MatchPlayer } from '../../../types/match'
import PremierRankBadge from './PremierRankBadge'
import FaceitLevelImage from './FaceitLevelBadge'
@ -13,25 +21,42 @@ import FaceitLevelImage from './FaceitLevelBadge'
export type MiniPlayerCardProps = {
open: boolean
player: MatchPlayer
anchor: DOMRect | null
onClose?: () => void
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
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 = {
steamId?: string | null
name?: string | null
avatar?: string | null
premierRank?: number | null
// flache Ban-Felder (manche APIs liefern das so)
vacBanned?: boolean | null
numberOfVACBans?: number | null
numberOfGameBans?: number | null
communityBanned?: boolean | null
economyBan?: string | null
daysSinceLastBan?: number | null
// oder geschachtelt:
banStatus?: BanStatus
// Faceit
faceitNickname?: string | null
faceitUrl?: string | null
faceitLevel?: number | null
@ -56,10 +81,17 @@ export type PlayerSummary = {
}
function Sparkline({ values }: { values: number[] }) {
const W = 200, 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 W = 200,
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 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 (
<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" />
@ -68,12 +100,22 @@ function Sparkline({ values }: { values: number[] }) {
}
export default function MiniPlayerCard({
open, player, anchor, onClose, prefetchedSummary, prefetchedFaceit, anchorEl, onCardMount
open,
player,
onClose,
prefetchedSummary,
prefetchedFaceit,
anchorEl,
onCardMount,
}: MiniPlayerCardProps) {
const router = useRouter()
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)
// Hover-Intent
@ -88,14 +130,19 @@ export default function MiniPlayerCard({
// Summary nur aus Prefetch (kein Fetch)
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
const faceit = useMemo<FaceitState>(() => {
const url =
prefetchedFaceit?.url
?? (u.faceitUrl ? u.faceitUrl.replace('{lang}', 'en')
: (u.faceitNickname ? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}` : null))
prefetchedFaceit?.url ??
(u.faceitUrl
? u.faceitUrl.replace('{lang}', 'en')
: u.faceitNickname
? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}`
: null)
return {
level: prefetchedFaceit?.level ?? u.faceitLevel ?? null,
@ -111,11 +158,12 @@ export default function MiniPlayerCard({
}
// Positionierung
const doPosition = () => {
const doPosition = useCallback(() => {
if (!cardRef.current || !anchorEl) return
const a = anchorEl.getBoundingClientRect()
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 rightLeft = a.right
@ -130,13 +178,18 @@ export default function MiniPlayerCard({
setPos({ top: Math.round(top), left: Math.round(left), side })
setMeasured(true)
}
}, [anchorEl])
const schedule = () => requestAnimationFrame(() => requestAnimationFrame(doPosition))
const schedule = useCallback(() => {
requestAnimationFrame(() => requestAnimationFrame(doPosition))
}, [doPosition])
useLayoutEffect(() => {
if (open) { setMeasured(false); schedule() }
}, [open, anchorEl])
if (open) {
setMeasured(false)
schedule()
}
}, [open, anchorEl, schedule])
useEffect(() => {
if (!open) return
@ -155,7 +208,7 @@ export default function MiniPlayerCard({
window.removeEventListener('resize', onScrollOrResize)
ro?.disconnect()
}
}, [open, anchorEl])
}, [open, anchorEl, schedule])
// Bei Spielerwechsel sanft neu einmessen
useEffect(() => {
@ -166,7 +219,7 @@ export default function MiniPlayerCard({
}
setMeasured(false)
schedule()
}, [open, anchorEl, player.user?.steamId])
}, [open, anchorEl, player.user?.steamId, schedule])
// Anchor-Hover steuert Open/Close
useEffect(() => {
@ -174,21 +227,32 @@ export default function MiniPlayerCard({
const armClose = () => {
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 = () => {
if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null }
if (closeT.current) {
window.clearTimeout(closeT.current)
closeT.current = null
}
}
const onEnter = () => {
disarmClose()
if (!open && !openT.current) {
openT.current = window.setTimeout(() => { openT.current = null }, OPEN_DELAY)
openT.current = window.setTimeout(() => {
openT.current = null
}, OPEN_DELAY)
}
}
const onLeave = () => {
if (openT.current) { window.clearTimeout(openT.current); openT.current = null }
if (openT.current) {
window.clearTimeout(openT.current)
openT.current = null
}
armClose()
}
@ -205,24 +269,31 @@ export default function MiniPlayerCard({
}
}, [anchorEl, open, onClose])
// BAN-Badges
const nestedBan = (player.user as any)?.banStatus
// BAN-Badges (verschachtelt oder flach)
const nestedBan: BanStatus | undefined =
(player.user as { banStatus?: BanStatus } | undefined)?.banStatus
const flat = u
const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0)
const isBannedNested =
!!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 ||
(nestedBan?.numberOfGameBans ?? 0) > 0 || nestedBan?.communityBanned ||
(nestedBan?.economyBan && nestedBan.economyBan !== 'none'))
const isBannedNested = !!(
nestedBan?.vacBanned ||
(nestedBan?.numberOfVACBans ?? 0) > 0 ||
(nestedBan?.numberOfGameBans ?? 0) > 0 ||
nestedBan?.communityBanned ||
(nestedBan?.economyBan && nestedBan.economyBan !== 'none')
)
const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0
const isBannedFlat =
!!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 || (flat.numberOfGameBans ?? 0) > 0 ||
!!flat.communityBanned || (!!flat.economyBan && flat.economyBan !== 'none')
!!flat.vacBanned ||
(flat.numberOfVACBans ?? 0) > 0 ||
(flat.numberOfGameBans ?? 0) > 0 ||
!!flat.communityBanned ||
(!!flat.economyBan && flat.economyBan !== 'none')
const hasVac = nestedBan ? hasVacNested : hasVacFlat
const isBanned = nestedBan ? isBannedNested : isBannedFlat
const banTooltip = useMemo(() => {
const parts: string[] = []
const src = nestedBan ?? flat
const src = (nestedBan ?? flat) as Required<BanStatus>
if (src.vacBanned) parts.push('VAC-Ban aktiv')
if ((src.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${src.numberOfVACBans}`)
if ((src.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${src.numberOfGameBans}`)
@ -244,10 +315,18 @@ export default function MiniPlayerCard({
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"
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={() => {
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 */}
<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"
>
{/* Links: Avatar + Name + Badges */}
@ -276,11 +357,9 @@ export default function MiniPlayerCard({
/>
<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">
<span className="truncate text-sm font-semibold">
{u.name ?? 'Unbekannt'}
</span>
<span className="truncate text-sm font-semibold">{u.name ?? 'Unbekannt'}</span>
{isBanned && (
<span
title={banTooltip}
@ -292,7 +371,7 @@ export default function MiniPlayerCard({
)}
</div>
{/* darunter: Premier + Faceit (unverändert) */}
{/* darunter: Premier + Faceit */}
<div className="mt-2 flex items-center gap-2">
<PremierRankBadge rank={u.premierRank ?? 0} />
{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}` : ''}`}
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>
)}
</div>
@ -346,10 +425,16 @@ export default function MiniPlayerCard({
<div
className={[
'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(' ')}
>
{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 className="mt-1">

View File

@ -1,4 +1,4 @@
// /src/app/components/Modal.tsx
// /src/app/[locale]/components/Modal.tsx
'use client'
import { useEffect } from 'react'
@ -29,6 +29,19 @@ type ModalProps = {
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({
id,
title,
@ -44,11 +57,16 @@ export default function Modal({
scrollBody = true,
}: ModalProps) {
useEffect(() => {
const modalEl = document.getElementById(id)
const hs = (window as any).HSOverlay
const modalEl = document.getElementById(id) as HTMLElement | null
// 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
const getCollection = (): any[] =>
const getCollection = (): HSOverlayInstance[] =>
Array.isArray(hs.collection) ? hs.collection : []
const destroyIfExists = () => {
@ -60,7 +78,7 @@ export default function Modal({
}
const handleClose = () => onClose?.()
modalEl.addEventListener('hsOverlay:close', handleClose)
modalEl.addEventListener('hsOverlay:close', handleClose as EventListener)
try {
if (show) {
@ -71,14 +89,16 @@ export default function Modal({
hs.close?.(modalEl)
destroyIfExists()
}
} catch (err) {}
} catch {
// noop
}
return () => {
modalEl.removeEventListener('hsOverlay:close', handleClose)
modalEl.removeEventListener('hsOverlay:close', handleClose as EventListener)
destroyIfExists()
// Fallback: Globale Backdrops wegräumen, falls die Lib zickt
document.querySelectorAll('.hs-overlay-backdrop')?.forEach(el => el.remove())
document.body.classList.remove('overflow-hidden','[&.hs-overlay-open]') // je nach Lib-Version
document.querySelectorAll('.hs-overlay-backdrop')?.forEach((el) => el.remove())
document.body.classList.remove('overflow-hidden', '[&.hs-overlay-open]')
}
}, [show, id, onClose])
@ -93,8 +113,8 @@ export default function Modal({
if (e.target === e.currentTarget) onClose?.()
}}
className={
"hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden " +
(show ? "" : "pointer-events-none")
'hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden ' +
(show ? '' : 'pointer-events-none')
}
>
{/* 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">
{!hideCloseButton && (
<Button
size='sm'
size="sm"
data-hs-overlay={`#${id}`}
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"
@ -151,7 +171,7 @@ export default function Modal({
{onSave && (
<Button
size='sm'
size="sm"
onClick={onSave}
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`}

View File

@ -14,6 +14,8 @@ type Props = {
initialInvitationMap: Record<string, string>
}
type SsePayloadLoose = Record<string, unknown>;
/* helpers */
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
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
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) => {
if (a.id !== b.id) return false
if ((a.name ?? '') !== (b.name ?? '')) 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
return (
eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
)
}
const eqTeamList = (a: Team[], b: Team[]) => {
if (a.length !== b.length) return false
const mapA = new Map(a.map(t => [t.id, t]))
@ -41,9 +53,10 @@ const eqTeamList = (a: Team[], b: Team[]) => {
}
return true
}
function parseTeamsResponse(raw: any): Team[] {
function parseTeamsResponse(raw: unknown): 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 []
}
@ -64,13 +77,20 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
fetch('/api/user/invitations', { cache: 'no-store' }),
])
if (!teamRes.ok || !invitesRes.ok) return
const rawTeams = await teamRes.json()
const rawInv = await invitesRes.json()
const rawTeams: unknown = await teamRes.json()
const rawInv: unknown = await invitesRes.json()
const nextTeams: Team[] = parseTeamsResponse(rawTeams)
const mapping: Record<string, string> = {}
for (const inv of rawInv?.invitations || []) {
if (inv.type === 'team-join-request') mapping[inv.teamId] = inv.id
if (isRecord(rawInv) && Array.isArray(rawInv.invitations)) {
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))
@ -92,7 +112,8 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
useEffect(() => {
// Nur nachladen, falls keine Initialdaten übergeben wurden
if (!initialTeams?.length) fetchTeamsAndInvitations()
}, [])
// Warnung beheben: explizit auf die Länge hören
}, [initialTeams?.length])
const teamsRef = useRef(teams)
useEffect(() => { teamsRef.current = teams }, [teams])
@ -103,19 +124,25 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
if (!lastEvent) return
// Signatur: Typ + die paar Payload-Felder, die sich bei uns ändern
const p = (lastEvent.payload ?? {}) as SsePayloadLoose;
const sig = JSON.stringify({
t: lastEvent.type,
tid: lastEvent.payload?.teamId ?? null,
jp: lastEvent.payload?.joinPolicy ?? null,
f: lastEvent.payload?.filename ?? null,
v: lastEvent.payload?.version ?? null,
})
tid: typeof p.teamId === 'string' ? p.teamId : null,
jp: typeof p.joinPolicy === 'string' ? p.joinPolicy : null,
f: typeof p.filename === 'string' ? p.filename : null,
v: typeof p.version === 'string' ? p.version : null,
});
if (lastSigRef.current === sig) return
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 (!payload?.teamId || teamsRef.current.some(t => t.id === payload.teamId)) {
if (!teamId || teamsRef.current.some(t => t.id === teamId)) {
fetchTeamsAndInvitations()
}
return
@ -127,7 +154,8 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
const visibleTeams = useMemo(() => {
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') {
list.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', 'de', { sensitivity: 'base' }))
} else {
@ -168,7 +196,7 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
<div className="flex items-center gap-2">
<select
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"
>
<option value="name-asc">Sortieren: Name (AZ)</option>
@ -212,12 +240,14 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
team={team}
currentUserSteamId={currentSteamId}
invitationId={teamToInvitationId[team.id]}
onUpdateInvitation={(teamId, newValue) => {
onUpdateInvitation={(teamId: string, newValue: string | null) => {
setTeamToInvitationId(prev => {
const updated = { ...prev }
if (!newValue) delete updated[teamId]
else if (newValue === 'pending') updated[teamId] = updated[teamId] ?? 'pending'
else updated[teamId] = newValue
const updated: Record<string, string> = { ...prev }
if (newValue === null) {
delete updated[teamId]
} else {
updated[teamId] = newValue
}
return updated
})
}}

View File

@ -7,7 +7,7 @@ import NotificationCenter from './NotificationCenter'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useSSEStore } from '@/lib/useSSEStore'
import { NOTIFICATION_EVENTS, isSseEventType } from '@/lib/sseEvents'
import { NOTIFICATION_EVENTS } from '@/lib/sseEvents'
import { useGameBannerStore } from '@/lib/useGameBannerStore'
type Notification = {
@ -23,22 +23,43 @@ type ActionData =
| { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
| { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
// --- API Helper ---
async function apiJSON(url: string, body?: any, method = 'POST') {
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
async function apiJSON<T = unknown>(url: string, body?: unknown, method: HttpMethod = 'POST'): Promise<T> {
const res = await fetch(url, {
method,
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))
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() {
const { data: session } = useSession()
const router = useRouter()
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 [notifications, setNotifications] = useState<Notification[]>([])
@ -46,40 +67,10 @@ export default function NotificationBell() {
const [previewText, setPreviewText] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [animateBell, setAnimateBell] = useState(false)
const baseBottom = 24 // px, entspricht bottom-6
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
useEffect(() => {
const steamId = session?.user?.steamId
@ -88,8 +79,8 @@ export default function NotificationBell() {
try {
const res = await fetch('/api/notifications')
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
const loaded: Notification[] = data.notifications.map((n: any) => ({
const data: NotificationsResponse = await res.json()
const loaded: Notification[] = data.notifications.map((n: ApiNotification) => ({
id: n.id,
text: n.message,
read: n.read,
@ -106,11 +97,16 @@ export default function NotificationBell() {
// 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
useEffect(() => {
if (!lastEvent) return
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
if (!lastEvent) return;
const data = lastEvent.payload
if (data?.type === 'heartbeat') return
// optional: nur Events, die dein Set kennt
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 = {
id: data?.id ?? crypto.randomUUID(),
@ -119,11 +115,11 @@ export default function NotificationBell() {
actionType: data?.actionType,
actionData: data?.actionData,
createdAt: data?.createdAt ?? new Date().toISOString(),
}
};
setNotifications(prev => [newNotification, ...prev])
setPreviewText(newNotification.text) // <-- nur das hier
}, [lastEvent])
setNotifications(prev => [newNotification, ...prev]);
setPreviewText(newNotification.text);
}, [lastEvent]);
// 2) Timer separat steuern: triggert bei neuem previewText
useEffect(() => {
@ -135,8 +131,8 @@ export default function NotificationBell() {
const PREVIEW_MS = 5000
const CLEAR_DELAY = 300
const tHide = window.setTimeout(() => setShowPreview(false), PREVIEW_MS)
const tBell = window.setTimeout(() => setAnimateBell(false), PREVIEW_MS)
const tHide = window.setTimeout(() => setShowPreview(false), PREVIEW_MS)
const tBell = window.setTimeout(() => setAnimateBell(false), PREVIEW_MS)
const tClear = window.setTimeout(() => setPreviewText(null), PREVIEW_MS + CLEAR_DELAY)
return () => {
@ -146,7 +142,6 @@ export default function NotificationBell() {
}
}, [previewText])
// 3) Actions
const markAllAsRead = async () => {
await apiJSON('/api/notifications/mark-all-read', undefined, 'POST')
@ -155,9 +150,7 @@ export default function NotificationBell() {
const markOneAsRead = async (notificationId: string) => {
await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST')
setNotifications(prev =>
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)),
)
setNotifications(prev => prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)))
}
const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => {
@ -174,21 +167,19 @@ export default function NotificationBell() {
return
}
// actionData parsen: erlaubt JSON {kind, inviteId/requestId, teamId} ODER nackte ID
let kind: 'invite' | 'join-request' | undefined
// actionData parsen: erlaubt JSON ActionData ODER nackte ID
let kind: ActionData['kind'] | undefined
let invitationId: string | undefined
let requestId: string | undefined
let teamId: string | undefined
try {
const data = JSON.parse(n.actionData) as
| { kind?: 'invite' | 'join-request'; inviteId?: string; requestId?: string; teamId?: string }
| string
const data = JSON.parse(n.actionData) as ActionData | string
if (typeof data === 'object' && data) {
kind = data.kind
invitationId = data.inviteId
requestId = data.requestId
if (data.kind === 'invite') invitationId = data.inviteId
if (data.kind === 'join-request') requestId = data.requestId
teamId = data.teamId
} else if (typeof data === 'string') {
// nackte ID: sowohl als invitationId als auch requestId nutzbar
@ -217,16 +208,11 @@ export default function NotificationBell() {
// Optimistic Update (Buttons ausblenden)
const snapshot = notifications
setNotifications(prev =>
prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)),
)
setNotifications(prev => prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)))
try {
if (kind === 'invite') {
await apiJSON(`/api/user/invitations/${action}`, {
invitationId,
teamId,
})
await apiJSON('/api/user/invitations/' + action, { invitationId, teamId })
setNotifications(prev => prev.filter(x => x.id !== n.id))
if (action === 'accept') router.refresh()
return
@ -251,11 +237,10 @@ export default function NotificationBell() {
}
}
const onNotificationClick = (notification: Notification) => {
if (!notification.actionData) return
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)
} catch (err) {
console.error('[NotificationBell] Ungültige actionData:', err)
@ -264,32 +249,32 @@ export default function NotificationBell() {
// 4) Render
return (
<div
className="fixed right-6 z-50"
style={{ bottom: bottomPx }}
>
<div className="fixed right-6 z-50" style={{ bottom: bottomPx }}>
<button
ref={bellRef}
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)}
className={`relative flex items-center transition-all duration-300 ease-in-out
${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
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
>
{previewText && (
<span className="truncate text-sm text-gray-800 dark:text-white">
{previewText}
</span>
)}
{previewText && <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">
<svg
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}
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" />
<path
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>
{notifications.some(n => !n.read) && (
<span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">

View File

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

View File

@ -1,7 +1,8 @@
// /src/app/[locale]/components/ReadyOverlayHost.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/lib/useSSEStore'
import MatchReadyOverlay from './MatchReadyOverlay'
@ -16,11 +17,12 @@ async function getConnectHref(matchId?: string): Promise<string | null> {
try {
const qs = matchId ? `?matchId=${encodeURIComponent(matchId)}` : ''
const r = await fetch(`/api/cs2/server${qs}`, { cache: 'no-store' })
if (!r.ok) {
return null
}
const j = await r.json()
const href: string | undefined = j?.connectHref
if (!r.ok) return null
const j = (await r.json()) as unknown
const href =
(j && typeof j === 'object' && 'connectHref' in j
? (j as Record<string, unknown>).connectHref
: undefined) as string | undefined
if (href) CONNECT_CACHE.set(matchId, href)
return href ?? null
} 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() {
const router = useRouter()
const { data: session } = useSession()
const mySteamId = session?.user?.steamId ?? null
const mySteamId = (session?.user as { steamId?: string } | undefined)?.steamId ?? null
const { lastEvent } = useSSEStore()
const { open, data, showWithDelay, hide } = useReadyOverlayStore()
const setRoster = useMatchRosterStore(s => s.setRoster) // ⬅️ neu
const clearRoster = useMatchRosterStore(s => s.clearRoster) // ⬅️ neu
const setRoster = useMatchRosterStore(s => s.setRoster)
const clearRoster = useMatchRosterStore(s => s.clearRoster)
const isAccepted = (matchId: string) =>
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'
useEffect(() => {
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') {
(async () => {
const m: string | undefined = evt?.matchId
const participants: string[] = evt?.participants ?? []
if (evt.type === 'match-ready') {
;(async () => {
const p = isObject(payload) ? (payload as MatchReadyPayload) : {}
const m = p.matchId
const participants = isStringArray(p.participants) ? p.participants : []
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
setRoster(participants) // ✅ wird jetzt sicher gesetzt
setRoster(participants)
const label = evt?.firstMap?.label ?? '?'
const bg = evt?.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
const label = p.firstMap?.label ?? '?'
const bg = p.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
const connectHref =
(await getConnectHref(m)) ||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
null
(await getConnectHref(m)) || process.env.NEXT_PUBLIC_CONNECT_HREF || null
showWithDelay(
{
@ -117,9 +159,9 @@ export default function ReadyOverlayHost() {
return
}
if (lastEvent.type === 'map-vote-updated') {
(async () => {
const summary = deriveReadySummary(evt) // evt statt lastEvent.payload
if (evt.type === 'map-vote-updated') {
;(async () => {
const summary = deriveReadySummary(payload)
if (!summary) return
const { matchId: m, firstMap, participants } = summary
if (!participants.includes(mySteamId) || isAccepted(m)) return
@ -127,9 +169,7 @@ export default function ReadyOverlayHost() {
setRoster(participants)
const connectHref =
(await getConnectHref(m)) ||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
null
(await getConnectHref(m)) || process.env.NEXT_PUBLIC_CONNECT_HREF || null
showWithDelay(
{
@ -149,11 +189,11 @@ export default function ReadyOverlayHost() {
useEffect(() => {
if (!lastEvent) return
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') {
window.localStorage.removeItem(`match:${m}:readyAccepted`)
}
clearRoster() // ⬅️ neu
clearRoster()
if (open) hide()
}
}, [lastEvent, open, hide, clearRoster])

View File

@ -1,5 +1,6 @@
// /src/app/[locale]/components/ScrollSpyTabs.tsx
'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 }
@ -14,6 +15,14 @@ type Props = {
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({
items,
containerRef,
@ -29,20 +38,17 @@ export default function ScrollSpyTabs({
const isProgrammaticRef = useRef(false)
const progTimerRef = useRef<number | null>(null)
const setActive = (id: string) => {
if (id && id !== activeId) {
setActiveId(id)
if (updateHash) history.replaceState(null, '', `#${id}`)
}
}
const setActive = useCallback(
(id: string) => {
if (id && id !== activeId) {
setActiveId(id)
if (updateHash) history.replaceState(null, '', `#${id}`)
}
},
[activeId, updateHash]
)
// sichere Query
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 -------- */
/* -------- IntersectionObserver -------- */
useEffect(() => {
const rootEl = containerRef?.current ?? null
const rootNode: Document | HTMLElement = rootEl ?? document
@ -55,23 +61,24 @@ export default function ScrollSpyTabs({
() => {
if (isProgrammaticRef.current) return
// ⬇️ Top/Bottom bevorzugen
const firstId = items[0]?.id
const lastId = items[items.length - 1]?.id
const lastId = items[items.length - 1]?.id
const EPS = 1
if (rootEl) {
const atTop = rootEl.scrollTop <= EPS
const atBottom = Math.ceil(rootEl.scrollTop + rootEl.clientHeight) >= rootEl.scrollHeight - EPS
if (atTop && firstId) { setActive(firstId); return }
if (atBottom && lastId){ setActive(lastId); return }
const atTop = rootEl.scrollTop <= EPS
const atBottom =
Math.ceil(rootEl.scrollTop + rootEl.clientHeight) >= rootEl.scrollHeight - EPS
if (atTop && firstId) return setActive(firstId)
if (atBottom && lastId) return setActive(lastId)
} else {
const atTop = window.scrollY <= EPS
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - EPS
if (atTop && firstId) { setActive(firstId); return }
if (atBottom && lastId){ setActive(lastId); return }
const atTop = window.scrollY <= EPS
const atBottom =
Math.ceil(window.scrollY + window.innerHeight) >=
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 targetLine = rootTop + offset + 1
@ -93,15 +100,14 @@ export default function ScrollSpyTabs({
sections.forEach(s => observerRef.current!.observe(s))
return () => observerRef.current?.disconnect()
// ⬇️ WICHTIG: auf das ELEMENt hören, nicht nur auf das Ref-Objekt
}, [containerRef?.current, items, offset, updateHash])
}, [containerRef, items, offset, setActive])
/* -------- NEU: Kanten-Logik (Top/Bottom bevorzugen) -------- */
/* -------- Kanten-Logik (Top/Bottom bevorzugen) -------- */
useEffect(() => {
const el = containerRef?.current
const target: any = el ?? window
const target: Window | HTMLElement = el ?? window
const firstId = items[0]?.id
const lastId = items[items.length - 1]?.id
const lastId = items[items.length - 1]?.id
const EPS = 1
const onScrollOrResize = () => {
@ -109,28 +115,30 @@ export default function ScrollSpyTabs({
if (!firstId || !lastId) return
if (el) {
const atTop = el.scrollTop <= EPS
const atBottom = Math.ceil(el.scrollTop + el.clientHeight) >= el.scrollHeight - EPS
if (atTop) return setActive(firstId)
const atTop = el.scrollTop <= EPS
const atBottom =
Math.ceil(el.scrollTop + el.clientHeight) >= el.scrollHeight - EPS
if (atTop) return setActive(firstId)
if (atBottom) return setActive(lastId)
} else {
const atTop = window.scrollY <= EPS
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - EPS
if (atTop) return setActive(firstId)
const atTop = window.scrollY <= EPS
const atBottom =
Math.ceil(window.scrollY + window.innerHeight) >=
document.documentElement.scrollHeight - EPS
if (atTop) return setActive(firstId)
if (atBottom) return setActive(lastId)
}
}
target.addEventListener('scroll', onScrollOrResize, { passive: true })
window.addEventListener('resize', onScrollOrResize, { passive: true })
target.addEventListener('scroll', onScrollOrResize, { passive: true } as AddEventListenerOptions)
window.addEventListener('resize', onScrollOrResize, { passive: true } as AddEventListenerOptions)
onScrollOrResize()
return () => {
target.removeEventListener('scroll', onScrollOrResize)
window.removeEventListener('resize', onScrollOrResize)
target.removeEventListener('scroll', onScrollOrResize as EventListener)
window.removeEventListener('resize', onScrollOrResize as EventListener)
}
// ⬇️ ebenfalls auf das Element selbst hören
}, [containerRef?.current, items])
}, [containerRef, items, setActive])
/* -------- programmatic scroll (Tabs-Klick) -------- */
const onJump = (id: string) => {
@ -161,15 +169,17 @@ export default function ScrollSpyTabs({
return (
<nav className={className} aria-label="Section navigation" role="tablist" aria-orientation="vertical">
{items.map(it => {
const isActive = activeId === it.id
const isCurrent = activeId === it.id
return (
<button
key={it.id}
type="button"
role="tab"
aria-selected={isActive}
aria-selected={isCurrent}
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}
</button>

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useMemo } from 'react'
import { useState, useMemo, useCallback } from 'react'
import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl'
import Button from './Button'
@ -23,7 +23,7 @@ export default function Sidebar() {
const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null)
// 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 =
'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))
// ✅ Locale-Wechsel: gleiche Route behalten, nur Locale ändern
const changeLocale = (nextLocale: 'en' | 'de') => {
const changeLocale = useCallback((nextLocale: 'en' | 'de') => {
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)
}
}, [router, pathname, locale])
// Gemeinsamer Inhalt (wird in Desktop-Aside und im Mobile-Drawer benutzt)
const SidebarInner = useMemo(
@ -70,10 +69,10 @@ export default function Sidebar() {
{/* Dashboard */}
<li>
<Button
onClick={() => { router.push('/dashboard'); setIsOpen(false) }}
onClick={() => { router.push('/'); setIsOpen(false) }}
size="sm"
variant="link"
className={`${navBtnBase} ${isActive('/dashboard') ? activeClasses : idleClasses}`}
className={`${navBtnBase} ${isActive('/') ? activeClasses : idleClasses}`}
aria-label={tNav('dashboard')}
>
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@ -248,7 +247,7 @@ export default function Sidebar() {
</footer>
</div>
),
[pathname, openSubmenu, locale, tNav, tSidebar]
[openSubmenu, locale, tNav, tSidebar, isActive, changeLocale, router]
)
return (

View File

@ -1,40 +1,44 @@
// /src/app/[locale]]/components/SidebarFooter.ts
// /src/app/[locale]/components/SidebarFooter.tsx
'use client'
import { useEffect, useState } from 'react'
import { useSession, signIn, signOut } from 'next-auth/react'
import { useTranslations, useLocale } from 'next-intl'
import { useSession, signIn } from 'next-auth/react'
import { useTranslations } from 'next-intl'
import { signOutWithStatus } from '@/lib/signOutWithStatus'
import { useRouter, usePathname } from 'next/navigation'
import { AnimatePresence, motion } from 'framer-motion'
import Image from 'next/image'
import LoadingSpinner from '../components/LoadingSpinner'
import Button from './Button'
import UserAvatarWithStatus from './UserAvatarWithStatus'
import PremierRankBadge from './PremierRankBadge'
type SessUser = {
steamId?: string | null
name?: string | null
image?: string | null
avatar?: string | null
isAdmin?: boolean
}
export default function SidebarFooter() {
const router = useRouter()
const pathname = usePathname()
// Übersetzungen
const tSidebar = useTranslations('sidebar')
const { data: session, status } = useSession()
const [isOpen, setIsOpen] = useState(false)
const [teamName, setTeamName] = useState<string | null>(null)
const [premierRank, setPremierRank] = useState<number>(0)
// ➜ Nach Login: User aus DB laden (inkl. premierRank & Teamname)
// Userdetails laden
useEffect(() => {
if (status !== 'authenticated') {
setTeamName(null)
setPremierRank(0) // ← immer 0, nicht null
setPremierRank(0)
return
}
(async () => {
;(async () => {
try {
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return
@ -54,7 +58,7 @@ export default function SidebarFooter() {
if (status === 'unauthenticated') {
return (
<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"
>
<i className="fab fa-steam" />
@ -63,9 +67,10 @@ export default function SidebarFooter() {
)
}
const subline = teamName ?? session?.user?.steamId
const userName = session?.user?.name || 'Profil'
const avatarSrc = (session?.user as any)?.avatar || session?.user?.image || '/default-avatar.png'
const u = session?.user as SessUser | undefined
const subline = teamName ?? u?.steamId ?? undefined
const userName = u?.name || 'Profil'
const avatarSrc = u?.avatar ?? u?.image ?? '/default-avatar.png'
const linkClass = (active: boolean) =>
`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}
alt={userName}
size={30}
steamId={session?.user?.steamId}
steamId={u?.steamId ?? undefined}
/>
<div className="ms-3 flex-1 min-w-0">
<h3 className="font-semibold text-gray-800 dark:text-white truncate">
@ -100,13 +105,11 @@ export default function SidebarFooter() {
</p>
</div>
{/* Badge darf nicht schrumpfen */}
<div className="ml-2 flex-shrink-0">
<PremierRankBadge rank={premierRank} />
</div>
</div>
{/* Pfeil ebenfalls nicht schrumpfen */}
<svg
className={`ms-2 size-4 shrink-0 ${isOpen ? 'rotate-180' : ''} text-gray-600 dark:text-neutral-400`}
xmlns="http://www.w3.org/2000/svg"
@ -130,16 +133,16 @@ export default function SidebarFooter() {
>
<div className="p-2 flex flex-col gap-1">
<Button
onClick={() => router.push(`/profile/${session?.user?.steamId}`)}
onClick={() => router.push(`/profile/${u?.steamId ?? ''}`)}
size="sm"
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"
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg>
{tSidebar('footer.profile')}
{tSidebar('footer.profile')}
</Button>
<Button
@ -152,7 +155,7 @@ export default function SidebarFooter() {
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg>
{tSidebar('footer.team')}
{tSidebar('footer.team')}
</Button>
<Button
@ -168,7 +171,7 @@ export default function SidebarFooter() {
{tSidebar('footer.settings')}
</Button>
{session?.user?.isAdmin && (
{u?.isAdmin && (
<Button
onClick={() => router.push('/admin')}
size="sm"
@ -194,7 +197,7 @@ export default function SidebarFooter() {
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/>
</svg>
{tSidebar('footer.signout')}
{tSidebar('footer.signout')}
</Button>
</div>
</motion.div>

View File

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

View File

@ -1,10 +1,10 @@
// /src/app/[locale]/components/Tabs.tsx
'use client'
import { usePathname } from 'next/navigation'
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 = {
name: string
@ -13,47 +13,47 @@ export type TabProps = {
type TabsProps = {
children: ReactNode
/** optional kontrollierter Modus */
value?: string
onChange?: (name: string) => void
/** Ausrichtung */
orientation?: 'horizontal' | 'vertical'
/** optional: Styling */
className?: string
tabClassName?: string
}
// ── add a component type that has a static Tab
type TabsComponent = FC<TabsProps> & { Tab: FC<TabProps> }
function normalize(path: string) {
if (!path) return '/'
const v = path.replace(/\/+$/, '')
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,
value,
onChange,
orientation = 'horizontal',
className = '',
tabClassName = ''
}: TabsProps) {
}) => {
const pathname = usePathname()
// Kinder in gültige Tab-Elemente filtern
const rawTabs = Array.isArray(children) ? children : [children]
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 rawTabs = Children.toArray(children);
const tabs = rawTabs.filter(isTabElement);
const isVertical = orientation === 'vertical'
const current = normalize(pathname)
// Liste aller Tab-URLs (normalisiert) für die Heuristik
const hrefs = tabs.map(t => normalize(t.props.href))
return (
@ -68,10 +68,8 @@ export function Tabs({
aria-orientation={isVertical ? 'vertical' : 'horizontal'}
>
{tabs.map((tab, index) => {
const baseClasses =
'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
const baseClasses = 'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
// Kontrollierter Modus: Auswahl über value/onChange
if (onChange && value !== undefined) {
const isActive = value === tab.props.name
return (
@ -82,9 +80,7 @@ export function Tabs({
role="tab"
aria-selected={isActive}
className={
baseClasses +
' ' +
(isActive
baseClasses + ' ' + (isActive
? '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')
}
@ -94,16 +90,9 @@ export function Tabs({
)
}
// Unkontrollierter Modus: Link-basiert
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 + '/'))
// 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 isActive = current === base || (allowStartsWith && current.startsWith(base + '/'))
return (
@ -113,9 +102,7 @@ export function Tabs({
role="tab"
aria-selected={isActive}
className={
baseClasses +
' ' +
(isActive
baseClasses + ' ' + (isActive
? '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')
}
@ -128,6 +115,8 @@ export function Tabs({
)
}
Tabs.Tab = function Tab(_props: TabProps) {
return null
}
// the dummy Tab element (for nicer JSX usage)
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'
import { useState, useMemo, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge'
import type { Team, TeamJoinPolicy } from '../../../types/team'
// ⬇️ NEU: SSE-Hooks / Type-Guard
import type { Team, TeamJoinPolicy, Player } from '../../../types/team'
import { useSSEStore } from '@/lib/useSSEStore'
import { isSseEventType } from '@/lib/sseEvents'
type Props = {
team: Team
@ -17,9 +15,30 @@ type Props = {
invitationId?: string
onUpdateInvitation: (teamId: string, newValue: string | null | 'pending') => void
adminMode?: boolean
/** Falls false, ist Anfragen grundsätzlich deaktiviert (z. B. User ist bereits in einem Team) */
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({
team,
currentUserSteamId,
@ -31,14 +50,12 @@ export default function TeamCard({
const router = useRouter()
const [joining, setJoining] = useState(false)
// ⬇️ NEU: SSE
// SSE
const { connect, lastEvent, isConnected } = useSSEStore()
// ⬇️ NEU: lokale, wirksame Policy startet mit Prop
// lokale, wirksame Policy
const [effectivePolicy, setEffectivePolicy] = useState<TeamJoinPolicy>(team.joinPolicy)
const sseWinsUntil = useRef(0)
const lastHandledKeyRef = useRef('')
const lastSeenTsRef = useRef<number | null>(null)
// SSE-Verbindung herstellen
@ -47,56 +64,57 @@ export default function TeamCard({
if (!isConnected) connect(currentUserSteamId)
}, [currentUserSteamId, isConnected, connect])
// ⬇️ Jede 'team-updated'-Änderung verarbeiten, robust entpacken, per ts deduplizieren
// team-updated verarbeiten
useEffect(() => {
const ev = lastEvent
if (!ev || ev.type !== 'team-updated') return
// payload kann entweder direkt die Felder haben … oder unter payload liegen
const p = (ev.payload && typeof ev.payload === 'object' && 'payload' in ev.payload)
? ev.payload.payload
: ev.payload
const p = extractPayloadObject(ev.payload)
const tid = typeof p.teamId === 'string' ? (p.teamId as string) : undefined
const jp = asJoinPolicy(p.joinPolicy)
const tid = p?.teamId
const jp = p?.joinPolicy as TeamJoinPolicy | undefined
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
lastSeenTsRef.current = ev.ts ?? Date.now()
// kurzes Fenster, in dem Props-Refetch nicht wieder überschreibt
sseWinsUntil.current = Date.now() + 1500
setEffectivePolicy(jp)
}, [lastEvent?.ts, lastEvent, team.id])
}, [lastEvent, team.id])
// ⬇️ Props nur übernehmen, wenn kein frisches SSE dazwischenfunkt
// Props übernehmen, wenn kein frisches SSE dazwischenfunkt
useEffect(() => {
const jp = team.joinPolicy as TeamJoinPolicy | undefined
if (Date.now() < sseWinsUntil.current) return
if (jp === 'REQUEST' || jp === 'INVITE_ONLY') {
setEffectivePolicy(prev => (prev === jp ? prev : jp))
setEffectivePolicy((prev) => (prev === jp ? prev : jp))
}
}, [team.id, team.joinPolicy])
// ── Stati ableiten (jetzt von effectivePolicy!)
const isInviteOnly = effectivePolicy === 'INVITE_ONLY'
// Stati ableiten
const isInviteOnly = effectivePolicy === 'INVITE_ONLY'
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
const hasPendingRequest = invitationId === 'pending'
const isRequested = hasRealInvitation || hasPendingRequest
const isRequested = hasRealInvitation || hasPendingRequest
const isMemberOfThisTeam = useMemo(() => {
const inActive = (team.activePlayers ?? []).some(p => String(p.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)
const inActive = (team.activePlayers ?? []).some(
(p) => String(p.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)
}, [team, currentUserSteamId])
const isDisabled =
joining ||
isMemberOfThisTeam ||
!canRequestJoin ||
(isInviteOnly && !hasRealInvitation && !hasPendingRequest)
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" />
Lädt
</>
) : isMemberOfThisTeam ? 'Schon Mitglied'
: hasRealInvitation ? 'Einladung ablehnen'
: hasPendingRequest ? 'Angefragt (zurückziehen)'
: isInviteOnly ? 'Nur Einladungen'
: 'Beitritt anfragen'
) : isMemberOfThisTeam ? (
'Schon Mitglied'
) : hasRealInvitation ? (
'Einladung ablehnen'
) : hasPendingRequest ? (
'Angefragt (zurückziehen)'
) : isInviteOnly ? (
'Nur Einladungen'
) : (
'Beitritt anfragen'
)
const buttonColor =
hasRealInvitation ? 'red' :
isDisabled ? 'gray' :
(isRequested ? 'gray' : 'blue')
const buttonColor: ButtonColor =
hasRealInvitation ? 'red' : 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 (
<div
role="button"
tabIndex={0}
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
dark:border-neutral-700 shadow-sm hover:shadow-md
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 gap-3">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt={team.name ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border
border-gray-200 dark:border-neutral-600"
/>
<div className="relative h-12 w-12 overflow-hidden rounded-full border border-gray-200 dark:border-neutral-600">
<Image
src={
team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`
}
alt={team.name ?? 'Teamlogo'}
fill
sizes="48px"
className="object-cover"
unoptimized
/>
</div>
<div className="flex items-center gap-2">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
{team.name ?? 'Team'}
@ -184,7 +232,7 @@ export default function TeamCard({
size="md"
color="blue"
variant="solid"
onClick={e => {
onClick={(e) => {
e.stopPropagation()
router.push(`/admin/teams/${team.id}`)
}}
@ -192,13 +240,15 @@ export default function TeamCard({
Verwalten
</Button>
) : (
// 👉 Button immer zeigen falls nicht möglich: disabled + anderes Label
<Button
title={typeof buttonLabel === 'string' ? buttonLabel : undefined}
size="sm"
color={buttonColor as any}
color={buttonColor}
disabled={isDisabled}
onClick={e => { e.stopPropagation(); handleClick() }}
onClick={(e) => {
e.stopPropagation()
handleClick()
}}
aria-disabled={isDisabled ? 'true' : undefined}
>
{buttonLabel}
@ -207,33 +257,16 @@ export default function TeamCard({
</div>
<div className="flex -space-x-3">
{(() => {
const seen = new Set<string>();
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}
src={p.avatar}
alt={p.name}
title={p.name}
className="w-8 h-8 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
/>
));
})()}
{members.map((p) => (
<div
key={p.steamId}
title={p.name}
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>
)

View File

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

View File

@ -1,8 +1,9 @@
// TeamInvitationBanner.tsx
// /src/app/[locale]/components/TeamInvitationBanner.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge'
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 =
'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 ' +
@ -59,18 +59,21 @@ export default function TeamInvitationBanner({
onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
className={cardClasses}
>
{/* animierter, dezenter grüner Gradient */}
<div aria-hidden className="absolute inset-0 z-0 pointer-events-none invitationGradient" />
{/* Inhalt */}
<div className="relative z-[1] p-4">
<div className="flex items-center justify-between 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`}
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"
priority={false}
/>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
@ -86,11 +89,16 @@ export default function TeamInvitationBanner({
{/* Teammitglieder */}
<div className="flex -space-x-3">
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].map((p) => (
<img
<Image
key={p.steamId}
src={p.avatar}
alt={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"
/>
))}
@ -144,7 +152,6 @@ export default function TeamInvitationBanner({
</div>
<style jsx>{`
/* Hintergrund-Schimmer (läuft permanent) */
@keyframes slide-x {
from { background-position-x: 0%; }
to { background-position-x: 200%; }
@ -158,7 +165,7 @@ export default function TeamInvitationBanner({
);
background-size: 200% 100%;
background-repeat: repeat-x;
animation: slide-x 6s linear infinite; /* etwas ruhiger */
animation: slide-x 6s linear infinite;
}
:global(.dark) .invitationGradient {
background-image: repeating-linear-gradient(
@ -168,18 +175,13 @@ export default function TeamInvitationBanner({
rgba(16,168,54,0.28) 100%
);
}
/* Shine-Sweep nur auf Hover */
@keyframes shine {
0% { transform: translateX(-120%) skewX(-20deg); opacity: 0; }
10% { opacity: .7; }
27% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
100% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
}
.shine {
position: absolute;
inset: 0;
}
.shine { position: absolute; inset: 0; }
.shine::before {
content: "";
position: absolute;
@ -194,12 +196,9 @@ export default function TeamInvitationBanner({
transform: translateX(-120%) skewX(-20deg);
transition: opacity .2s;
}
/* nur wenn die Karte offen ist und gehovert wird */
:global(.group:hover) .shine::before {
animation: shine 3.8s ease-out infinite;
}
/* Respektiere Bewegungs-Präferenzen */
@media (prefers-reduced-motion: reduce) {
.invitationGradient { animation: none; }
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }

View File

@ -1,10 +1,14 @@
// /src/app/[locale]/components/TeamMemberView.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
DndContext,
closestCenter,
DragOverlay,
type DragStartEvent,
type DragEndEvent,
} from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { DroppableZone } from './DroppableZone'
import MiniCard from './MiniCard'
@ -43,16 +47,10 @@ type Props = {
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) {
const { team: storeTeam, setTeam } = useTeamStore()
// Prop -> Store spiegeln: auch bei gleicher ID relevante Felder patchen
// Prop -> Store spiegeln
useEffect(() => {
if (!props.team) return
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.leader?.steamId ?? null) !== (next.leader?.steamId ?? null)) diff.leader = next.leader
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)
}, [props.team, setTeam])
// Guards dürfen im Wrapper stehen (kein Hook darunter bricht ab)
if (!props.adminMode && !props.currentUserSteamId) return null
const team = props.team ?? storeTeam ?? 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} />
}
/**
* Body-Komponente:
* - enthält ALLE übrigen Hooks in fester Reihenfolge
* - hier ist team garantiert vorhanden (nicht null)
*/
function TeamMemberViewBody({
team,
activeDragItem,
@ -105,7 +96,11 @@ function TeamMemberViewBody({
const teamId = team.id
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 canManage = adminMode || isLeader
@ -139,18 +134,20 @@ function TeamMemberViewBody({
const [inviteKey, setInviteKey] = useState(0)
const openInvite = () => {
setInviteKey(k => k + 1) // erzwingt frischen Mount
setInviteKey(k => k + 1)
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 =
(team as any).logoUpdatedAt
? new Date((team as any).logoUpdatedAt).getTime()
: (team as any).updatedAt
? new Date((team as any).updatedAt).getTime()
: 0;
const [logoVersion, setLogoVersion] = useState<number | null>(initialLogoVersion);
tStamped.logoUpdatedAt
? new Date(tStamped.logoUpdatedAt).getTime()
: tStamped.updatedAt
? new Date(tStamped.updatedAt).getTime()
: 0
const [logoVersion, setLogoVersion] = useState<number | null>(initialLogoVersion)
// Upload-Progress
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
@ -172,7 +169,7 @@ function TeamMemberViewBody({
const bb = b.map(p=>p.steamId).join(',')
return aa === bb
}
const eqSetByIds = (a: {steamId:string}[], b: {steamId:string}[]) => {
if (a.length !== b.length) return false
const sa = [...a.map(p => p.steamId)].sort()
@ -182,9 +179,8 @@ function TeamMemberViewBody({
}
return true
}
useEffect(() => {
// Nur setzen, wenn der Server wirklich einen Wert liefert.
if (typeof team.joinPolicy === 'string') {
setJoinPolicy(team.joinPolicy as TeamJoinPolicy)
}
@ -218,17 +214,10 @@ function TeamMemberViewBody({
useEffect(() => {
if (!lastEvent || !team.id) return
if (!isSseEventType(lastEvent.type)) return
const payload = lastEvent.payload ?? {}
const payload = (lastEvent.payload ?? {}) as Record<string, unknown>
const now = Date.now()
// nur joinPolicy geändert → minimal patchen
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.
// Nach lokalem Speichern: kurzes Ignore-Fenster
if (lastEvent.type === 'team-updated' && payload.teamId === team.id) {
if (policyChangedAtRef.current && (now - policyChangedAtRef.current) < 2000) {
policyChangedAtRef.current = null
@ -238,16 +227,20 @@ function TeamMemberViewBody({
// nur Logo geändert → minimal patchen
if (lastEvent.type === 'team-logo-updated') {
if (payload.teamId && payload.teamId !== team.id) return
const curr = useTeamStore.getState().team
if (payload?.filename && curr) setTeam({ ...curr, logo: payload.filename })
if (payload?.version) setLogoVersion(payload.version)
const filename = (payload as { filename?: string }).filename
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
}
// Rest: reload + remount NUR wenn Listen wirklich anders sind
// Rest: reload wenn relevant
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 () => {
const updated = await reloadTeam(team.id)
@ -256,8 +249,8 @@ function TeamMemberViewBody({
setTeam(updated)
setEditedName(updated.name || '')
if (typeof (updated as any).joinPolicy === 'string') {
setJoinPolicy((updated as any).joinPolicy as TeamJoinPolicy)
if (typeof updated.joinPolicy === 'string') {
setJoinPolicy(updated.joinPolicy as TeamJoinPolicy)
}
const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
@ -270,35 +263,31 @@ function TeamMemberViewBody({
return
}
// 1) Set-Vergleich (Inhalt)
const contentChanged =
!eqSetByIds(activePlayers, nextActive) ||
!eqSetByIds(inactivePlayers, nextInactive) ||
!eqSetByIds(invitedPlayers, nextInvited)
// 2) Reihenfolge-Vergleich (nur Order)
const orderChanged =
!eqByIds(activePlayers, nextActive) ||
!eqByIds(inactivePlayers, nextInactive) ||
!eqByIds(invitedPlayers, nextInvited)
if (contentChanged) {
// IDs haben sich geändert → Listen setzen + DnD remounten (Keys bleiben!)
setActivePlayers(nextActive)
setInactivePlayers(nextInactive)
setInvitedPlayers(nextInvited)
setRemountKey(k => k + 1)
} else if (orderChanged) {
// Nur Reihenfolge/Sichtung anders → Listen setzen, aber KEIN remount
setActivePlayers(nextActive)
setInactivePlayers(nextInactive)
setInvitedPlayers(nextInvited)
}
})()
}, [lastEvent, team.id, setTeam, activePlayers, inactivePlayers, invitedPlayers])
}, [RELEVANT, lastEvent, team.id, setTeam, activePlayers, inactivePlayers, invitedPlayers])
const handleDragStart = (event: any) => {
const id = event.active.id as string
const handleDragStart = (event: DragStartEvent) => {
const id = String(event.active.id)
const item =
activePlayers.find(p => p.steamId === id) ||
inactivePlayers.find(p => p.steamId === id)
@ -314,8 +303,8 @@ function TeamMemberViewBody({
const applyPolicy = async (p: TeamJoinPolicy) => {
if (p === joinPolicy) { setShowPolicyMenu(false); return }
setJoinPolicy(p) // optimistisch
await saveJoinPolicy(p) // serverseitig speichern
setJoinPolicy(p)
await saveJoinPolicy(p)
setShowPolicyMenu(false)
}
@ -325,7 +314,6 @@ function TeamMemberViewBody({
const onOutside = (e: PointerEvent) => {
if (!policyMenuRef.current) return
if (!policyMenuRef.current.contains(e.target as Node)) {
// Klick außerhalb: Menü schließen + Navigation/Drag verhindern
e.preventDefault()
e.stopPropagation()
setShowPolicyMenu(false)
@ -335,7 +323,6 @@ function TeamMemberViewBody({
if (e.key === 'Escape') setShowPolicyMenu(false)
}
// Capture-Phase, damit wir VOR Links/Drag reagieren
document.addEventListener('pointerdown', onOutside, { capture: true })
document.addEventListener('keydown', onEsc)
@ -345,28 +332,27 @@ function TeamMemberViewBody({
}
}, [showPolicyMenu])
const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => {
const updateTeamMembers = async (tId: string, active: Player[], inactive: Player[]) => {
try {
const res = await fetch('/api/team/update-players', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teamId,
teamId: tId,
activePlayers: active.map(p => p.steamId),
inactivePlayers: inactive.map(p => p.steamId),
}),
})
if (!res.ok) throw new Error('Update fehlgeschlagen')
const updated = await reloadTeam(teamId)
const updated = await reloadTeam(tId)
if (updated) setTeam(updated)
} catch (err) {
console.error('Fehler beim Aktualisieren:', err)
}
}
const handleDragEnd = async (event: any) => {
const handleDragEnd = async (event: DragEndEvent) => {
setActiveDragItem(null)
setIsDragging(false)
isDraggingRef.current = false
@ -482,8 +468,8 @@ function TeamMemberViewBody({
body: JSON.stringify({ teamId, newLeaderSteamId: newLeaderId }),
})
if (!res.ok) {
const data = await res.json()
console.error('Fehler bei Leader-Übertragung:', data.message)
const data: unknown = await res.json().catch(() => ({}))
console.error('Fehler bei Leader-Übertragung:', (data as { message?: string }).message)
return
}
await handleReload()
@ -493,11 +479,11 @@ function TeamMemberViewBody({
}
type DownscaleOpts = {
size?: number; // Zielkante (px)
quality?: number; // 0..1
mime?: string; // Wunschformat, default 'image/webp'
square?: boolean; // center-crop auf Quadrat
};
size?: number
quality?: number
mime?: string
square?: boolean
}
async function saveJoinPolicy(next: TeamJoinPolicy = joinPolicy) {
const prev = joinPolicy
@ -509,20 +495,21 @@ function TeamMemberViewBody({
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({ teamId, joinPolicy: next }), // teamId aus dem Body-Scope
body: JSON.stringify({ teamId, joinPolicy: next }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.message ?? `Speichern fehlgeschlagen (${res.status})`)
const data: unknown = await res.json().catch(() => ({}))
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
setJoinPolicy(patched)
// Store patchen
const curr = useTeamStore.getState().team
if (curr && curr.id === teamId && curr.joinPolicy !== patched) {
setTeam({ ...curr, joinPolicy: patched })
@ -543,18 +530,17 @@ function TeamMemberViewBody({
async function canEncode(mime: string): Promise<boolean> {
try {
// OffscreenCanvas hat die zuverlässigste Blob-API
if ('OffscreenCanvas' in window) {
const c = new OffscreenCanvas(2, 2);
const b = await (c as any).convertToBlob?.({ type: mime, quality: 0.8 });
return !!b;
const c = new OffscreenCanvas(2, 2)
const b = await c.convertToBlob({ type: mime, quality: 0.8 })
return !!b
}
const c = document.createElement('canvas');
c.width = 2; c.height = 2;
const url = c.toDataURL(mime);
return typeof url === 'string' && url.startsWith(`data:${mime}`);
const c = document.createElement('canvas')
c.width = 2; c.height = 2
const url = c.toDataURL(mime)
return typeof url === 'string' && url.startsWith(`data:${mime}`)
} catch {
return false;
return false
}
}
@ -564,104 +550,93 @@ function TeamMemberViewBody({
quality = 0.85,
mime: wantedMime = 'image/webp',
square = true,
} = opts;
} = opts
// 1) Bild laden (ImageBitmap bevorzugt)
let url: string | null = null;
let img: ImageBitmap | HTMLImageElement;
// 1) Bild laden (ImageBitmap bevorzugt, ohne any)
let url: string | null = null
let img: ImageBitmap | HTMLImageElement
const useBitmap = 'createImageBitmap' in window;
if (useBitmap) {
try {
img = await (createImageBitmap as any)(file, { imageOrientation: 'from-image' });
} catch {
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!;
});
}
} else {
url = URL.createObjectURL(file);
try {
img = await createImageBitmap(file)
} catch {
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 im = new window.Image()
im.onload = () => res(im)
im.onerror = rej
im.src = url!
})
}
const srcW = (img as any).width as number;
const srcH = (img as any).height as number;
const dims = img as unknown as { width: number; height: number }
const srcW = dims.width
const srcH = dims.height
if (!srcW || !srcH) {
if (url) URL.revokeObjectURL(url);
if ('close' in (img as any)) try { (img as ImageBitmap).close(); } catch {}
throw new Error('Invalid image dimensions');
if (url) URL.revokeObjectURL(url)
if ('close' in (img as ImageBitmap)) try { (img as ImageBitmap).close() } catch {}
throw new Error('Invalid image dimensions')
}
// 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) {
const side = Math.min(srcW, srcH);
sx = Math.max(0, Math.floor((srcW - side) / 2));
sy = Math.max(0, Math.floor((srcH - side) / 2));
sw = side; sh = side;
const side = Math.min(srcW, srcH)
sx = Math.max(0, Math.floor((srcW - side) / 2))
sy = Math.max(0, Math.floor((srcH - side) / 2))
sw = side; sh = side
}
const scale = Math.min(size / sw, size / sh, 1);
const dw = Math.max(1, Math.round(sw * scale));
const dh = Math.max(1, Math.round(sh * scale));
const scale = Math.min(size / sw, size / sh, 1)
const dw = Math.max(1, Math.round(sw * scale))
const dh = Math.max(1, Math.round(sh * scale))
// 3) Canvas wählen (Offscreen bevorzugt)
const offscreen = 'OffscreenCanvas' in window;
let blob: Blob | null = null;
// 3) Canvas (Offscreen bevorzugt)
const source = img as unknown as CanvasImageSource
const offscreen = 'OffscreenCanvas' in window
let blob: Blob | null = null
if (offscreen) {
const c = new OffscreenCanvas(dw, dh);
const ctx = c.getContext('2d', { alpha: true })!;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img as any, sx, sy, sw, sh, 0, 0, dw, dh);
const c = new OffscreenCanvas(dw, dh)
const ctx = c.getContext('2d', { alpha: true })!
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(source, sx, sy, sw, sh, 0, 0, dw, dh)
// 4) Format mit Fallbacks
const canWebp = await canEncode('image/webp');
const canJpeg = await canEncode('image/jpeg');
const canWebp = await canEncode('image/webp')
const canJpeg = await canEncode('image/jpeg')
const targetMime =
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
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 {
const c = document.createElement('canvas');
c.width = dw; c.height = dh;
const ctx = c.getContext('2d')!;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img as any, sx, sy, sw, sh, 0, 0, dw, dh);
const c = document.createElement('canvas')
c.width = dw; c.height = dh
const ctx = c.getContext('2d')!
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(source, sx, sy, sw, sh, 0, 0, dw, dh)
const canWebp = await canEncode('image/webp');
const canJpeg = await canEncode('image/jpeg');
const canWebp = await canEncode('image/webp')
const canJpeg = await canEncode('image/jpeg')
const targetMime =
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
canWebp ? 'image/webp' :
canJpeg ? 'image/jpeg' : 'image/png';
canJpeg ? 'image/jpeg' : 'image/png'
blob = await new Promise<Blob | null>((res) =>
c.toBlob(b => res(b), targetMime, targetMime === 'image/png' ? undefined : quality)
);
)
}
// Cleanup
if (url) URL.revokeObjectURL(url);
if ('close' in (img as any)) { try { (img as ImageBitmap).close(); } catch {} }
if (url) URL.revokeObjectURL(url)
if ('close' in (img as ImageBitmap)) { try { (img as ImageBitmap).close() } catch {} }
if (!blob) throw new Error('Canvas encoding failed (toBlob returned null)');
return blob;
if (!blob) throw new Error('Canvas encoding failed (toBlob returned null)')
return blob
}
// Upload mit Progress via XHR setzt filename/version direkt, kein Reload nötig
async function uploadTeamLogo(file: File) {
return new Promise<void>((resolve, reject) => {
const formData = new FormData()
@ -677,10 +652,12 @@ function TeamMemberViewBody({
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const json = JSON.parse(xhr.responseText)
const json: unknown = JSON.parse(xhr.responseText)
const current = useTeamStore.getState().team
if (json?.filename && current) setTeam({ ...current, logo: json.filename })
if (json?.version) setLogoVersion(json.version)
const filename = (json as { filename?: string }).filename
const version = (json as { version?: number }).version
if (filename && current) setTeam({ ...current, logo: filename })
if (typeof version === 'number') setLogoVersion(version)
} catch {}
resolve()
} else {
@ -709,7 +686,6 @@ function TeamMemberViewBody({
>
<Link
href={`/profile/${player.steamId}`}
passHref
onClick={e => { if (isDragging) e.preventDefault() }}
>
<SortableMiniCard
@ -769,7 +745,6 @@ function TeamMemberViewBody({
unoptimized
/>
{/* Hover-Overlay nur, wenn klickbar */}
{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">
<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>
)}
{/* Progress-Kreis (Start bei 12 Uhr via rotate(-90 …)) */}
{isUploadingLogo && (
<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">
@ -810,24 +784,22 @@ function TeamMemberViewBody({
className="hidden"
disabled={!isClickable}
onChange={async (e) => {
if (isUploadingLogo) return;
const file = e.target.files?.[0];
if (!file) return;
if (isUploadingLogo) return
const file = e.target.files?.[0]
if (!file) return
try {
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 ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp';
const processed = new File([blob], `${team!.id}.${ext}`, { type: mime });
await uploadTeamLogo(processed);
const blob = await downscaleImage(file, { size: 512, quality: 0.85, mime: 'image/webp', square: true })
const mime = blob.type || 'image/webp'
const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp'
const processed = new File([blob], `${team!.id}.${ext}`, { type: mime })
await uploadTeamLogo(processed)
} catch (err) {
console.error('Fehler beim Hochladen des Logos:', err);
alert('Fehler beim Hochladen des Logos.');
console.error('Fehler beim Hochladen des Logos:', err)
alert('Fehler beim Hochladen des Logos.')
} finally {
setTimeout(() => { setIsUploadingLogo(false); setUploadPct(0); }, 300);
e.currentTarget.value = '';
setTimeout(() => { setIsUploadingLogo(false); setUploadPct(0) }, 300)
e.currentTarget.value = ''
}
}}
/>
@ -879,8 +851,7 @@ function TeamMemberViewBody({
</h2>
<TeamPremierRankBadge players={activePlayers} />
</div>
{/* Beitritts-Einstellungen (nur Leader/Admin) */}
{canManage && (
<>
<Button
@ -897,12 +868,12 @@ function TeamMemberViewBody({
Bearbeiten
</Button>
{/* 🔽 Dezente Policy-Pill */}
{/* Policy-Pill */}
<div className="relative" ref={policyMenuRef}>
<button
type="button"
onPointerDownCapture={(e) => { e.stopPropagation(); }} // verhindert Drag/Link schon sehr früh
onMouseDown={(e) => e.stopPropagation()} // fallback
onPointerDownCapture={(e) => { e.stopPropagation() }}
onMouseDown={(e) => e.stopPropagation()}
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
bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-200
@ -926,47 +897,44 @@ function TeamMemberViewBody({
</button>
{showPolicyMenu && (
<>
<div
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"
onPointerDownCapture={(e) => e.stopPropagation()} // Klicks bleiben im Menü
onClick={(e) => e.stopPropagation()}
<div
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"
onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); applyPolicy('REQUEST') }}
className={`w-full text-left px-2.5 py-2 rounded-md text-sm
${joinPolicy === 'REQUEST'
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
<button
type="button"
onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); applyPolicy('REQUEST') }}
className={`w-full text-left px-2.5 py-2 rounded-md text-sm
${joinPolicy === 'REQUEST'
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
<div className="font-medium">Mit Genehmigung</div>
<div className="text-xs text-gray-500 dark:text-neutral-400">
Spieler stellen eine Anfrage; Leader entscheidet.
</div>
</button>
<div className="font-medium">Mit Genehmigung</div>
<div className="text-xs text-gray-500 dark:text-neutral-400">
Spieler stellen eine Anfrage; Leader entscheidet.
</div>
</button>
<button
type="button"
onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); applyPolicy('INVITE_ONLY') }}
className={`w-full text-left px-2.5 py-2 rounded-md text-sm
${joinPolicy === 'INVITE_ONLY'
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
<div className="font-medium">Nur Einladung</div>
<div className="text-xs text-gray-500 dark:text-neutral-400">
Beitritt nur per Einladung.
</div>
</button>
</div>
</>
<button
type="button"
onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); applyPolicy('INVITE_ONLY') }}
className={`w-full text-left px-2.5 py-2 rounded-md text-sm
${joinPolicy === 'INVITE_ONLY'
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
<div className="font-medium">Nur Einladung</div>
<div className="text-xs text-gray-500 dark:text-neutral-400">
Beitritt nur per Einladung.
</div>
</button>
</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="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
<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 }}>
<MiniCard
steamId={player.steamId}
@ -1053,7 +1021,8 @@ function TeamMemberViewBody({
isSelectable={false}
isInvite={true}
rank={player.premierRank}
invitationId={(player as any).invitationId}
// optional lokales Extra-Feld sicher lesen
invitationId={(player as InvitedPlayer & { invitationId?: string }).invitationId}
onKick={async (sid) => {
setInvitedPlayers(list => list.filter(p => p.steamId !== sid))
try {
@ -1061,7 +1030,7 @@ function TeamMemberViewBody({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invitationId: (player as any).invitationId ?? undefined,
invitationId: (player as InvitedPlayer & { invitationId?: string }).invitationId ?? undefined,
teamId: team.id,
steamId: sid,
}),

View File

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

View File

@ -1,14 +1,16 @@
// /src/app/[locale]/components/TelemetrySocket.tsx
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
import { usePresenceStore } from '@/lib/usePresenceStore'
import { useTelemetryStore } from '@/lib/useTelemetryStore'
import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
import { useSSEStore } from '@/lib/useSSEStore'
import type { SSEEventType } from '@/lib/sseEvents'
/* ===================== helpers & types ===================== */
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
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}`
}
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
function toSnapshotList(arr: PlayerLike[]): SnapshotPlayer[] {
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() {
// WS-URL aus ENV ableiten
@ -40,7 +100,7 @@ export default function TelemetrySocket() {
// aktiver User
const { data: session } = useSession()
const mySteamId = (session?.user as any)?.steamId ?? null
const mySteamId = (session?.user as { steamId?: string } | undefined)?.steamId ?? null
// Overlay-Steuerung
const hideOverlay = useReadyOverlayStore((s) => s.hide)
@ -52,40 +112,42 @@ export default function TelemetrySocket() {
const setMapKey = useTelemetryStore((s) => s.setMapKey)
const setPhase = useTelemetryStore((s) => s.setPhase)
// 👇 NEU: online-Status für GameBanner
const setOnline = useTelemetryStore((s) => s.setOnline)
// Roster-Store
const setRoster = useMatchRosterStore((s) => s.setRoster)
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
const { lastEvent } = useSSEStore()
async function fetchCurrentRoster() {
const fetchCurrentRoster = useCallback(async () => {
try {
const r = await fetch('/api/matches/current', { cache: 'no-store' })
if (!r.ok) return
const j = await r.json()
const ids: string[] = Array.isArray(j?.steamIds) ? j.steamIds : []
const j: unknown = await r.json()
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)
else clearRoster()
} catch {}
}
} catch {
// still
}
}, [setRoster, clearRoster])
// initial + bei Events
useEffect(() => { fetchCurrentRoster() }, [])
useEffect(() => {
fetchCurrentRoster()
}, [fetchCurrentRoster])
useEffect(() => {
if (!lastEvent) return
const t = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
if (['match-updated', 'match-ready', 'map-vote-updated', 'match-exported'].includes(String(t))) {
const t = lastEvent.type ?? (isObject(lastEvent.payload) ? (lastEvent.payload as Record<string, unknown>).type : undefined)
if (shouldRefetchRoster(t as SSEEventType | string)) {
fetchCurrentRoster()
}
}, [lastEvent])
}, [lastEvent, fetchCurrentRoster])
// wenn User ab-/anmeldet → Online-Flag sinnvoll zurücksetzen
useEffect(() => {
@ -104,17 +166,21 @@ export default function TelemetrySocket() {
if (!aliveRef.current || !url) return
// nicht doppelt verbinden
if (wsRef.current && (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING
)) return
if (
wsRef.current &&
(wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)
)
return
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
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 = () => {
@ -135,59 +201,56 @@ export default function TelemetrySocket() {
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return
let msg: unknown
try {
msg = JSON.parse(String(ev.data ?? ''))
} catch {
return
}
// komplette Playerliste
if (msg.type === 'players' && Array.isArray(msg.players)) {
setSnapshot(msg.players)
if (isPlayersMsg(msg)) {
setSnapshot(toSnapshotList(msg.players));
const ids = msg.players.map(sidOf).filter(Boolean)
setTelemetrySet(toSet(ids))
const mePresent = !!mySteamId && ids.includes(String(mySteamId))
setOnline(!!mePresent)
if (mePresent) hideOverlay()
return
}
// join/leave deltas
if (msg.type === 'player_join' && msg.player) {
setJoin(msg.player)
setTelemetrySet(prev => {
const next = new Set(prev)
const sid = sidOf(msg.player)
if (sid) next.add(sid)
return next
})
const sid = sidOf(msg.player)
if (mySteamId && sid === String(mySteamId)) {
setOnline(true)
hideOverlay()
if (isPlayerJoinMsg(msg)) {
const sid = msg.player.steamId ?? msg.player.steam_id ?? msg.player.id;
if (sid != null) {
setJoin({ steamId: sid, name: msg.player.name, team: msg.player.team }); // ✅ required steamId
if (mySteamId && String(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 ?? '')
if (sid) setLeave(sid)
setTelemetrySet(prev => {
const next = new Set(prev)
if (sid) next.delete(sid)
return next
})
if (mySteamId && sid === String(mySteamId)) {
setOnline(false)
}
return
}
// Map-Key und Phase ins Telemetry-Store schreiben
if (msg.type === 'map' && typeof msg.name === 'string') {
if (isMapMsg(msg)) {
const key = msg.name.toLowerCase()
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()
return () => {
aliveRef.current = false
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
if (retryRef.current) {
window.clearTimeout(retryRef.current)
retryRef.current = null
}
try {
wsRef.current?.close(1000, 'telemetry socket unmounted')
} catch {}
setOnline(false)
}
}, [url, hideOverlay, mySteamId, setJoin, setLeave, setMapKey, setPhase, setSnapshot, setOnline])
// ⬇️ WICHTIG: Kein Banner-Rendering mehr hier. UI kommt aus GameBannerHost.
return null
}

View File

@ -1,4 +1,4 @@
// /src/app/components/UserAvatarWithStatus.tsx
// /src/app/[locale]/components/UserAvatarWithStatus.tsx
'use client'
import { useEffect, useState, useMemo } from 'react'
@ -22,6 +22,34 @@ type Props = React.HTMLAttributes<HTMLDivElement> & {
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({
steamId,
src,
@ -31,20 +59,21 @@ export default function UserAvatarWithStatus({
isLeader = false,
alignRight = false,
showStatus = true,
className, // <— NEU: Klassen für den äußeren Wrapper
avatarClassName, // <— NEU: Klassen für den inneren Avatar-Container
...rest // <— NEU: alle weiteren div-Props (onClick, title, …)
className,
avatarClassName,
...rest
}: Props) {
const { lastEvent } = useSSEStore()
const [status, setStatus] = useState<Presence>(initialStatus)
const { crownSize, crownOffset, crownIconSize } = useMemo(() => {
const cs = Math.min(20, Math.round(Math.max(size * 0.5, 14)))
const off = Math.round(size * 0.10)
const icon = Math.round(cs * 0.70)
const off = Math.round(size * 0.1)
const icon = Math.round(cs * 0.7)
return { crownSize: cs, crownOffset: off, crownIconSize: icon }
}, [size])
// Initialstatus vom Server holen (optional)
useEffect(() => {
if (!showStatus || !steamId) return
let alive = true
@ -52,19 +81,29 @@ export default function UserAvatarWithStatus({
try {
const res = await fetch(`/api/user/${steamId}`, { cache: 'no-store' })
if (!res.ok) return
const data = await res.json()
if (alive) setStatus((data?.user?.status ?? 'offline') as Presence)
} catch {}
const data: unknown = await res.json()
const next = typeof data === 'object' && data != null
? (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])
// Live-Updates via SSE
useEffect(() => {
if (!showStatus || !steamId) return
if (!lastEvent || lastEvent.type !== 'user-status-updated') return
const raw = lastEvent.payload as any
const sid = raw?.steamId ?? raw?.payload?.steamId
const st = (raw?.status ?? raw?.payload?.status) as Presence | undefined
const { steamId: sid, status: st } = extractStatusPayload(lastEvent.payload as MaybeStatusPayload)
if (sid === steamId && st) setStatus(st)
}, [lastEvent, steamId, showStatus])
@ -113,8 +152,14 @@ export default function UserAvatarWithStatus({
...(alignRight ? { left: -crownOffset } : { right: -crownOffset }),
}}
>
<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"/>
<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" />
</svg>
</span>
)}

View File

@ -1,37 +1,10 @@
// /src/app/[locale]/components/admin/MatchesAdminManager.tsx
'use client'
import { useEffect, useState } from 'react'
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() {
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 (
<CommunityMatchList matchType="community" />
)

View File

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

View File

@ -3,6 +3,7 @@ import Card from '../../Card'
import PremierRankBadge from '../../PremierRankBadge'
import CompRankBadge from '../../CompRankBadge'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import Image from 'next/image'
type Props = { steamId: string }
@ -191,13 +192,15 @@ const iconForMap = (raw: string) => {
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`
}
const bgForMap = (raw: string) => {
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])
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return `/assets/img/maps/${withPrefix}.webp`
}
const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
const parseScoreString = (raw?: string | null): [number | null, number | null] => {
@ -436,7 +439,13 @@ export default async function Profile({ steamId }: Props) {
{/* Map + Meta */}
<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">
<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 className="min-w-0">
<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 CompRankBadge from '../../../CompRankBadge'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import Image from 'next/image'
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`
}
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 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])
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return `/assets/img/maps/${withPrefix}.webp`
@ -231,7 +225,12 @@ export default async function MatchesList({ steamId }: Props) {
{/* LINKS: Map + Meta */}
<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">
<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 className="min-w-0">

View File

@ -1,7 +1,9 @@
// /src/app/[locale]/components/profile/[steamId]/stats/StatsView.tsx
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useSession } from 'next-auth/react'
import type { TooltipItem } from 'chart.js'
import Chart from '../../../Chart'
import Card from '../../../Card'
import { MatchStats } from '@/types/match'
@ -45,15 +47,20 @@ const tone = {
const fmtADR = (v: number) =>
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 {
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 (
(m as any).rounds ??
(m as any).roundCount ??
(m as any).roundsPlayed ??
(m as any).roundsTotal ??
r('rounds') ??
r('roundCount') ??
r('roundsPlayed') ??
r('roundsTotal') ??
0
) || 0
)
}
/* kleine Sparkline */
@ -126,11 +133,11 @@ function perfOfMatch(m: Partial<MatchStats>) {
const d = m.deaths ?? 0
const a = m.assists ?? 0
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 kpr = k / r
const apr = a / r
const kdS = clamp01(kd / KD_CAP)
const kdS = clamp01(kdV / KD_CAP)
const adrS = clamp01(adr / ADR_CAP)
const kprS = clamp01(kpr / KPR_CAP)
const aprS = clamp01(apr / APR_CAP)
@ -139,8 +146,11 @@ function perfOfMatch(m: Partial<MatchStats>) {
/* Hauptkomponente */
export default function StatsView({ steamId, stats }: Props) {
const { data: session } = useSession()
const allMatches = stats.matches ?? []
// Matches-Array stabilisieren, damit useMemo-Dep sauber bleibt
const allMatches = useMemo<MatchStats[]>(
() => stats.matches ?? [],
[stats.matches]
)
/* ─ Filter: 30 | 90 | Alle ─ */
const [range, setRange] = useState<'30' | '90' | 'all'>('30')
@ -159,7 +169,8 @@ export default function StatsView({ steamId, stats }: Props) {
// ► ADR-Berechnung
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 dateLabels = matches.map((m) => fmtShortDate(m.date))
@ -187,7 +198,7 @@ export default function StatsView({ steamId, stats }: Props) {
}, [matches])
const mapKeys = Object.keys(killsPerMap)
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) */
@ -211,13 +222,11 @@ export default function StatsView({ steamId, stats }: Props) {
}
}
})()
return () => {
stop = true
}
return () => { stop = true }
}, [steamId])
const winPct = wrValues.map(v => Math.max(0, Math.min(100, v ?? 0)));
const lossPct = winPct.map(v => 100 - v);
const winPct = wrValues.map(v => Math.max(0, Math.min(100, v ?? 0)))
const lossPct = winPct.map(v => 100 - v)
// ►► Per-Match-ADR Serie für Chart
const adrPerMatch = matches.map((m) => {
@ -339,56 +348,44 @@ export default function StatsView({ steamId, stats }: Props) {
</Section>
<Section title="Win / Loss je Map (%)">
{wrLabels.length === 0 ? (
<div className="rounded-md border border-dashed border-white/10 p-6 text-center text-sm text-white/60">
Keine Daten.
</div>
) : (
<Chart
type="bar"
labels={wrLabels}
datasets={[
{
label: 'Win %',
data: winPct,
backgroundColor: 'rgba(16,185,129,.85)',
},
{
label: 'Loss %',
data: lossPct,
backgroundColor: 'rgba(239,68,68,.85)',
},
]}
options={{
indexAxis: 'y',
responsive: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: (ctx: any) => `${ctx.dataset.label}: ${Number(ctx.parsed.x).toFixed(0)}%`,
{wrLabels.length === 0 ? (
<div className="rounded-md border border-dashed border-white/10 p-6 text-center text-sm text-white/60">
Keine Daten.
</div>
) : (
<Chart
type="bar"
labels={wrLabels}
datasets={[
{ label: 'Win %', data: winPct, backgroundColor: 'rgba(16,185,129,.85)' },
{ label: 'Loss %', data: lossPct, backgroundColor: 'rgba(239,68,68,.85)' },
]}
options={{
indexAxis: 'y',
responsive: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: (ctx: TooltipItem<'bar'>) =>
`${ctx.dataset.label ?? ''}: ${Number(ctx.parsed.x).toFixed(0)}%`,
},
},
},
},
scales: {
x: {
stacked: true,
min: 0,
max: 100,
ticks: {
callback: (v) => `${v}%`,
scales: {
x: {
stacked: true,
min: 0,
max: 100,
ticks: { callback: (v) => `${v}%` },
grid: { color: 'rgba(255,255,255,.08)' },
},
grid: { color: 'rgba(255,255,255,.08)' },
y: { stacked: true, grid: { display: false } },
},
y: {
stacked: true,
grid: { display: false },
},
},
}}
/>
)}
</Section>
}}
/>
)}
</Section>
</div>
{/* right column */}
@ -409,9 +406,7 @@ export default function StatsView({ steamId, stats }: Props) {
datasets={[
{
label: 'K/D',
data: matches.map((m) =>
(m.deaths ?? 0) > 0 ? (m.kills ?? 0) / (m.deaths ?? 1) : (m.kills ?? 0),
),
data: matches.map((m) => (m.deaths ?? 0) > 0 ? (m.kills ?? 0) / (m.deaths ?? 1) : (m.kills ?? 0)),
borderColor: tone.red,
backgroundColor: tone.redBg,
borderWidth: 2,

View File

@ -1,65 +1,78 @@
// /src/app/[locale]/components/radar/GameSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
type UnknownRecord = Record<string, unknown>
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
type GameSocketProps = {
url?: string
onStatus?: (s: Status) => void
onMap?: (mapKey: string) => void
onPlayerUpdate?: (p: any) => void
onPlayersAll?: (payload: any) => void
onGrenades?: (g: any) => void
onPlayerUpdate?: (p: UnknownRecord) => void
onPlayersAll?: (payload: UnknownRecord) => void
onGrenades?: (g: unknown) => void
onRoundStart?: () => void
onRoundEnd?: () => void
onBomb?: (b:any) => void
onBomb?: (b: UnknownRecord) => void
}
// HINZUFÜGEN: oben im Modul kleine Helfer
function pickVec3Loose(src: any) {
// akzeptiert {x,y,z}, [x,y,z], "x, y, z"
function isObj(v: unknown): v is UnknownRecord {
return !!v && typeof v === 'object'
}
/* 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 (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)
if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
return null
}
if (typeof src === 'string') {
const parts = src.split(',').map(s => Number(s.trim()))
if (parts.length >= 2 && parts.slice(0,2).every(Number.isFinite)) {
const parts = src.split(',').map((s) => Number(s.trim()))
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 null
}
const nx = Number(src?.x), ny = Number(src?.y), nz = Number(src?.z)
if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
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 }
}
return null
}
function extractBombPayload(msg: any): any | null {
// 1) Wenn msg.bomb / msg.c4 schon da ist → ggf. Position aus bekannten Feldern ergänzen
const base = msg?.bomb ?? msg?.c4 ?? null
function extractBombPayload(msg: UnknownRecord): UnknownRecord | null {
const base = (msg.bomb as UnknownRecord | undefined) ?? (msg.c4 as UnknownRecord | undefined) ?? null
// mögliche Felder, wo Positionsinfos oft landen
const posCandidates = [
const posCandidates: unknown[] = [
base?.pos, base?.position, base?.location, base?.coordinates, base?.origin,
msg?.bomb_pos, msg?.bomb_position, msg?.bombLocation, msg?.bomblocation,
msg?.pos, msg?.position, msg?.location, msg?.coordinates, msg?.origin,
msg?.world?.bomb, msg?.objectives?.bomb
(msg as UnknownRecord)['bomb_pos'],
(msg as UnknownRecord)['bomb_position'],
(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
for (const p of posCandidates) { P = pickVec3Loose(p); if (P) break }
let P: { x: number; y: number; z: number } | null = null
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 ?? '').toLowerCase()
let status: 'carried'|'dropped'|'planted'|'defusing'|'defused'|'unknown' = 'unknown'
const s = String(base?.status ?? base?.state ?? '').toLowerCase()
if (s.includes('plant')) status = 'planted'
const t = String((msg.type ?? '') as string).toLowerCase()
let status: 'carried' | 'dropped' | 'planted' | 'defusing' | 'defused' | 'unknown' = 'unknown'
const s = String(((base as UnknownRecord | undefined)?.status ?? (base as UnknownRecord | undefined)?.state ?? '') as string).toLowerCase()
if (s.includes('plant')) status = 'planted'
else if (s.includes('drop')) status = 'dropped'
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'
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_defused') status = 'defused'
// Wir wollen nur liefern, wenn NICHT getragen
const notCarried = status !== 'carried'
if (!base && !P && !t.startsWith('bomb_')) return null
if (!notCarried && !t.startsWith('bomb_')) return null
const payload = {
// Lass LiveRadar.normalizeBomb entscheiden wir geben „bomb“ aus
bomb: {
...(base || {}),
...(P ? { x: P.x, y: P.y, z: P.z } : {}),
status
},
// original message für evtl. weitere Felder
type: msg?.type
const bombPayload: UnknownRecord = {
...(base ?? {}),
...(P ? { x: P.x, y: P.y, z: P.z } : {}),
status,
}
return payload
}
return { bomb: bombPayload, type: msg.type }
}
export default function GameSocket(props: GameSocketProps) {
const { url, onStatus, onMap, onPlayerUpdate, onPlayersAll, onGrenades, onRoundStart, onRoundEnd, onBomb } = props
const wsRef = useRef<WebSocket | 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 dispatch = (msg: any) => {
if (!msg) return;
const dispatch = useCallback(
(msg: UnknownRecord) => {
if (!msg) return
if (msg.type === 'round_start') { onRoundStart?.(); return; }
if (msg.type === 'round_end') { onRoundEnd?.(); return; }
const type = String((msg.type ?? '') as string)
if (msg.type === 'tick') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase());
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}));
const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles;
if (g) onGrenades?.(g);
// 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);
if (type === 'round_start') {
onRoundStart?.()
return
}
if (type === 'round_end') {
onRoundEnd?.()
return
}
onPlayersAll?.(msg);
return;
}
if (type === 'tick') {
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.) ---
if (typeof msg.map === 'string') {
onMap?.(msg.map.toLowerCase());
} else if (msg.map && typeof msg.map.name === 'string') {
onMap?.(msg.map.name.toLowerCase());
}
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.allplayers) onPlayersAll?.(msg);
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg);
if (msg.bomb) {
const b = msg.bomb
if (isObj(b)) onBomb?.(b)
} else {
const synth = extractBombPayload(msg)
if (synth) onBomb?.(synth)
}
const g2 = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles;
if (g2) onGrenades?.(g2);
onPlayersAll?.(msg)
return
}
// Bombe: generische Events + direkte bomb/c4-Payload
const t = String(msg.type || '').toLowerCase();
// non-tick
if (typeof msg.map === 'string') {
onMap?.(msg.map.toLowerCase())
} else if (isObj(msg.map) && typeof (msg.map as UnknownRecord).name === 'string') {
onMap?.(String((msg.map as UnknownRecord).name).toLowerCase())
}
if (msg.bomb || msg.c4) {
onBomb?.(msg); // unverändert weiterreichen
} else if (t.startsWith('bomb_')) {
// NEU: Event ohne bomb-Objekt → mit Position/Status anreichern
const enriched = extractBombPayload(msg);
if (enriched) onBomb?.(enriched);
else onBomb?.(msg); // Fallback: Event trotzdem melden
}
};
if (msg.allplayers) onPlayersAll?.(msg)
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg)
const g2 =
(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) {
onBomb?.(msg)
} else if (t.startsWith('bomb_')) {
const enriched = extractBombPayload(msg)
if (enriched) onBomb?.(enriched)
else onBomb?.(msg)
}
},
[onBomb, onGrenades, onMap, onPlayerUpdate, onPlayersAll, onRoundEnd, onRoundStart]
)
useEffect(() => {
if (!url) return
shouldReconnectRef.current = true
// evtl. alte Ressourcen räumen
try { wsRef.current?.close(1000, 'replaced by new /radar visit') } catch {}
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
if (connectTimerRef.current) { window.clearTimeout(connectTimerRef.current); connectTimerRef.current = null }
try {
wsRef.current?.close(1000, 'replaced by new /radar visit')
} catch {}
if (retryRef.current) {
window.clearTimeout(retryRef.current)
retryRef.current = null
}
if (connectTimerRef.current) {
window.clearTimeout(connectTimerRef.current)
connectTimerRef.current = null
}
const connect = () => {
if (!shouldReconnectRef.current) return
@ -175,31 +211,46 @@ export default function GameSocket(props: GameSocketProps) {
}
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (Array.isArray(msg)) msg.forEach(dispatch)
else dispatch(msg)
let parsed: unknown = null
try {
parsed = JSON.parse(String(ev.data ?? ''))
} 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)
return () => {
shouldReconnectRef.current = false
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
}
if (retryRef.current) {
window.clearTimeout(retryRef.current)
retryRef.current = null
}
const ws = wsRef.current
if (ws) {
ws.onclose = null // Reconnect nicht anstoßen
try { ws.close(1000, 'left /radar') } catch {}
ws.onclose = null
try {
ws.close(1000, 'left /radar')
} catch {}
}
onStatus?.('closed')
}
}, [url])
}, [url, dispatch, onStatus])
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
'use client'
import Image from 'next/image'
import StaticEffects from './StaticEffects';
import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';
import { contrastStroke } from './lib/helpers';
import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types';
type AvatarEntry = { avatar?: string; notFound?: boolean } | undefined
export default function RadarCanvas({
activeMapKey,
currentSrc, onImgLoad, onImgError,
imgSize,
worldToPx, unitsToPx,
players, grenades, trails, deathMarkers,
useAvatars, avatarById, hoveredPlayerId, setHoveredPlayerId,
useAvatars, avatarById, hoveredPlayerId,
myTeam,
beepState, bombFinal10,
bomb,
@ -30,9 +33,8 @@ export default function RadarCanvas({
trails: Trail[];
deathMarkers: DeathMarker[];
useAvatars: boolean;
avatarById: Record<string, any>;
avatarById: Record<string, AvatarEntry>;
hoveredPlayerId: string | null;
setHoveredPlayerId: (id: string|null)=>void;
myTeam: 'T'|'CT'|string|null;
beepState: {key:number;dur:number}|null;
bombFinal10: boolean;
@ -50,9 +52,7 @@ export default function RadarCanvas({
}
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')
// Jedes Wort kapitalisieren
const pretty = spaced.replace(/\b\w/g, c => c.toUpperCase())
return (
@ -66,13 +66,16 @@ export default function RadarCanvas({
{currentSrc ? (
<div className="absolute inset-0">
<img
<Image
key={currentSrc}
src={currentSrc}
alt={activeMapKey ?? 'map'}
className="absolute inset-0 h-full w-full object-contain object-center"
onLoad={(e) => onImgLoad(e.currentTarget)}
onError={onImgError}
fill
sizes="100vw"
style={{ objectFit: 'contain', objectPosition: 'center' }}
onLoadingComplete={(img) => onImgLoad(img)}
onError={() => onImgError()}
priority={false}
/>
{imgSize && (
@ -117,10 +120,6 @@ export default function RadarCanvas({
{grenades.filter(shouldShowGrenade).map((g) => {
const P = worldToPx(g.x, g.y);
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
if (g.phase === 'projectile') {
@ -181,7 +180,7 @@ export default function RadarCanvas({
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 avatarUrl = useAvatars ? (p.id.toUpperCase().startsWith('BOT:') ? BOT_ICON : (avatarFromStore || DEFAULT_AVATAR)) : null;
const isAvatar = !!avatarUrl;

View File

@ -28,6 +28,11 @@ type Grenade = {
effectTimeSec?: number
lifeElapsedMs?: 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 }
@ -62,7 +67,6 @@ function seedRng(seed: string) {
};
}
export default function StaticEffects({
grenades,
bomb,
@ -111,7 +115,7 @@ export default function StaticEffects({
// ── TIMER: Immer lokal bei 20s starten, unabhängig von Serverzeiten
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 timerSecs = Math.ceil(timerLeftMs / 1000)
const timerAlpha = Math.min(1, timerLeftMs / 1000) // in letzter Sekunde ausblenden
@ -230,78 +234,75 @@ export default function StaticEffects({
)
}
const molotovNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
// 1.0 = wie jetzt. 1.3..1.8 = größerer Radius
const FIRE_RADIUS_MULT = 2
const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
// 1.0 = wie jetzt. 1.3..1.8 = größerer Radius
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)
const coverPx = rPx * FIRE_RADIUS_MULT // „Radius“ der Fläche
const W = Math.max(26, coverPx * 1.45) // Breite des Feuers
const H = W * 1.20 // Höhe der Säule
const RISE = H * (10/12) // Aufstiegsweg der Partikel
// Größen analog zum CodePen (Container ~10em breit, Rise ~10em)
const coverPx = rPx * FIRE_RADIUS_MULT // „Radius“ der Fläche
const W = Math.max(26, coverPx * 1.45) // Breite des Feuers
const H = W * 1.20 // Höhe der Säule
const RISE = H * (10/12) // Aufstiegsweg der Partikel
// Partikel (wie im Pen: 50 Stück, identische Größe)
const PARTS = Math.min(70, Math.round(50 * FIRE_RADIUS_MULT)) // Dichte mitwachsen lassen
const DUR_MS = 1000
const PART_SIZE = W * 0.50
const rnd = seedRng(`${g.id}-${g.spawnedAt ?? 0}`)
// Partikel (wie im Pen: 50 Stück, identische Größe)
const PARTS = Math.min(70, Math.round(50 * FIRE_RADIUS_MULT)) // Dichte mitwachsen lassen
const DUR_MS = 1000
const PART_SIZE = W * 0.50
const rnd = seedRng(`${g.id}-${g.spawnedAt ?? 0}`)
// Defs für Verlauf & Blur
const gradPartId = `molo-fire-grad-${g.id}`
const blurPartId = `molo-fire-blur-${g.id}`
// Defs für Verlauf & Blur
const gradPartId = `molo-fire-grad-${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 (
<g key={g.id}>
<defs>
<radialGradient id={gradPartId} cx="50%" cy="50%" r="50%">
<stop offset="20%" stopColor="rgb(255,80,0)" stopOpacity="1" />
<stop offset="70%" stopColor="rgb(255,80,0)" stopOpacity="0" />
</radialGradient>
<filter id={blurPartId} x="-50%" y="-50%" width="200%" height="200%">
{/* entspricht blur(0.02em) */}
<feGaussianBlur stdDeviation={Math.max(0.6, W * 0.02)} />
</filter>
</defs>
return (
<g key={g.id}>
<defs>
<radialGradient id={gradPartId} cx="50%" cy="50%" r="50%">
<stop offset="20%" stopColor="rgb(255,80,0)" stopOpacity="1" />
<stop offset="70%" stopColor="rgb(255,80,0)" stopOpacity="0" />
</radialGradient>
<filter id={blurPartId} x="-50%" y="-50%" width="200%" height="200%">
{/* entspricht blur(0.02em) */}
<feGaussianBlur stdDeviation={Math.max(0.6, W * 0.02)} />
</filter>
</defs>
{/* Partikel exakt bei P, über die Breite verteilt */}
<g transform={`translate(${P.x}, ${P.y})`} style={{ ['--rise' as any]: `${Math.round(RISE)}px` }}>
{Array.from({ length: PARTS }).map((_, i) => {
// „left: calc((100% - partSize) * (i/parts))“
const leftX = (i / PARTS) * (W - PART_SIZE) - (W/2 - PART_SIZE/2)
return (
<g key={i} transform={`translate(${leftX}, 0)`}>
<circle
cx={0}
cy={0}
r={PART_SIZE / 2}
fill={`url(#${gradPartId})`}
filter={`url(#${blurPartId})`}
style={{
mixBlendMode: 'screen' as any,
animation: `molotovFireRise ${DUR_MS}ms ease-in infinite`,
animationDelay: `${Math.round(DUR_MS * rnd())}ms`,
transformBox: 'fill-box',
transformOrigin: 'center',
opacity: 0
}}
/>
</g>
)
})}
{/* Partikel exakt bei P, über die Breite verteilt */}
<g transform={`translate(${P.x}, ${P.y})`} style={riseStyle}>
{Array.from({ length: PARTS }).map((_, i) => {
// „left: calc((100% - partSize) * (i/parts))“
const leftX = (i / PARTS) * (W - PART_SIZE) - (W/2 - PART_SIZE/2)
return (
<g key={i} transform={`translate(${leftX}, 0)`}>
<circle
cx={0}
cy={0}
r={PART_SIZE / 2}
fill={`url(#${gradPartId})`}
filter={`url(#${blurPartId})`}
style={{
mixBlendMode: 'screen',
animation: `molotovFireRise ${DUR_MS}ms ease-in infinite`,
animationDelay: `${Math.round(DUR_MS * rnd())}ms`,
transformBox: 'fill-box',
transformOrigin: 'center',
opacity: 0
}}
/>
</g>
)
})}
</g>
</g>
</g>
)
}
)
}
const decoyNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y)
@ -359,7 +360,7 @@ export default function StaticEffects({
cx={P.x} cy={P.y} r={rBase}
fill="none"
stroke={bombFinal10 ? '#ef4444' : '#f59e0b'}
strokeWidth={2} // vorher 3
strokeWidth={2}
style={{
transformBox: 'fill-box',
transformOrigin: 'center',
@ -410,8 +411,8 @@ export default function StaticEffects({
if (g.kind === 'smoke') return smokeNode(g)
if (g.kind === 'molotov' || g.kind === 'incendiary') {
const spreaded = (g as any).spreaded === true || ((g as any).flamesCount ?? 0) > 0
if (!spreaded) return null // << Nur zeigen, wenn wirklich Flames vorhanden/spreaded
const spreaded = g.spreaded === true || ((g.flamesCount ?? 0) > 0)
if (!spreaded) return null // Nur zeigen, wenn wirklich Flames vorhanden/spreaded
return molotovNode(g)
}
@ -444,7 +445,7 @@ export default function StaticEffects({
50% { transform: translateY(-2px) scale(1.02); }
100% { transform: translateY(0px) scale(1); }
}
@keyframes molotovFireRise {
@keyframes molotovFireRise {
0% { opacity: 0; transform: translateY(0) scale(1); }
25% { opacity: 1; }
100% { opacity: 0; transform: translateY(calc(var(--rise, 80px) * -1)) scale(0); }

View File

@ -1,7 +1,7 @@
// /src/app/[locale]/components/radar/TeamSidebar.tsx
'use client'
import React, { useEffect, useState } from 'react'
import Image from 'next/image'
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'
export type Team = 'T' | 'CT'
@ -39,7 +39,7 @@ const ShieldIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
</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 }) => (
<span
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 }) {
const out: { src: string; title: string; key: string }[] = []
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({
team, teamId, players, align = 'left', onHoverPlayer, score, oppScore
}: {
@ -203,35 +207,39 @@ export default function TeamSidebar({
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 (
<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 */}
<div className="flex items-center justify-between text-xs uppercase tracking-wide opacity-80">
<span className={`font-semibold flex items-center gap-2 ${teamColor}`}>
{/* 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>
<span className="flex items-center gap-2">
{/* Score-Pill in der Sidebar */}
{(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">
{score}<span className="opacity-60 mx-1">:</span>{oppScore}
</span>
)}
{/* Alive-Count bleibt */}
<span className="tabular-nums">{aliveCount}/{players.length}</span>
</span>
</div>
<div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
{sorted.map(p=>{
void avatarVer
const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100)
const armor = clamp(p.armor ?? 0, 0, 100)
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)
? BOT_ICON
: (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`}>
{/* 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)]' : ''}`}>
<img
src={avatarUrl}
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' : ''}`}
width={48} height={48} loading="lazy"
/>
<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}
alt={p.name || p.id}
fill
sizes="48px"
className="object-contain"
unoptimized
/>
</span>
</div>
<div className={`flex-1 min-w-0 flex flex-col ${isRight ? 'items-end' : 'items-start'}`}>
{/* Kopfzeile: Name & Gear je Seite */}
{/* Kopfzeile: Name & Gear */}
{!isRight ? (
<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}
</span>
<span className="inline-flex items-center gap-1">
@ -292,7 +304,7 @@ export default function TeamSidebar({
).map(icon => (
icon.key === 'c4'
? <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>
</div>
@ -304,16 +316,16 @@ export default function TeamSidebar({
).map(icon => (
icon.key === 'c4'
? <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 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}
</span>
</div>
)}
{/* Waffenzeile: Primär (links/rechts je nach align) — Sekundär+Messer auf der Gegenseite */}
{/* Waffenzeile */}
<div
className={[
'mt-1 w-full flex items-center',
@ -325,65 +337,67 @@ export default function TeamSidebar({
{/* Primär */}
{primIcon && (
<div className="flex items-center gap-3 shrink-0">
<img
<IconImg
src={primIcon}
alt={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
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30 '
: 'grayscale brightness-90 contrast-75 opacity-90 '
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30'
: 'grayscale brightness-90 contrast-75 opacity-90'
}`}
/>
</div>
)}
{/* Sekundär + Messer (als Gruppe) */}
{/* Sekundär + Messer */}
{(secIcon || knifeIcon) && (
<div
className={[
'flex items-center gap-2',
// Wenn keine Primärwaffe existiert, die Gruppe passend ausrichten
!primIcon ? (isRight ? 'justify-end' : 'justify-start') : ''
].join(' ')}
>
<div className={['flex items-center gap-2', !primIcon ? (isRight ? 'justify-end' : 'justify-start') : ''].join(' ')}>
{secIcon && (
<img
<IconImg
src={secIcon}
alt={sec?.name ?? 'secondary'}
title={sec?.name ?? 'secondary'}
className={`h-10 w-10 transition filter 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'
w={40}
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 && (
<img
<IconImg
src={knifeIcon}
alt={knife?.name ?? 'knife'}
title={knife?.name ?? 'knife'}
className={`h-10 w-10 transition filter ${
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'
}`}
w={40}
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>
{/* Granaten: ohne Count; Icon mehrfach je Anzahl */}
{/* Granaten */}
<div className={`mt-2 flex items-center gap-1 ${isRight ? 'justify-start' : 'justify-end'}`}>
{GRENADE_DISPLAY_ORDER.flatMap(k=>{
const c = p.grenades?.[k] ?? 0
if (!c) return []
const src = grenadeIconFromKey(k)
return Array.from({ length: c }, (_,i)=>( // je Anzahl ein Icon
<img key={`${k}-${i}`} src={src} alt={k} title={k} className="h-4 w-4 opacity-90" />
return Array.from({ length: c }, (_,i)=>(
<IconImg key={`${k}-${i}`} src={src} alt={k} title={k} w={16} h={16} />
))
})}
</div>
{/* HP / Armor Bars (SVG-Icons weiß) */}
{/* HP / Armor Bars */}
<div className="mt-2 w-full space-y-2">
{/* HP */}
<div
@ -393,16 +407,13 @@ export default function TeamSidebar({
>
<div
className={[
// nur der Füllbalken bekommt ggf. das Blinken
'h-full transition-[width] duration-300 ease-out',
hp > 66 ? 'bg-green-500' : hp > 20 ? 'bg-amber-500' : 'bg-red-500',
hp > 0 && hp <= 20 ? 'animate-hpPulse' : ''
].join(' ')}
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)]" />
{/* Label */}
<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="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; };
}, [activeMapKey]);
const { folderKey, imageCandidates } = useMemo(() => {
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] };
const imageCandidates = useMemo(() => {
if (!activeMapKey) return [] as string[];
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey;
const base = `/assets/img/radar/${activeMapKey}`;
return {
folderKey: short,
imageCandidates: [
`${base}/de_${short}_radar_psd.png`,
`${base}/de_${short}_lower_radar_psd.png`,
`${base}/de_${short}_v1_radar_psd.png`,
`${base}/de_${short}_radar.png`,
],
};
return [
`${base}/de_${short}_radar_psd.png`,
`${base}/de_${short}_lower_radar_psd.png`,
`${base}/de_${short}_v1_radar_psd.png`,
`${base}/de_${short}_radar.png`,
];
}, [activeMapKey]);
const currentSrc = imageCandidates[srcIdx];

View File

@ -1,15 +1,33 @@
// /src/app/[locale]/components/radar/hooks/useRadarState.ts
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 { asNum, mapTeam, steamIdOf } from '../lib/helpers';
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) {
// WS / Map
const [radarWsStatus, setGameWsStatus] = useState<WsStatus>('idle');
const [activeMapKey, setActiveMapKey] = useState<string | null>(null);
const [activeMapKey, setActiveMapKey] = useState<string | null>(null);
// Spieler
const playersRef = useRef<Map<string, PlayerState>>(new Map());
@ -19,7 +37,7 @@ export function useRadarState(mySteamId: string | null) {
// Deaths
const deathSeqRef = useRef(0);
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
const grenadesRef = useRef<Map<string, Grenade>>(new Map());
@ -37,10 +55,14 @@ export function useRadarState(mySteamId: string | null) {
// Score + Phase
const [roundPhase, setRoundPhase] =
useState<'freezetime'|'live'|'bomb'|'over'|'warmup'|'unknown'>('unknown');
const roundEndsAtRef = 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 });
useState<'freezetime' | 'live' | 'bomb' | 'over' | 'warmup' | 'unknown'>('unknown');
const roundEndsAtRef = 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 [score, setScore] = useState<Score>({ ct: 0, t: 0, round: null });
// flush-batching
@ -58,16 +80,23 @@ export function useRadarState(mySteamId: string | null) {
}, 66);
};
useEffect(() => () => {
if (flushTimer.current != null) { window.clearTimeout(flushTimer.current); flushTimer.current = null; }
}, []);
useEffect(
() => () => {
if (flushTimer.current != null) {
window.clearTimeout(flushTimer.current);
flushTimer.current = null;
}
},
[]
);
const myTeam = useMemo<'T'|'CT'|string|null>(() => {
// ⚠️ nur von mySteamId abhängig (playersRef.current wird direkt gelesen)
const myTeam = useMemo<'T' | 'CT' | string | null>(() => {
if (!mySteamId) return 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();
if (steamId) {
if (deathSeenRef.current.has(steamId)) return;
@ -79,8 +108,8 @@ export function useRadarState(mySteamId: string | null) {
const addDeathMarkerFor = (id: string, xNow: number, yNow: number) => {
const last = lastAlivePosRef.current.get(id);
const x = Number.isFinite(last?.x) ? last!.x : xNow;
const y = Number.isFinite(last?.y) ? last!.y : yNow;
const x = Number.isFinite(last?.x) ? (last as { x: number; y: number }).x : xNow;
const y = Number.isFinite(last?.y) ? (last as { x: number; y: number }).y : yNow;
addDeathMarker(x, y, id);
};
@ -104,66 +133,87 @@ export function useRadarState(mySteamId: string | null) {
const updateBombFromPlayers = () => {
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) {
bombRef.current = {
x: carrier.x, y: carrier.y, z: carrier.z,
x: carrier.x,
y: carrier.y,
z: carrier.z,
status: 'carried',
changedAt: bombRef.current?.status === 'carried'
? bombRef.current.changedAt
: Date.now(),
changedAt:
bombRef.current?.status === 'carried' ? bombRef.current.changedAt : Date.now(),
};
}
};
// ---- Player Upsert (gekürzt Logik aus deiner Datei) --------------
function upsertPlayer(e:any) {
const id = steamIdOf(e); if (!id) return;
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates;
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 z = asNum(e.z ?? (Array.isArray(pos) ? pos?.[2] : pos?.z), 0);
/* ---------- Player-Upsert (typsicher) ---------- */
function upsertPlayer(e: unknown) {
const src = getObj(e);
if (!src) return;
const id = steamIdOf(src);
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;
const hpProbe = asNum(e.hp ?? e.health ?? e.state?.health, NaN);
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 });
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 =
(typeof e.activeWeapon === 'string' && e.activeWeapon) ||
(e.activeWeapon?.name ?? null) ||
(Array.isArray(e.weapons)
? (e.weapons.find((w:any) => (w?.state ?? '').toLowerCase() === 'active')?.name ?? null)
: null);
(typeof src.activeWeapon === 'string' && src.activeWeapon) ||
(getObj(src.activeWeapon)?.name as string | undefined) ||
(isObj(activeFromArr) ? (activeFromArr.name as string | undefined) : undefined) ||
null;
const stateObj = getObj(src.state);
playersRef.current.set(id, {
id,
name: e.name ?? old?.name ?? null,
team: mapTeam(e.team ?? old?.team),
x, y, z,
yaw: Number.isFinite(Number(e.yaw)) ? Number(e.yaw) : (old?.yaw ?? null),
name: (src.name as string | undefined) ?? old?.name ?? null,
team: mapTeam(src.team ?? old?.team),
x,
y,
z,
yaw: Number.isFinite(num(src.yaw)) ? (num(src.yaw) as number) : (old?.yaw ?? null),
alive: nextAlive,
hasBomb: Boolean(e.hasBomb) || Boolean(old?.hasBomb),
hp: Number.isFinite(hpProbe) ? hpProbe : (old?.hp ?? null),
armor: Number.isFinite(asNum(e.armor ?? e.state?.armor, NaN)) ? asNum(e.armor ?? e.state?.armor, NaN) : (old?.armor ?? null),
helmet: (e.helmet ?? e.hasHelmet ?? e.state?.helmet) ?? (old?.helmet ?? null),
defuse: (e.defuse ?? e.hasDefuse ?? e.hasDefuser ?? e.state?.defusekit) ?? (old?.defuse ?? null),
hasBomb: Boolean(src.hasBomb) || Boolean(old?.hasBomb),
hp: Number.isFinite(hpProbe) ? (hpProbe as number) : (old?.hp ?? null),
armor:
Number.isFinite(asNum(src.armor ?? stateObj?.armor, NaN))
? (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,
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,
});
}
// ---- Handlers für GameSocket ---------------------------------------
const handlePlayersAll = (msg:any) => {
const pcd = msg?.phase ?? msg?.phase_countdowns;
const phase = String(pcd?.phase ?? '').toLowerCase();
/* ---------- Handlers für GameSocket ---------- */
const handlePlayersAll = (msg: unknown) => {
const m = getObj(msg) ?? {};
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);
}
@ -171,118 +221,180 @@ export function useRadarState(mySteamId: string | null) {
const sec = Number(pcd.phase_ends_in);
if (Number.isFinite(sec)) {
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) {
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;
bombEndsAtRef.current = null;
defuseRef.current = { by: null, hasKit: false, endsAt: null };
}
// Spieler (gekürzt, robust genug)
const apObj = msg?.allplayers;
const apArr = Array.isArray(msg?.players) ? msg.players : null;
const upsertFromPayload = (p:any) => {
const id = steamIdOf(p); if (!id) return;
const pos = p.position ?? p.pos ?? p.location ?? p.coordinates ?? p.eye ?? p.pos;
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] }
: typeof pos === 'object' ? pos : { x: p.x, y: p.y, z: p.z };
const { x=0, y=0, z=0 } = xyz;
const hpNum = Number(p?.state?.health ?? p?.hp);
const isAlive = Number.isFinite(hpNum) ? hpNum > 0 : (playersRef.current.get(id)?.alive ?? true);
// Spieler aus allplayers/players
const apObj = getObj(m.allplayers);
const apArr = getArr(m.players);
const upsertFromPayload = (p: unknown) => {
const o = getObj(p);
if (!o) return;
const id = steamIdOf(o);
if (!id) return;
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);
const prev = playersRef.current.get(id);
playersRef.current.set(id, {
id,
name: p?.name ?? playersRef.current.get(id)?.name ?? null,
team: mapTeam(p?.team ?? playersRef.current.get(id)?.team),
x, y, z,
yaw: playersRef.current.get(id)?.yaw ?? null,
name: (o.name as string | undefined) ?? prev?.name ?? null,
team: mapTeam(o.team ?? prev?.team),
x,
y,
z,
yaw: prev?.yaw ?? null,
alive: isAlive,
hasBomb: Boolean(playersRef.current.get(id)?.hasBomb),
hp: Number.isFinite(hpNum) ? hpNum : (playersRef.current.get(id)?.hp ?? null),
armor: playersRef.current.get(id)?.armor ?? null,
helmet: playersRef.current.get(id)?.helmet ?? null,
defuse: playersRef.current.get(id)?.defuse ?? null,
activeWeapon: playersRef.current.get(id)?.activeWeapon ?? null,
weapons: playersRef.current.get(id)?.weapons ?? null,
nades: playersRef.current.get(id)?.nades ?? null,
hasBomb: Boolean(prev?.hasBomb),
hp: Number.isFinite(hpNum ?? NaN) ? (hpNum as number) : (prev?.hp ?? null),
armor: prev?.armor ?? null,
helmet: prev?.helmet ?? null,
defuse: prev?.defuse ?? null,
activeWeapon: prev?.activeWeapon ?? null,
weapons: prev?.weapons ?? 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);
// Scores (robust, gekürzt)
const pick = (v:any)=> 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 t = pick(msg?.score?.t) ?? pick(msg?.scores?.t) ?? pick(msg?.map?.team_t?.score) ?? 0;
const rnd= pick(msg?.round) ?? pick(msg?.rounds?.played) ?? pick(msg?.map?.round) ?? null;
// Scores
const pick = (v: unknown) => (Number.isFinite(Number(v)) ? Number(v) : null);
const ct =
pick(getObj(m.score)?.ct) ??
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 });
scheduleFlush();
};
const handleGrenades = (g:any) => {
const handleGrenades = (g: unknown) => {
const list = normalizeGrenades(g);
const now = Date.now();
// Trails nur für eigene Projektile
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>();
for (const it of mine) {
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];
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
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);
}
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 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) {
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;
scheduleFlush();
};
const handleBomb = (normalizeBomb:(b:any)=>BombState|null) => (b:any) => {
const prev = bombRef.current;
const nb = normalizeBomb(b);
if (!nb) return;
const withPos = {
x: Number.isFinite(nb.x) ? nb.x : (prev?.x ?? 0),
y: Number.isFinite(nb.y) ? nb.y : (prev?.y ?? 0),
z: Number.isFinite(nb.z) ? nb.z : (prev?.z ?? 0),
const handleBomb =
(normalizeBomb: (b: unknown) => BombState | null) =>
(b: unknown) => {
const prev = bombRef.current;
const nb = normalizeBomb(b);
if (!nb) return;
const withPos = {
x: Number.isFinite(nb.x) ? nb.x : prev?.x ?? 0,
y: Number.isFinite(nb.y) ? nb.y : prev?.y ?? 0,
z: Number.isFinite(nb.z) ? nb.z : prev?.z ?? 0,
};
const sameStatus = prev && prev.status === nb.status;
bombRef.current = {
...withPos,
status: nb.status,
changedAt: sameStatus ? prev!.changedAt : Date.now(),
};
scheduleFlush();
};
const sameStatus = prev && prev.status === nb.status;
bombRef.current = { ...withPos, status: nb.status, changedAt: sameStatus ? prev!.changedAt : Date.now() };
scheduleFlush();
};
return {
// state
radarWsStatus, setGameWsStatus,
activeMapKey, setActiveMapKey,
players, playersRef, hoveredPlayerId, setHoveredPlayerId,
grenades, trails, deathMarkers,
radarWsStatus,
setGameWsStatus,
activeMapKey,
setActiveMapKey,
players,
playersRef,
hoveredPlayerId,
setHoveredPlayerId,
grenades,
trails,
deathMarkers,
bomb,
roundPhase, roundEndsAtRef, bombEndsAtRef, defuseRef,
roundPhase,
roundEndsAtRef,
bombEndsAtRef,
defuseRef,
score,
myTeam,

View File

@ -31,16 +31,22 @@ export function defaultLifeMs(kind: Grenade['kind'], phase: Grenade['phase'] | n
/* ───────── 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']> = {
smoke: 'smoke', smokegrenade: 'smoke',
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',
flash: 'flash', flashbang: 'flash',
decoy: 'decoy'
};
const asNum = (n: any, d = NaN) => {
const asNum = (n: unknown, d = NaN) => {
const v = Number(n);
return Number.isFinite(v) ? v : d;
};
@ -51,19 +57,26 @@ const parseVec3String = (str?: string) => {
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
const pos = g.pos ?? g.position ?? g.location ?? g.coordinates ?? g.origin ?? [g.x, g.y, g.z];
if (Array.isArray(pos)) {
return { x: asNum(pos[0]), y: asNum(pos[1]), z: asNum(pos[2], 0) };
const posSrc =
g.pos ?? g.position ?? g.location ?? g.coordinates ?? g.origin ?? [g.x, g.y, g.z];
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) };
}
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) };
};
@ -78,13 +91,18 @@ export function teamOfGrenade(
}
/** Liefert eine normalisierte Liste von Grenades. */
export function normalizeGrenades(raw: any): Grenade[] {
const arr = Array.isArray(raw) ? raw : Object.values(raw ?? {});
export function normalizeGrenades(raw: unknown): Grenade[] {
const src = getObj(raw);
const arr = Array.isArray(raw) ? (raw as unknown[]) : Object.values(src ?? {});
const out: Grenade[] = [];
for (const g of arr) {
for (const gi of arr) {
const g = getObj(gi) ?? {};
// 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';
// Position
@ -92,53 +110,62 @@ export function normalizeGrenades(raw: any): Grenade[] {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
// Phase
const phaseRaw = String(g.phase ?? g.state ?? g.status ?? '').toLowerCase();
const hasEffectHints = typeof g.effectTimeSec === 'number' || typeof g.lifeElapsedMs === 'number' || typeof g.expiresAt === 'number';
let phase: Grenade['phase'] =
phaseRaw.includes('effect') || hasEffectHints ? 'effect'
: phaseRaw.includes('explode') ? 'exploded'
: 'projectile';
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 phase: Grenade['phase'] =
phaseRaw.includes('effect') || hasEffectHints
? 'effect'
: phaseRaw.includes('explode')
? 'exploded'
: 'projectile';
// Heading (aus velocity/forward)
let headingRad: number | null = null;
const vel = g.vel ?? g.velocity ?? g.dir ?? g.forward;
if (vel && Number.isFinite(vel.x) && Number.isFinite(vel.y)) {
const vel = getObj(g.vel) ?? getObj(g.velocity) ?? getObj(g.dir) ?? getObj(g.forward);
if (vel && Number.isFinite(asNum(vel.x)) && Number.isFinite(asNum(vel.y))) {
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);
}
// 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
const team = g.team === 'T' || g.team === 'CT' ? g.team : null;
const radius = Number.isFinite(Number(g.radius)) ? Number(g.radius) : null;
const spawnedAt = Number.isFinite(Number(g.spawnedAt ?? g.t)) ? Number(g.spawnedAt ?? g.t) : Date.now();
const ownerId = g.ownerId ?? g.owner ?? g.thrower ?? g.player ?? g.userid ?? null;
const team = g.team === 'T' || g.team === 'CT' ? (g.team as 'T' | 'CT') : null;
const radius = Number.isFinite(asNum(g.radius)) ? Number(g.radius) : null;
const spawnedAt = Number.isFinite(asNum(g.spawnedAt ?? g.t))
? 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)
let effectTimeSec = typeof g.effectTimeSec === 'number' ? g.effectTimeSec : undefined;
let lifeElapsedMs = typeof g.lifeElapsedMs === 'number' ? g.lifeElapsedMs : undefined;
let lifeLeftMs = typeof g.lifeLeftMs === 'number' ? g.lifeLeftMs : undefined;
let expiresAt = typeof g.expiresAt === 'number' ? g.expiresAt : undefined;
let effectTimeSec =
typeof g.effectTimeSec === 'number' ? (g.effectTimeSec as number) : undefined;
let lifeElapsedMs =
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
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. */
/* ── Smoke lokal um +2s verlängern ─────────────────────────────── */
if (kind === 'smoke' && phase === 'effect') {
const base = defaultLifeMs('smoke', 'effect'); // 21_000
const total = base + SMOKE_LINGER_MS; // 23_000
const base = defaultLifeMs('smoke', 'effect'); // 21_000
const total = base + SMOKE_LINGER_MS; // 23_000
const now = Date.now();
if (typeof effectTimeSec === 'number') {
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);
lifeElapsedMs = elapsedAdj;
@ -148,20 +175,16 @@ export function normalizeGrenades(raw: any): Grenade[] {
const expLocal = now + left;
expiresAt = Math.max(expiresAt ?? 0, expLocal);
} else if (typeof lifeElapsedMs === 'number') {
// Wir kennen die verstrichene Zeit → Rest = total - elapsed
const left = Math.max(0, total - lifeElapsedMs);
lifeLeftMs = Math.max(lifeLeftMs ?? 0, left);
expiresAt = Math.max(expiresAt ?? 0, now + (lifeLeftMs ?? 0));
} else if (typeof lifeLeftMs === 'number') {
// Wir kennen nur die Restzeit → +2s addieren
lifeLeftMs = Math.max(0, lifeLeftMs + SMOKE_LINGER_MS);
expiresAt = Math.max(expiresAt ?? 0, now + lifeLeftMs);
} else if (typeof expiresAt === 'number') {
// Nur expiresAt bekannt → um +2s schieben
expiresAt = expiresAt + SMOKE_LINGER_MS;
lifeLeftMs = Math.max(0, expiresAt - now);
} else {
// Keine Zeitangaben → aus Spawn + total ableiten
const exp = spawnedAt + total;
expiresAt = exp;
lifeElapsedMs = Math.max(0, now - spawnedAt);
@ -171,14 +194,21 @@ export function normalizeGrenades(raw: any): Grenade[] {
}
out.push({
id, kind, x, y, z: Number.isFinite(z) ? z : 0,
id,
kind,
x,
y,
z: Number.isFinite(z) ? z : 0,
radius,
expiresAt: expiresAt ?? null,
team, phase, headingRad, spawnedAt,
ownerId: ownerId ? String(ownerId) : null,
team,
phase,
headingRad,
spawnedAt,
ownerId,
effectTimeSec,
lifeElapsedMs,
lifeLeftMs
lifeLeftMs,
});
}

View File

@ -2,80 +2,118 @@
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 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) {
const h = hex.replace('#','');
const r = parseInt(h.slice(0,2),16)/255;
const g = parseInt(h.slice(2,4),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 L = 0.2126*toL(r) + 0.7152*toL(g) + 0.0722*toL(b);
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 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 L = 0.2126 * toL(r) + 0.7152 * toL(g) + 0.0722 * toL(b);
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 === 3 || t === 'CT' || t === 'ct') return 'CT';
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 (Array.isArray(v)) {
const x = Number(v[0]), y = Number(v[1]);
return isFiniteXY(x,y) ? {x,y} : null;
const x = Number(v[0]),
y = Number(v[1]);
return isFiniteXY(x, y) ? { x, y } : null;
}
if (typeof v === 'string') {
const [xs,ys] = v.split(','); const x = Number(xs), y = Number(ys);
return isFiniteXY(x,y) ? {x,y} : null;
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;
const o = getObj(v);
const x = Number(o?.x),
y = Number(o?.y);
return isFiniteXY(x, y) ? { x, y } : null;
}
export const steamIdOf = (src: any): string | null => {
const raw = src?.steamId ?? src?.steam_id ?? src?.steamid ?? src?.id ?? src?.entityId ?? src?.entindex;
const s = raw != null ? String(raw) : '';
if (/^\d{17}$/.test(s)) return s;
const name = (src?.name ?? src?.playerName ?? '').toString().trim();
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 (s && s !== '0' && s.toUpperCase() !== 'BOT') return s;
if (rawStr && rawStr !== '0' && rawStr.toUpperCase() !== 'BOT') return rawStr;
return null;
};
export const normalizeDeg = (d: number) => (d % 360 + 360) % 360;
export function defaultWorldToPx(imgSize: {w:number;h:number}|null): Mapper {
export function defaultWorldToPx(imgSize: { w: number; h: number } | null): Mapper {
return (xw, yw) => {
if (!imgSize) return { x: 0, y: 0 };
const R = 4096;
const span = Math.min(imgSize.w, imgSize.h);
const k = span / (2 * R);
return { x: imgSize.w/2 + xw*k, y: imgSize.h/2 - yw*k };
return { x: imgSize.w / 2 + xw * k, y: imgSize.h / 2 - yw * k };
};
}
export function parseOverviewJson(j: any): Overview | null {
const posX = Number(j?.posX ?? j?.pos_x);
const posY = Number(j?.posY ?? j?.pos_y);
const scale = Number(j?.scale);
const rotate = Number(j?.rotate ?? 0);
export function parseOverviewJson(j: unknown): Overview | null {
const o = getObj(j) ?? {};
const posX = Number(o?.posX ?? (o as UnknownRecord)['pos_x']);
const posY = Number(o?.posY ?? (o as UnknownRecord)['pos_y']);
const scale = Number(o?.scale);
const rotate = Number(o?.rotate ?? 0);
if (![posX, posY, scale].every(Number.isFinite)) return null;
return { posX, posY, scale, rotate };
}
export function parseValveKvOverview(txt: string): Overview | null {
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 posX = pick('pos_x'), posY = pick('pos_y'), scale = pick('scale');
const r = pick('rotate'); const rotate = Number.isFinite(r) ? r : 0;
const pick = (k: string) => {
const m = clean.match(new RegExp(`"\\s*${k}\\s*"\\s*"([^"]+)"`));
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;
return { posX, posY, scale, rotate };
}

View File

@ -1,13 +1,15 @@
// /src/app/[locale]/components/settings/account/AppearanceSettings.tsx
'use client'
import { useTheme } from 'next-themes'
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() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Übersetzungen
const tSettings = useTranslations('settings')
@ -18,67 +20,95 @@ export default function AppearanceSettings() {
if (!mounted) return null
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: 'dark', label: tSettings("sections.account.page.AppearanceSettings.theme.dark"), img: 'account-dark-image.svg' },
]
{
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: 'dark',
label: tSettings('sections.account.page.AppearanceSettings.theme.dark'),
img: 'account-dark-image.svg',
},
] as const
return (
<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="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">
{tSettings("sections.account.page.AppearanceSettings.name")}
{tSettings('sections.account.page.AppearanceSettings.name')}
</label>
</div>
<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">
{tSettings("sections.account.page.AppearanceSettings.description")}
{tSettings('sections.account.page.AppearanceSettings.description')}
</p>
<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>
<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>
<div className="mt-5">
<div className="grid grid-cols-3 gap-x-2 sm:gap-x-4">
{options.map(({ id, label, img }) => {
const isChecked = theme === id
{options.map(({ id, label, img }) => {
const isChecked = theme === id
const src = img ? `/assets/img/themes/${img}` : '/assets/img/logos/cs2.webp'
return (
<label
key={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
${isChecked ? 'ring-1 ring-blue-600 dark:ring-blue-500' : 'ring-1 ring-gray-200 dark:ring-neutral-700'}`}
>
<input
type="radio"
id={`theme-${id}`}
name="theme-mode"
value={id}
className="hidden"
checked={isChecked}
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" />
<span
className={`py-3 px-2 text-sm font-semibold rounded-b-xl
${isChecked
? 'bg-blue-600 text-white'
: 'text-gray-800 dark:text-neutral-200'
}`}
return (
<label
key={id}
htmlFor={`theme-${id}`}
className={[
'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(' ')}
>
{label}
</span>
</label>
)
})}
<input
type="radio"
id={`theme-${id}`}
name="theme-mode"
value={id}
className="hidden"
checked={isChecked}
onChange={() => setTheme(id)}
/>
<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
className={[
'py-3 px-2 text-sm font-semibold rounded-b-xl',
isChecked ? 'bg-blue-600 text-white' : 'text-gray-800 dark:text-neutral-200',
].join(' ')}
>
{label}
</span>
</label>
)
})}
</div>
</div>
</div>

View File

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

View File

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

View File

@ -1,10 +1,9 @@
'use client'
import {useEffect, useMemo, useRef, useState} from 'react'
import Popover from '../../Popover'
import { useEffect, useMemo, useRef, useState } from 'react'
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 = [
'UTC',
'Europe/Berlin', 'Europe/Vienna', 'Europe/Zurich',
@ -14,16 +13,21 @@ const FALLBACK_TIMEZONES = [
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'America/Sao_Paulo',
'Asia/Tokyo', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Kolkata',
'Australia/Sydney'
'Australia/Sydney',
]
function getTimeZones(): string[] {
// @ts-ignore Node/Browser, je nach Runtime verfügbar
if (typeof Intl.supportedValuesOf === 'function') {
// typ-sicher prüfen, ohne ts-ignore
const maybeSupportedValuesOf = (Intl as unknown as { supportedValuesOf?: (k: string) => unknown[] }).supportedValuesOf
if (typeof maybeSupportedValuesOf === 'function') {
try {
// @ts-ignore
return Intl.supportedValuesOf('timeZone') as string[]
} catch {}
const list = maybeSupportedValuesOf('timeZone')
if (Array.isArray(list) && list.every((z) => typeof z === 'string')) {
return list as string[]
}
} catch {
// ignore and fall back
}
}
return FALLBACK_TIMEZONES
}
@ -50,7 +54,7 @@ export default function UserSettings() {
try {
const res = await fetch('/api/user/timezone', { cache: 'no-store' })
const data = await res.json().catch(() => ({}))
const tz = data?.timeZone ?? null
const tz = (data as { timeZone?: string })?.timeZone ?? null
setTimeZone(tz)
setInitialTz(tz)
} 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) => {
// laufende Anfrage abbrechen
inFlight.current?.abort()
const ctrl = new AbortController()
inFlight.current = ctrl
@ -77,22 +80,23 @@ export default function UserSettings() {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timeZone: tz }),
signal: ctrl.signal
signal: ctrl.signal,
})
if (!res.ok) {
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)
setSavedOk(true)
setTouched(false) // <- hinzu
// kleines Auto-Reset des „Gespeichert“-Hinweises
setTouched(false)
window.setTimeout(() => setSavedOk(null), 2000)
} catch (e: any) {
if (e?.name === 'AbortError') return
} catch (e: unknown) {
// 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)
setSavedOk(false)
setErrorMsg(e?.message ?? 'Save failed')
setErrorMsg(msg)
} finally {
setSaving(false)
}
@ -105,7 +109,7 @@ export default function UserSettings() {
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
debounceTimer.current = window.setTimeout(() => {
persist(timeZone ?? null)
void persist(timeZone ?? null)
}, 400) as unknown as number
return () => {
@ -116,34 +120,29 @@ export default function UserSettings() {
return (
<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">
{/* Label + Hilfe */}
<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">
{tSettings('sections.user.timezone-label')}
</label>
</div>
{/* Eingabe */}
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
<div className="flex items-center gap-2">
<select
id="user-timezone"
value={timeZone ?? ''} // bleibt kontrolliert
value={timeZone ?? ''} // kontrollierter Input
onChange={(e) => { setTimeZone(e.target.value || null); setTouched(true) }}
disabled={saving || loading} // während Lade-/Speicherphase sperren
aria-busy={loading ? 'true' : undefined} // a11y
disabled={saving || loading}
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"
>
{loading ? (
// Nur während des Ladens:
<option value="" disabled>
{tCommon("loading")}...
{tCommon('loading')}
</option>
) : (
<>
<option value="">
{tSettings('sections.user.timezone-system')}
</option>
<option value="">{tSettings('sections.user.timezone-system')}</option>
{timeZones.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
@ -151,7 +150,6 @@ export default function UserSettings() {
)}
</select>
{/* Live-Status rechts (optional) */}
<span className="text-xs min-w-[80px] text-right">
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving')}</span>}
{savedOk === true && <span className="text-teal-600"> {tCommon('saved')}</span>}

View File

@ -1,7 +1,7 @@
// app/[locale]/settings/_components/PrivacySettings.tsx
// /src/app/[locale]/components/settings/privacy/PrivacySettings.tsx
'use client'
import {useEffect, useRef, useState} from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
export default function PrivacySettings() {
@ -26,7 +26,10 @@ export default function PrivacySettings() {
try {
const res = await fetch('/api/user/privacy', { cache: 'no-store' })
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)
setInitial(value)
} catch (e) {
@ -52,20 +55,24 @@ export default function PrivacySettings() {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ canBeInvited: value }),
signal: ctrl.signal
signal: ctrl.signal,
})
if (!res.ok) {
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)
setSavedOk(true)
window.setTimeout(() => setSavedOk(null), 2000)
} catch (e: any) {
if (e?.name === 'AbortError') return
} catch (e: unknown) {
// 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)
setSavedOk(false)
setErrorMsg(e?.message ?? 'Save failed')
setErrorMsg(msg)
} finally {
setSaving(false)
}
@ -76,7 +83,7 @@ export default function PrivacySettings() {
if (canBeInvited === initial) return
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
debounceTimer.current = window.setTimeout(() => {
persist(canBeInvited)
void persist(canBeInvited)
}, 400) as unknown as number
return () => {
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
@ -85,28 +92,23 @@ export default function PrivacySettings() {
return (
<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">
{/* Label-Spalte */}
<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">
{tSettings('sections.privacy.invites.label')}
</span>
</div>
{/* Inhalt-Spalte */}
<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">
{/* Toggle */}
<button
type="button"
disabled={loading || saving}
onClick={() => setCanBeInvited(v => !v)}
onClick={() => setCanBeInvited((v) => !v)}
className={[
'relative inline-flex h-6 w-11 items-center rounded-full transition',
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(' ')}
aria-pressed={canBeInvited}
aria-label={tSettings('sections.privacy.invites.label')}
@ -119,15 +121,12 @@ export default function PrivacySettings() {
/>
</button>
{/* Rechts: Hilfs-Text + Status NEBENeinander */}
<div className="min-w-0 flex-1">
<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">
{tSettings('sections.privacy.invites.help')}
</p>
{/* Status rechts daneben, bleibt in einer Zeile */}
<div className="ml-auto text-xs whitespace-nowrap" aria-live="polite">
{loading && (
<span className="text-gray-500 dark:text-neutral-400">
@ -140,9 +139,7 @@ export default function PrivacySettings() {
</span>
)}
{savedOk === true && (
<span className="text-teal-600">
{tCommon('saved') ?? 'Gespeichert'}
</span>
<span className="text-teal-600"> {tCommon('saved') ?? 'Gespeichert'}</span>
)}
{savedOk === false && (
<span className="text-red-600">
@ -155,7 +152,7 @@ export default function PrivacySettings() {
</div>
</div>
</div>
</div>
</div>
</div>
)
}

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 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') {
init.dispatcher = insecure
}

View File

@ -1,7 +1,242 @@
export default function Page() {
return (
<>
<h1>Home</h1>
</>
);
// /src/app/[locale]/page.tsx
'use client'
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 showComm = !!u.communityBanned
const showEcon = !!u.economyBan && u.economyBan !== 'none'
const showLastBan = typeof u.daysSinceLastBan === 'number'
const hasAnyBan = showVac || showGameBan || showComm || showEcon
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 ?? ''}`}
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>
)}
</div>

View File

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

View File

@ -1,11 +1,6 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
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 Card from '../components/Card'
@ -18,9 +13,7 @@ type Match = {
}
export default function MatchesPage() {
const { data: session } = useSession()
const [matches, setMatches] = useState<Match[]>([])
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
const [, setMatches] = useState<Match[]>([])
useEffect(() => {
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 (
<Card maxWidth='auto'>
<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 AuthCodeSettings from '../../components/settings/account/AuthCodeSettings'
import LatestKnownCodeSettings from '../../components/settings/account/ShareCodeSettings'
@ -9,17 +7,6 @@ import LatestKnownCodeSettings from '../../components/settings/account/ShareCode
export default async function AccountSection() {
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 (
<section id="account" className="scroll-mt-16 pb-10">
<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 { decrypt, encrypt } from '@/lib/crypto'
export async function GET(req: NextRequest) {
export async function GET() {
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId

View File

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

View File

@ -50,18 +50,12 @@ export async function GET(req: NextRequest) {
const steamId = /* session?.user?.id o.ä. */ null
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({
where: { steamId },
data: {
faceitId: me.guid ?? null,
faceitNickname: me.nickname ?? 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'
export const runtime = 'nodejs'
@ -42,7 +42,7 @@ async function findCurrentMatch() {
return upcoming
}
export async function GET(_req: NextRequest) {
export async function GET() {
const match = await findCurrentMatch()
if (!match) {
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
export async function GET(_req: NextRequest) {
export async function GET() {
const session = await getServerSession(sessionAuthOptions)
const meSteamId = (session?.user as { steamId?: string })?.steamId
@ -36,7 +36,7 @@ export async function GET(_req: NextRequest) {
}
// ✅ Alle Notifications auf "gelesen" setzen
export async function PUT(_req: NextRequest) {
export async function PUT() {
const session = await getServerSession(sessionAuthOptions)
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 { getServerSession } from 'next-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'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
export async function POST() {
try {
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId

View File

@ -2,9 +2,9 @@
import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth'
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)
if (!session?.user?.steamId) {

View File

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

View File

@ -1,13 +1,13 @@
// /src/app/api/stats/[steamId]/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import type { AsyncParams } from '@/types/next' // ← nutzt deinen Typ
import type { AsyncParams } from '@/types/next'
export async function GET(
_req: Request,
ctx: AsyncParams<{ steamId: string }>
) {
const { steamId } = await ctx.params // ← params auflösen
const { steamId } = await ctx.params
if (!steamId) {
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
@ -59,13 +59,15 @@ export async function GET(
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 stats = matches.map((entry) => {
const rounds =
(entry.stats as any)?.rounds ??
// (entry.match as any)?.roundCount ??
null
const rounds = readRounds(entry.stats /* | { roundCount?: unknown } */)
return {
date: entry.match?.demoDate?.toISOString().split('T')[0] ?? FallbackDate,
kills: entry.stats?.kills ?? 0,

View File

@ -1,8 +1,8 @@
import { getServerSession } from 'next-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)
if (!session || !session.user?.steamId) {

View File

@ -18,8 +18,30 @@ export async function GET(
const team = await prisma.team.findUnique({
where: { id: teamId },
include: {
leader: true,
invites: { include: { user: true } },
leader: {
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,
name: u.name ?? 'Unbekannt',
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 })
}
/* ▸ Alle Teammitglieder (alt + neu) als Zielgruppe ------------------------- */
const allPlayers = [
...team.activePlayers,
...team.inactivePlayers,
]
/* ▸ SSE-Push --------------------------------------------------------------- */
await sendServerSSEMessage({
type : 'team-member-joined',

View File

@ -5,6 +5,12 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
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) {
try {
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 })
}
// user dem Team zuordnen
await prisma.user.update({
where: { steamId: leader },
data: { teamId: newTeam.id },
})
// 🔔 (optional) persistente Notification
const note = await prisma.notification.create({
data: {
steamId: leader,
@ -50,7 +54,6 @@ export async function POST(req: NextRequest) {
},
})
// ➜ Sofort an Notification-Center
await sendServerSSEMessage({
type: 'notification',
targetUserIds: [leader],
@ -61,14 +64,12 @@ export async function POST(req: NextRequest) {
createdAt: note.createdAt.toISOString(),
})
// ✅ ➜ HIER: Self-Refresh für den Ersteller
await sendServerSSEMessage({
type: 'self-updated', // <— stelle sicher, dass dein Client darauf hört
type: 'self-updated',
targetUserIds: [leader],
})
}
// (Optional) Broadcasts
await sendServerSSEMessage({
type: 'team-created',
title: 'Team erstellt',
@ -85,8 +86,8 @@ export async function POST(req: NextRequest) {
{ message: 'Team erstellt', team: newTeam },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (error: any) {
if (error?.code === 'P2002') {
} catch (error: unknown) {
if (isKnownPrismaError(error) && error.code === 'P2002') {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
}
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'
// 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) {
try {
const { teamId, newName } = await req.json()
const raw = await req.json().catch(() => null)
const { teamId, newName } = parseBody(raw)
const name = (newName ?? '').trim()
if (!teamId || !name) {
@ -24,19 +41,19 @@ export async function POST(req: NextRequest) {
}
// Umbenennen (Unique-Constraint beachten)
let updated
try {
updated = await prisma.team.update({
where: { id: teamId },
data: { name },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
} catch (e: any) {
if (e?.code === 'P2002') {
const updated = await prisma.team.update({
where: { id: teamId },
data: { name },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
}).catch((e: unknown) => {
if (isKnownPrismaError(e) && e.code === 'P2002') {
return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 })
}
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
const targets = Array.from(new Set(
@ -49,7 +66,7 @@ export async function POST(req: NextRequest) {
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) {
const created = await Promise.all(
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({
type: 'team-renamed',
teamId,
message: text,
newName: updated.name,
})
// Optionaler Failsafe-Reload als Broadcast
await sendServerSSEMessage({
type: 'team-updated',
teamId,
})
await sendServerSSEMessage({ type: 'team-updated', teamId })
return NextResponse.json(
{ success: true, team: { id: updated.id, name: updated.name } },
{ 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)
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'
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) {
try {
const { teamId, newLeaderSteamId } = await req.json()
const raw = await req.json().catch(() => null)
const { teamId, newLeaderSteamId } = parseBody(raw)
if (!teamId || !newLeaderSteamId) {
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 })
// Bereits Leader eines anderen Teams?
// Bereits Leader eines anderen Teams?
const otherLedTeam = await prisma.team.findFirst({
where: { leaderId: newLeaderSteamId, NOT: { id: teamId } },
select: { id: true, name: true }
@ -45,7 +67,7 @@ export async function POST(req: NextRequest) {
data : { leaderId: newLeaderSteamId },
})
// --- Benachrichtigung & SSE unverändert ---
// Benachrichtigungen & SSE
const newLeader = await prisma.user.findUnique({
where : { steamId: newLeaderSteamId },
select: { name: true },
@ -109,15 +131,20 @@ export async function POST(req: NextRequest) {
}
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
} catch (error: any) {
// Falls du zusätzlich im Prisma-Schema @@unique([leaderId]) gesetzt hast:
if (error?.code === 'P2002' && error?.meta?.target?.includes('leaderId')) {
return NextResponse.json(
{ message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' },
{ status: 400 }
)
} catch (err: unknown) {
// unique-Constraint auf leaderId behandeln (falls im Schema vorhanden)
if (isKnownPrismaError(err) && err.code === 'P2002') {
const target = err.meta?.target; // type: unknown
if (Array.isArray(target) && target.includes('leaderId')) {
return NextResponse.json(
{ message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' },
{ 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 { prisma } from '@/lib/prisma'
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 type { TeamJoinPolicy } from '@/types/team'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const ALLOWED = ['REQUEST', 'INVITE_ONLY'] as const
type AllowedPolicy = (typeof ALLOWED)[number]
// Einmal zentral definieren und später benutzen
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) {
try {
// ⬇️ statt getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const meId = session?.user?.steamId
@ -22,14 +32,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const body = await req.json().catch(() => ({} as any))
const teamId: string | undefined = body?.teamId
const joinPolicy: TeamJoinPolicy | undefined = body?.joinPolicy
const raw: unknown = await req.json().catch(() => null)
const { teamId, joinPolicy } = parseBody(raw)
if (!teamId) {
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 })
}
@ -64,7 +74,6 @@ export async function POST(req: NextRequest) {
select: { id: true, joinPolicy: true },
})
// Fire-and-forget SSE
Promise.resolve().then(() =>
sendServerSSEMessage({
type: 'team-updated',

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
// /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 { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
export async function GET() {
try {
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId

View File

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

View File

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

View File

@ -1,5 +1,5 @@
// /src/app/api/user/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { sessionAuthOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
@ -17,7 +17,7 @@ type SlimPlayer = {
// Hilfstyp für Session, damit TS weiß, dass es user?.steamId gibt
type SessionShape = { user?: { steamId?: string } } | null;
export async function GET(_req: NextRequest) {
export async function GET() {
const session = (await getServerSession(sessionAuthOptions)) as SessionShape;
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 (typeof v !== 'string' || v.trim() === '') return false
// Validate via Intl.supportedValuesOf if available
// @ts-ignore
const list: string[] | undefined = typeof Intl.supportedValuesOf === 'function'
// @ts-ignore
? Intl.supportedValuesOf('timeZone')
: undefined
return list ? list.includes(v) : true // wenn kein Support: großzügig erlauben
}
export async function GET(req: NextRequest) {
export async function GET() {
try {
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
@ -29,8 +27,9 @@ export async function GET(req: NextRequest) {
select: { timeZone: true }
})
return NextResponse.json({ timeZone: user?.timeZone ?? null })
} catch (e: any) {
console.error('[TZ][GET] failed', e)
} catch (e: unknown) {
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 })
}
}
@ -55,8 +54,9 @@ export async function PUT(req: NextRequest) {
})
return NextResponse.json({ ok: true })
} catch (e: any) {
console.error('[TZ][PUT] failed', e)
} catch (e: unknown) {
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 })
}
}

View File

@ -1,5 +1,6 @@
// /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 { MAP_OPTIONS } from '@/lib/mapOptions'
@ -202,5 +203,6 @@ export async function POST(req: NextRequest) {
if (typeof body.onlyActive === 'boolean') {
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"
},
"output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -431,7 +431,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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