updated for build
This commit is contained in:
parent
4b3a8ae323
commit
5a3faaf1fe
@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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])
|
||||
|
||||
/* ---- Drag’n’Drop-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
|
||||
|
||||
|
||||
@ -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',
|
||||
}: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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]}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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`}
|
||||
|
||||
@ -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 (A–Z)</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
|
||||
})
|
||||
}}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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. Team‑Farbe brauchst
|
||||
align?: 'left' | 'right'
|
||||
maxWidth?: CardWidth
|
||||
}
|
||||
|
||||
export default function PlayerCard({
|
||||
player,
|
||||
team,
|
||||
align = 'left',
|
||||
maxWidth = 'sm',
|
||||
}: Props) {
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
type TabProps = {
|
||||
name: string
|
||||
href: string
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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" />
|
||||
)
|
||||
|
||||
@ -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[]>([])
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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' } })
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user