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'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
// Wichtig: Promises wie in .next/types
|
|
||||||
type PageProps = {
|
|
||||||
params?: Promise<{ locale?: string }>
|
|
||||||
searchParams?: Promise<Record<string, string | string[] | undefined>>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureConfig() {
|
async function ensureConfig() {
|
||||||
return prisma.serverConfig.upsert({
|
return prisma.serverConfig.upsert({
|
||||||
where: { id: 'default' },
|
where: { id: 'default' },
|
||||||
@ -29,16 +23,20 @@ async function ensureConfig() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminServerPage(_props: PageProps) {
|
export default async function AdminServerPage() {
|
||||||
// Falls du locale brauchst:
|
|
||||||
// const { locale } = (await _props.params) ?? {}
|
|
||||||
|
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const me = session?.user as any | undefined
|
|
||||||
|
|
||||||
if (!me?.steamId || !me?.isAdmin) {
|
type AdminUser = { steamId: string; isAdmin: boolean }
|
||||||
|
const isAdminUser = (u: unknown): u is AdminUser => {
|
||||||
|
const r = u as Record<string, unknown> | null | undefined
|
||||||
|
return !!r && typeof r.steamId === 'string' && typeof r.isAdmin === 'boolean'
|
||||||
|
}
|
||||||
|
|
||||||
|
const meUnknown = session?.user
|
||||||
|
if (!isAdminUser(meUnknown)) {
|
||||||
redirect('/')
|
redirect('/')
|
||||||
}
|
}
|
||||||
|
const me = meUnknown // ab hier getypt: AdminUser
|
||||||
|
|
||||||
const [cfg, meUser] = await Promise.all([
|
const [cfg, meUser] = await Promise.all([
|
||||||
ensureConfig(),
|
ensureConfig(),
|
||||||
@ -73,7 +71,7 @@ export default async function AdminServerPage(_props: PageProps) {
|
|||||||
|
|
||||||
if (clientApiKey) {
|
if (clientApiKey) {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { steamId: me?.steamId },
|
where: { steamId: me.steamId },
|
||||||
data: { pterodactylClientApiKey: clientApiKey },
|
data: { pterodactylClientApiKey: clientApiKey },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// /src/app/(admin)/admin/teams/[teamId]/TeamAdminClient.tsx
|
// /src/app/(admin)/admin/teams/[teamId]/TeamAdminClient.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||||
import TeamMemberView from '../../../components/TeamMemberView'
|
import TeamMemberView from '../../../components/TeamMemberView'
|
||||||
@ -21,7 +21,6 @@ export default function TeamAdminClient({ teamId }: Props) {
|
|||||||
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
||||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||||
|
|
||||||
|
|
||||||
const fetchTeam = useCallback(async () => {
|
const fetchTeam = useCallback(async () => {
|
||||||
const result = await reloadTeam(teamId)
|
const result = await reloadTeam(teamId)
|
||||||
if (result) setTeam(result)
|
if (result) setTeam(result)
|
||||||
@ -32,19 +31,18 @@ export default function TeamAdminClient({ teamId }: Props) {
|
|||||||
if (teamId) fetchTeam()
|
if (teamId) fetchTeam()
|
||||||
}, [teamId, fetchTeam])
|
}, [teamId, fetchTeam])
|
||||||
|
|
||||||
// 👇 WICHTIG: subscribe by steamId (passt zu deinem SSE-Server)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
if (!steamId) return
|
if (!steamId) return
|
||||||
|
|
||||||
// ggf. .env nutzen: z. B. NEXT_PUBLIC_SSE_URL=http://localhost:3001
|
|
||||||
const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001'
|
const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001'
|
||||||
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`
|
const url = `${base}/events?steamId=${encodeURIComponent(steamId)}`
|
||||||
let es: EventSource | null = new EventSource(url, { withCredentials: false })
|
let es: EventSource | null = new EventSource(url, { withCredentials: false })
|
||||||
|
|
||||||
const onTeamUpdated = (ev: MessageEvent) => {
|
// Listener als EventListener typisieren
|
||||||
|
const onTeamUpdated: EventListener = (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data)
|
const msg = JSON.parse((ev as MessageEvent).data as string)
|
||||||
if (msg.teamId === teamId) {
|
if (msg.teamId === teamId) {
|
||||||
fetchTeam()
|
fetchTeam()
|
||||||
}
|
}
|
||||||
@ -56,11 +54,9 @@ export default function TeamAdminClient({ teamId }: Props) {
|
|||||||
es.addEventListener('team-updated', onTeamUpdated)
|
es.addEventListener('team-updated', onTeamUpdated)
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
// sanftes Reconnect
|
|
||||||
es?.close()
|
es?.close()
|
||||||
es = null
|
es = null
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// neuer EventSource
|
|
||||||
const next = new EventSource(url, { withCredentials: false })
|
const next = new EventSource(url, { withCredentials: false })
|
||||||
next.addEventListener('team-updated', onTeamUpdated)
|
next.addEventListener('team-updated', onTeamUpdated)
|
||||||
next.onerror = () => { next.close() }
|
next.onerror = () => { next.close() }
|
||||||
@ -69,7 +65,7 @@ export default function TeamAdminClient({ teamId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
es?.removeEventListener('team-updated', onTeamUpdated as any)
|
es?.removeEventListener('team-updated', onTeamUpdated)
|
||||||
es?.close()
|
es?.close()
|
||||||
}
|
}
|
||||||
}, [session?.user?.steamId, teamId, fetchTeam])
|
}, [session?.user?.steamId, teamId, fetchTeam])
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /src/app/[locale]/components/Button.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ReactNode, forwardRef, useState, useRef, useEffect, ButtonHTMLAttributes } from 'react'
|
import { ReactNode, forwardRef, useState, useRef, useEffect, ButtonHTMLAttributes } from 'react'
|
||||||
@ -40,7 +42,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [direction, setDirection] = useState<'up' | 'down'>('down')
|
const [, setDirection] = useState<'up' | 'down'>('down')
|
||||||
const localRef = useRef<HTMLButtonElement>(null)
|
const localRef = useRef<HTMLButtonElement>(null)
|
||||||
const buttonRef = (ref as React.RefObject<HTMLButtonElement>) || localRef
|
const buttonRef = (ref as React.RefObject<HTMLButtonElement>) || localRef
|
||||||
|
|
||||||
@ -159,7 +161,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|||||||
setDirection(spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? 'up' : 'down')
|
setDirection(spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? 'up' : 'down')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [open, dropDirection])
|
}, [buttonRef, open, dropDirection])
|
||||||
|
|
||||||
const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
const next = !open
|
const next = !open
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// components/Chart.tsx
|
// /src/app/[locale]/components/Chart.tsx
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
@ -16,6 +16,8 @@ import {
|
|||||||
type ChartData,
|
type ChartData,
|
||||||
type ChartOptions,
|
type ChartOptions,
|
||||||
type Plugin,
|
type Plugin,
|
||||||
|
type ChartConfiguration,
|
||||||
|
type RadialLinearScaleOptions,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
RadialLinearScale,
|
RadialLinearScale,
|
||||||
@ -120,7 +122,7 @@ function getImage(src: string): HTMLImageElement {
|
|||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _Chart<TType extends ChartJSType = ChartJSType>(
|
function ChartInner<TType extends ChartJSType = ChartJSType>(
|
||||||
props: BaseProps<TType>,
|
props: BaseProps<TType>,
|
||||||
ref: React.Ref<ChartHandle>
|
ref: React.Ref<ChartHandle>
|
||||||
) {
|
) {
|
||||||
@ -141,16 +143,15 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
onReady,
|
onReady,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
|
||||||
radarIcons,
|
// ⚠️ bewusst NICHT destrukturieren: radarIcons, radarIconLabelColor, radarAddRingOffset
|
||||||
|
// werden innerhalb von plugin/mergedOptions über `props.*` genutzt – so vermeiden wir
|
||||||
|
// “defined but never used”-Warnungen.
|
||||||
radarIconSize = 40,
|
radarIconSize = 40,
|
||||||
radarIconLabels = false,
|
radarIconLabels = false,
|
||||||
radarIconLabelFont = '12px Inter, system-ui, sans-serif',
|
radarIconLabelFont = '12px Inter, system-ui, sans-serif',
|
||||||
radarIconLabelColor = '#ffffff',
|
|
||||||
radarIconLabelMargin = 6,
|
radarIconLabelMargin = 6,
|
||||||
radarHideTicks = false,
|
|
||||||
radarMax,
|
radarMax,
|
||||||
radarStepSize,
|
radarStepSize,
|
||||||
radarAddRingOffset = false,
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
@ -161,7 +162,10 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
const baseData = useMemo<ChartData<TType> | undefined>(() => {
|
const baseData = useMemo<ChartData<TType> | undefined>(() => {
|
||||||
if (data) return data;
|
if (data) return data;
|
||||||
if (!labels || !datasets) return undefined;
|
if (!labels || !datasets) return undefined;
|
||||||
return { labels, datasets: datasets as any } as ChartData<TType>;
|
|
||||||
|
// Datensätze in das erwartete Typfeld casten (ohne `any`)
|
||||||
|
const dsTyped = datasets as unknown as NonNullable<ChartData<TType>['datasets']>;
|
||||||
|
return { labels, datasets: dsTyped } as ChartData<TType>;
|
||||||
}, [data, labels, datasets]);
|
}, [data, labels, datasets]);
|
||||||
|
|
||||||
// ▼ Für RADAR: Daten intern um +20 verschieben (Plot), Original bleibt in Props.
|
// ▼ Für RADAR: Daten intern um +20 verschieben (Plot), Original bleibt in Props.
|
||||||
@ -169,62 +173,91 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
if (!baseData) return baseData;
|
if (!baseData) return baseData;
|
||||||
if (type !== 'radar') return baseData;
|
if (type !== 'radar') return baseData;
|
||||||
|
|
||||||
// flache Kopie, Datensätze klonen und data +20
|
// datasets generisch transformieren (ohne `any`)
|
||||||
const cloned: any = {
|
const dsArray =
|
||||||
...baseData,
|
(baseData.datasets ?? []) as unknown as Array<Record<string, unknown>>;
|
||||||
datasets: (baseData.datasets ?? []).map((ds: any) => {
|
const dsShifted = dsArray.map((ds) => {
|
||||||
// Nur numerische Arrays anfassen
|
const d = (ds.data as unknown[] | undefined)?.map((v) =>
|
||||||
const d = Array.isArray(ds.data)
|
|
||||||
? ds.data.map((v: any) =>
|
|
||||||
typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v
|
typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v
|
||||||
)
|
);
|
||||||
: ds.data;
|
return { ...ds, data: d } as Record<string, unknown>;
|
||||||
|
});
|
||||||
|
|
||||||
return { ...ds, data: d };
|
return {
|
||||||
}),
|
labels: baseData.labels as ChartData<TType>['labels'],
|
||||||
};
|
datasets: dsShifted as unknown as NonNullable<ChartData<TType>['datasets']>,
|
||||||
return cloned;
|
} as ChartData<TType>;
|
||||||
}, [baseData, type]);
|
}, [baseData, type]);
|
||||||
|
|
||||||
/* ---------- Radar Scale ---------- */
|
// ---------- Radar Scale ----------
|
||||||
const radarScaleOpts = useMemo(() => {
|
const radarScaleOpts = useMemo(() => {
|
||||||
if (type !== 'radar') return undefined;
|
if (type !== 'radar') return undefined;
|
||||||
|
|
||||||
const gridColor = 'rgba(255,255,255,0.10)';
|
const gridColor = 'rgba(255,255,255,0.10)';
|
||||||
const angleColor = 'rgba(255,255,255,0.12)';
|
const angleColor = 'rgba(255,255,255,0.12)';
|
||||||
|
|
||||||
const ticks: any = {
|
const ticks: NonNullable<RadialLinearScaleOptions['ticks']> = {
|
||||||
beginAtZero: true,
|
display: true,
|
||||||
showLabelBackdrop: false,
|
|
||||||
color: 'rgba(255,255,255,0.6)',
|
color: 'rgba(255,255,255,0.6)',
|
||||||
|
font: { size: 12 },
|
||||||
|
padding: 0,
|
||||||
backdropColor: 'transparent',
|
backdropColor: 'transparent',
|
||||||
...(radarHideTicks ? { display: false } : {}),
|
backdropPadding: 0,
|
||||||
|
showLabelBackdrop: false,
|
||||||
|
textStrokeColor: 'transparent',
|
||||||
|
textStrokeWidth: 0,
|
||||||
|
z: 0,
|
||||||
|
major: { enabled: false },
|
||||||
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
|
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
|
||||||
|
callback: (value) => String(value),
|
||||||
};
|
};
|
||||||
|
|
||||||
const r: any = {
|
const pointLabels: NonNullable<RadialLinearScaleOptions['pointLabels']> = {
|
||||||
suggestedMin: 0,
|
display: false,
|
||||||
grid: { color: gridColor, lineWidth: 1 },
|
color: '#ffffff',
|
||||||
angleLines: { display: true, color: angleColor, lineWidth: 1 },
|
font: { size: 12 },
|
||||||
|
padding: 0,
|
||||||
|
backdropColor: 'transparent',
|
||||||
|
backdropPadding: 0,
|
||||||
|
borderRadius: 0,
|
||||||
|
centerPointLabels: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ⬇︎ HIER: r als RadialLinearScaleOptions typisieren
|
||||||
|
const r: RadialLinearScaleOptions = {
|
||||||
|
display: true,
|
||||||
|
alignToPixels: false,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
reverse: false,
|
||||||
ticks,
|
ticks,
|
||||||
pointLabels: { display: false },
|
grid: { color: gridColor, lineWidth: 1 },
|
||||||
|
angleLines: {
|
||||||
|
display: true,
|
||||||
|
color: angleColor,
|
||||||
|
lineWidth: 1,
|
||||||
|
borderDash: [],
|
||||||
|
borderDashOffset: 0,
|
||||||
|
},
|
||||||
|
pointLabels,
|
||||||
|
suggestedMin: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// WICHTIG: max anheben, damit +20 nicht abschneidet
|
// ⬇︎ ohne any cast
|
||||||
if (typeof radarMax === 'number') {
|
if (typeof radarMax === 'number') {
|
||||||
r.max = radarMax + RADAR_OFFSET;
|
r.max = radarMax + RADAR_OFFSET;
|
||||||
r.suggestedMax = radarMax + RADAR_OFFSET;
|
r.suggestedMax = radarMax + RADAR_OFFSET;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { r };
|
return { r };
|
||||||
}, [type, radarHideTicks, radarStepSize, radarMax]);
|
}, [type, radarStepSize, radarMax]);
|
||||||
|
|
||||||
/* ---------- Radar Icons Plugin ---------- */
|
/* ---------- Radar Icons Plugin ---------- */
|
||||||
const [radarPlugin] = useState<Plugin<'radar'>>(() => ({
|
const [radarPlugin] = useState<Plugin<'radar'>>(() => ({
|
||||||
id: 'radarIconsPlugin',
|
id: 'radarIconsPlugin',
|
||||||
afterDatasetsDraw(chart) {
|
afterDatasetsDraw(chart) {
|
||||||
const ctx = chart.ctx as CanvasRenderingContext2D;
|
const ctx = chart.ctx as CanvasRenderingContext2D;
|
||||||
const scale: any = (chart as any).scales?.r;
|
const maybeScales = (chart as unknown as { scales?: { r?: { max: number; getPointPositionForValue: (i: number, v: number) => { x: number; y: number } } } }).scales;
|
||||||
|
const scale = maybeScales?.r;
|
||||||
if (!scale) return;
|
if (!scale) return;
|
||||||
|
|
||||||
const lbls = chart.data.labels as string[] | undefined;
|
const lbls = chart.data.labels as string[] | undefined;
|
||||||
@ -233,14 +266,14 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
const icons = props.radarIcons ?? [];
|
const icons = props.radarIcons ?? [];
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
(ctx as any).resetTransform?.();
|
(ctx as unknown as { resetTransform?: () => void }).resetTransform?.();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.rect(0, 0, chart.width, chart.height);
|
ctx.rect(0, 0, chart.width, chart.height);
|
||||||
ctx.clip();
|
ctx.clip();
|
||||||
|
|
||||||
const ca = (chart as any).chartArea as { left:number; right:number; top:number; bottom:number } | undefined;
|
const ca = (chart as unknown as { chartArea?: { left: number; right: number; top: number; bottom: number } }).chartArea;
|
||||||
const cx0 = scale.xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width as number) / 2);
|
const cx0 = (scale as unknown as { xCenter?: number }).xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width as number) / 2);
|
||||||
const cy0 = scale.yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2);
|
const cy0 = (scale as unknown as { yCenter?: number }).yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2);
|
||||||
|
|
||||||
const half = (props.radarIconSize ?? 40) / 2;
|
const half = (props.radarIconSize ?? 40) / 2;
|
||||||
const gap = Math.max(4, props.radarIconLabelMargin ?? 6);
|
const gap = Math.max(4, props.radarIconLabelMargin ?? 6);
|
||||||
@ -251,9 +284,9 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
ctx.fillStyle = props.radarIconLabelColor ?? '#ffffff';
|
ctx.fillStyle = props.radarIconLabelColor ?? '#ffffff';
|
||||||
|
|
||||||
for (let i = 0; i < lbls.length; i++) {
|
for (let i = 0; i < lbls.length; i++) {
|
||||||
const p = scale.getPointPositionForValue(i, scale.max);
|
const p = scale.getPointPositionForValue(i, (scale as unknown as { max: number }).max);
|
||||||
const px = p.x as number;
|
const px = p.x;
|
||||||
const py = p.y as number;
|
const py = p.y;
|
||||||
|
|
||||||
const dx = px - cx0;
|
const dx = px - cx0;
|
||||||
const dy = py - cy0;
|
const dy = py - cy0;
|
||||||
@ -298,16 +331,18 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
|
|
||||||
if (type === 'radar') {
|
if (type === 'radar') {
|
||||||
// Scales zusammenführen
|
// Scales zusammenführen
|
||||||
(o as any).scales = {
|
(o as unknown as { scales?: Record<string, unknown> }).scales = {
|
||||||
...(radarScaleOpts ?? {}),
|
...(radarScaleOpts ?? {}),
|
||||||
...(options?.scales as any),
|
...(options?.scales as unknown as Record<string, unknown>),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tooltip: echten Wert (ohne Offset) anzeigen
|
// Tooltip: echten Wert (ohne Offset) anzeigen
|
||||||
const userTooltip = options?.plugins?.tooltip;
|
const userTooltip = options?.plugins?.tooltip;
|
||||||
const userLabelCb = userTooltip?.callbacks?.label;
|
const userLabelCb = userTooltip?.callbacks?.label as
|
||||||
|
| ((this: unknown, c: unknown) => unknown)
|
||||||
|
| undefined;
|
||||||
|
|
||||||
(o as any).plugins = {
|
(o as unknown as { plugins?: Record<string, unknown> }).plugins = {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
...options?.plugins,
|
...options?.plugins,
|
||||||
@ -315,14 +350,15 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
...userTooltip,
|
...userTooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
...userTooltip?.callbacks,
|
...userTooltip?.callbacks,
|
||||||
label: function (this: any, ctx: any) {
|
label: function (this: unknown, rawCtx: unknown) {
|
||||||
const shifted = Number(ctx.raw);
|
const ctx = rawCtx as { raw?: unknown; dataset?: { label?: string } };
|
||||||
|
const shifted = Number((ctx as { raw?: unknown }).raw);
|
||||||
const original = Number.isFinite(shifted) ? shifted - RADAR_OFFSET : shifted;
|
const original = Number.isFinite(shifted) ? shifted - RADAR_OFFSET : shifted;
|
||||||
const shown = Math.round(original); // ⬅️ hier auf ganze Zahlen runden
|
const shown = Math.round(original);
|
||||||
|
|
||||||
if (typeof userLabelCb === 'function') {
|
if (typeof userLabelCb === 'function') {
|
||||||
const clone = { ...ctx, raw: shown, parsed: { r: shown } };
|
const clone = { ...(ctx as Record<string, unknown>), raw: shown, parsed: { r: shown } };
|
||||||
return (userLabelCb as (this: any, c: any) => any).call(this, clone);
|
return userLabelCb.call(this, clone);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dsLabel = ctx.dataset?.label ? `${ctx.dataset.label}: ` : '';
|
const dsLabel = ctx.dataset?.label ? `${ctx.dataset.label}: ` : '';
|
||||||
@ -330,7 +366,7 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
} as Record<string, unknown>;
|
||||||
|
|
||||||
// Layout-Padding für Außenbeschriftungen (Icons/Labels)
|
// Layout-Padding für Außenbeschriftungen (Icons/Labels)
|
||||||
const fontPx = (() => {
|
const fontPx = (() => {
|
||||||
@ -342,18 +378,18 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
(radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) +
|
(radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) +
|
||||||
6
|
6
|
||||||
);
|
);
|
||||||
const currentPadding = (o as any).layout?.padding ?? {};
|
const currentPadding = (o as unknown as { layout?: { padding?: Record<string, number> } }).layout?.padding ?? {};
|
||||||
(o as any).layout = {
|
(o as unknown as { layout?: { padding?: Record<string, number> } }).layout = {
|
||||||
...(o as any).layout,
|
...(o as unknown as { layout?: Record<string, unknown> }).layout,
|
||||||
padding: {
|
padding: {
|
||||||
top: Math.max(pad, currentPadding.top ?? 0),
|
top: Math.max(pad, (currentPadding as Record<string, number>).top ?? 0),
|
||||||
right: Math.max(pad, currentPadding.right ?? 0),
|
right: Math.max(pad, (currentPadding as Record<string, number>).right ?? 0),
|
||||||
bottom: Math.max(pad, currentPadding.bottom ?? 0),
|
bottom: Math.max(pad, (currentPadding as Record<string, number>).bottom ?? 0),
|
||||||
left: Math.max(pad, currentPadding.left ?? 0),
|
left: Math.max(pad, (currentPadding as Record<string, number>).left ?? 0),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else if (options?.scales) {
|
} else if (options?.scales) {
|
||||||
(o as any).scales = { ...(options.scales as any) };
|
(o as unknown as { scales?: Record<string, unknown> }).scales = { ...(options.scales as unknown as Record<string, unknown>) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return o;
|
return o;
|
||||||
@ -371,7 +407,7 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
const mergedPlugins = useMemo<Plugin<TType>[]>(() => {
|
const mergedPlugins = useMemo<Plugin<TType>[]>(() => {
|
||||||
const list: Plugin<TType>[] = [];
|
const list: Plugin<TType>[] = [];
|
||||||
if (plugins?.length) list.push(...plugins);
|
if (plugins?.length) list.push(...plugins);
|
||||||
if (type === 'radar') list.push(radarPlugin as any);
|
if (type === 'radar') list.push(radarPlugin as unknown as Plugin<TType>);
|
||||||
return list;
|
return list;
|
||||||
}, [plugins, type, radarPlugin]);
|
}, [plugins, type, radarPlugin]);
|
||||||
|
|
||||||
@ -379,7 +415,10 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
const config = useMemo(
|
const config = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type,
|
type,
|
||||||
data: (type === 'radar' ? (shiftedData ?? { labels: [], datasets: [] }) : (baseData ?? { labels: [], datasets: [] })) as ChartData<TType>,
|
data: (type === 'radar'
|
||||||
|
? (shiftedData ?? { labels: [], datasets: [] })
|
||||||
|
: (baseData ?? { labels: [], datasets: [] })
|
||||||
|
) as ChartData<TType>,
|
||||||
options: mergedOptions,
|
options: mergedOptions,
|
||||||
plugins: mergedPlugins,
|
plugins: mergedPlugins,
|
||||||
}),
|
}),
|
||||||
@ -395,7 +434,8 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
|
|
||||||
if (mustRecreate) {
|
if (mustRecreate) {
|
||||||
chartRef.current?.destroy();
|
chartRef.current?.destroy();
|
||||||
chartRef.current = new ChartJS(canvasRef.current, config as any);
|
const cfg = config as unknown as ChartConfiguration;
|
||||||
|
chartRef.current = new ChartJS(canvasRef.current, cfg);
|
||||||
prevTypeRef.current = type;
|
prevTypeRef.current = type;
|
||||||
onReady?.(chartRef.current);
|
onReady?.(chartRef.current);
|
||||||
return () => {
|
return () => {
|
||||||
@ -403,14 +443,16 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [config, type, redraw, onReady]);
|
}, [config, type, redraw, onReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const c = chartRef.current;
|
const c = chartRef.current;
|
||||||
if (!c || redraw || prevTypeRef.current !== type) return;
|
if (!c || redraw || prevTypeRef.current !== type) return;
|
||||||
(c as any).data = (type === 'radar' ? (shiftedData ?? c.data) : (baseData ?? c.data));
|
|
||||||
(c as any).options = mergedOptions;
|
(c as unknown as { data: unknown }).data =
|
||||||
|
(type === 'radar' ? (shiftedData ?? (c as unknown as { data: unknown }).data) : (baseData ?? (c as unknown as { data: unknown }).data));
|
||||||
|
|
||||||
|
(c as unknown as { options: unknown }).options = mergedOptions;
|
||||||
c.update();
|
c.update();
|
||||||
}, [baseData, shiftedData, mergedOptions, type, redraw]);
|
}, [baseData, shiftedData, mergedOptions, type, redraw]);
|
||||||
|
|
||||||
@ -443,8 +485,8 @@ function _Chart<TType extends ChartJSType = ChartJSType>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Chart = forwardRef(_Chart) as <TType extends ChartJSType = ChartJSType>(
|
const Chart = forwardRef(ChartInner) as <TType extends ChartJSType = ChartJSType>(
|
||||||
p: BaseProps<TType> & { ref?: React.Ref<ChartHandle> }
|
p: BaseProps<TType> & { ref?: React.Ref<ChartHandle> }
|
||||||
) => ReturnType<typeof _Chart>;
|
) => ReturnType<typeof ChartInner>;
|
||||||
|
|
||||||
export default Chart;
|
export default Chart;
|
||||||
|
|||||||
@ -1,15 +1,21 @@
|
|||||||
|
// /src/app/[locale]/components/ComboBox.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
type ComboItem = { id: string; label: string }
|
type ComboItem = { id: string; label: string }
|
||||||
|
|
||||||
type ComboBoxProps = {
|
type ComboBoxProps = {
|
||||||
value: string // ausgewählte ID
|
value: string
|
||||||
items: ComboItem[] // { id, label }
|
items: ComboItem[]
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
|
export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const selected = items.find(i => i.id === value)
|
const selected = items.find(i => i.id === value)
|
||||||
|
const listboxId = 'combo-listbox'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="hs-combobox-basic-usage" className="relative" data-hs-combo-box="">
|
<div id="hs-combobox-basic-usage" className="relative" data-hs-combo-box="">
|
||||||
@ -18,16 +24,21 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
|
|||||||
className="py-2.5 sm:py-3 ps-4 pe-9 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"
|
className="py-2.5 sm:py-3 ps-4 pe-9 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"
|
||||||
type="text"
|
type="text"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded="false"
|
aria-haspopup="listbox"
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-expanded={isOpen}
|
||||||
value={selected?.label ?? ''}
|
value={selected?.label ?? ''}
|
||||||
data-hs-combo-box-input=""
|
data-hs-combo-box-input=""
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
className="absolute top-1/2 end-3 -translate-y-1/2"
|
className="absolute top-1/2 end-3 -translate-y-1/2"
|
||||||
aria-expanded="false"
|
aria-expanded={isOpen}
|
||||||
role="button"
|
aria-controls={listboxId}
|
||||||
|
aria-label="Auswahl öffnen"
|
||||||
data-hs-combo-box-toggle=""
|
data-hs-combo-box-toggle=""
|
||||||
|
onClick={() => setIsOpen(o => !o)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500"
|
className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500"
|
||||||
@ -40,24 +51,28 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
|
|||||||
<path d="m7 15 5 5 5-5" />
|
<path d="m7 15 5 5 5-5" />
|
||||||
<path d="m7 9 5-5 5 5" />
|
<path d="m7 9 5-5 5 5" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
id={listboxId}
|
||||||
className="absolute z-50 w-full max-h-72 p-1 mt-1 bg-white border border-gray-200 rounded-lg overflow-y-auto dark:bg-neutral-900 dark:border-neutral-700"
|
className="absolute z-50 w-full max-h-72 p-1 mt-1 bg-white border border-gray-200 rounded-lg overflow-y-auto dark:bg-neutral-900 dark:border-neutral-700"
|
||||||
style={{ display: 'none' }}
|
style={{ display: isOpen ? 'block' : 'none' }}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
data-hs-combo-box-output=""
|
data-hs-combo-box-output=""
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
|
const selectedOpt = item.id === value
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800"
|
className="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800"
|
||||||
role="option"
|
role="option"
|
||||||
|
aria-selected={selectedOpt}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
data-hs-combo-box-output-item=""
|
data-hs-combo-box-output-item=""
|
||||||
data-hs-combo-box-item-stored-data={JSON.stringify({ id: item.id, name: item.label })}
|
data-hs-combo-box-item-stored-data={JSON.stringify({ id: item.id, name: item.label })}
|
||||||
onClick={() => onSelect(item.id)}
|
onClick={() => { onSelect(item.id); setIsOpen(false) }}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center w-full">
|
<div className="flex justify-between items-center w-full">
|
||||||
<span
|
<span
|
||||||
@ -66,7 +81,7 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
|
|||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{item.id === value && (
|
{selectedOpt && (
|
||||||
<span className="hidden hs-combo-box-selected:block">
|
<span className="hidden hs-combo-box-selected:block">
|
||||||
<svg
|
<svg
|
||||||
className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500"
|
className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500"
|
||||||
@ -86,7 +101,8 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,12 +4,9 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useRouter, usePathname } from '@/i18n/navigation'
|
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { format } from 'date-fns'
|
|
||||||
import { de } from 'date-fns/locale'
|
|
||||||
import Switch from '../components/Switch'
|
import Switch from '../components/Switch'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
@ -22,12 +19,33 @@ type Props = { matchType?: string }
|
|||||||
const getTeamLogo = (logo?: string | null) =>
|
const getTeamLogo = (logo?: string | null) =>
|
||||||
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
|
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
|
||||||
|
|
||||||
const toDateKey = (d: Date) => d.toISOString().slice(0, 10)
|
|
||||||
const weekdayDE = new Intl.DateTimeFormat('de-DE', { weekday: 'long' })
|
|
||||||
const weekdayEN = new Intl.DateTimeFormat('en-GB', { weekday: 'long' })
|
|
||||||
|
|
||||||
type TeamOption = { id: string; name: string; logo?: string | null }
|
type TeamOption = { id: string; name: string; logo?: string | null }
|
||||||
|
|
||||||
|
type UnknownRec = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isRecord(v: unknown): v is UnknownRec {
|
||||||
|
return !!v && typeof v === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerLike = {
|
||||||
|
user?: { steamId?: string | null } | null;
|
||||||
|
steamId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TeamMaybeWithPlayers = {
|
||||||
|
id?: string | null;
|
||||||
|
players?: PlayerLike[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toStringOrNull(v: unknown): string | null {
|
||||||
|
return typeof v === 'string' ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(...vals: unknown[]): string {
|
||||||
|
for (const v of vals) if (typeof v === 'string') return v;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */
|
/** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */
|
||||||
function combineLocalDateTime(dateStr: string, timeStr: string) {
|
function combineLocalDateTime(dateStr: string, timeStr: string) {
|
||||||
const [y, m, d] = dateStr.split('-').map(Number)
|
const [y, m, d] = dateStr.split('-').map(Number)
|
||||||
@ -64,14 +82,6 @@ type ZonedParts = {
|
|||||||
year: number; month: number; day: number; hour: number; minute: number;
|
year: number; month: number; day: number; hour: number; minute: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getUserTimeZone(sessionTz?: string): string {
|
|
||||||
return (
|
|
||||||
sessionTz ||
|
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone ||
|
|
||||||
'Europe/Berlin'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts {
|
function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
const parts = new Intl.DateTimeFormat(locale, {
|
const parts = new Intl.DateTimeFormat(locale, {
|
||||||
@ -126,44 +136,42 @@ function isBanStatusFlagged(b?: BanStatus | null): boolean {
|
|||||||
|
|
||||||
/** Liefert Info, ob Match gebannte Spieler enthält (zählt beide Seiten) */
|
/** Liefert Info, ob Match gebannte Spieler enthält (zählt beide Seiten) */
|
||||||
function matchBanInfo(m: Match): { hasBan: boolean; count: number; tooltip: string } {
|
function matchBanInfo(m: Match): { hasBan: boolean; count: number; tooltip: string } {
|
||||||
// a) neues Shape (teamA.players / teamB.players)
|
const teamA = (m.teamA as unknown as TeamMaybeWithPlayers);
|
||||||
const playersA = (m as any)?.teamA?.players ?? []
|
const teamB = (m.teamB as unknown as TeamMaybeWithPlayers);
|
||||||
const playersB = (m as any)?.teamB?.players ?? []
|
|
||||||
|
|
||||||
// b) Fallback: flaches players-Array (falls API alt)
|
const playersA = Array.isArray(teamA?.players) ? teamA.players! : [];
|
||||||
const flat = (m as any)?.players ?? []
|
const playersB = Array.isArray(teamB?.players) ? teamB.players! : [];
|
||||||
|
|
||||||
const all = Array.isArray(playersA) || Array.isArray(playersB)
|
// Fallback: flaches players-Array (ältere API)
|
||||||
? [...(playersA ?? []), ...(playersB ?? [])]
|
const flat = (m as unknown as { players?: PlayerLike[] | null }).players ?? [];
|
||||||
: Array.isArray(flat) ? flat : []
|
|
||||||
|
|
||||||
let count = 0
|
const all: PlayerLike[] =
|
||||||
const lines: string[] = []
|
playersA.length || playersB.length ? [...playersA, ...playersB] : Array.isArray(flat) ? flat : [];
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
const lines: string[] = [];
|
||||||
for (const p of all) {
|
for (const p of all) {
|
||||||
const user = p?.user ?? p // (Fallback falls p schon der User ist)
|
const user = (p?.user as unknown as { name?: string | null; banStatus?: BanStatus | null }) ?? {};
|
||||||
const name = user?.name ?? 'Unbekannt'
|
const name = user?.name ?? 'Unbekannt';
|
||||||
const b: BanStatus | undefined = user?.banStatus
|
const b = user?.banStatus ?? null;
|
||||||
if (isBanStatusFlagged(b)) {
|
if (isBanStatusFlagged(b)) {
|
||||||
count++
|
count++;
|
||||||
const parts: string[] = []
|
const parts: string[] = [];
|
||||||
if (b?.vacBanned) parts.push('VAC aktiv')
|
if (b?.vacBanned) parts.push('VAC aktiv');
|
||||||
if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`)
|
if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`);
|
||||||
if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`)
|
if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`);
|
||||||
if (b?.communityBanned) parts.push('Community')
|
if (b?.communityBanned) parts.push('Community');
|
||||||
if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`)
|
if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`);
|
||||||
if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`)
|
if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`);
|
||||||
lines.push(`${name}: ${parts.join(' · ')}`)
|
lines.push(`${name}: ${parts.join(' · ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hasBan: count > 0, count, tooltip: lines.join('\n') }
|
return { hasBan: count > 0, count, tooltip: lines.join('\n') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function CommunityMatchList({ matchType }: Props) {
|
export default function CommunityMatchList({ matchType }: Props) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const userTZ = useUserTimeZone([session?.user?.steamId])
|
const userTZ = useUserTimeZone([session?.user?.steamId])
|
||||||
const weekdayFmt = useMemo(() =>
|
const weekdayFmt = useMemo(() =>
|
||||||
@ -211,26 +219,36 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
|
|
||||||
const mySteamId = session?.user?.steamId
|
const mySteamId = session?.user?.steamId
|
||||||
|
|
||||||
const isOwnMatch = useCallback((m: any) => {
|
const isOwnMatch = useCallback((m: Match) => {
|
||||||
if (!mySteamId) return false
|
if (!mySteamId) return false;
|
||||||
|
|
||||||
// a) Neues Shape: teamA.players / teamB.players -> p.user.steamId
|
const teamA = (m.teamA as unknown as TeamMaybeWithPlayers);
|
||||||
const inTeamA = m?.teamA?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false
|
const teamB = (m.teamB as unknown as TeamMaybeWithPlayers);
|
||||||
const inTeamB = m?.teamB?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false
|
|
||||||
if (inTeamA || inTeamB) return true
|
|
||||||
|
|
||||||
// b) Manchmal flaches players-Array (falls noch vorhanden)
|
const inTeamA =
|
||||||
const inFlat = m?.players?.some((p: any) =>
|
Array.isArray(teamA?.players) &&
|
||||||
p?.user?.steamId === mySteamId || p?.steamId === mySteamId
|
teamA.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
|
||||||
) ?? false
|
|
||||||
if (inFlat) return true
|
|
||||||
|
|
||||||
// c) Fallback (nur wenn du es noch möchtest): Team-Mitgliedschaft
|
const inTeamB =
|
||||||
|
Array.isArray(teamB?.players) &&
|
||||||
|
teamB.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
|
||||||
|
|
||||||
|
if (inTeamA || inTeamB) return true;
|
||||||
|
|
||||||
|
// Fallback: flaches players-Array
|
||||||
|
const flatPlayers = (m as unknown as { players?: PlayerLike[] | null }).players ?? [];
|
||||||
|
const inFlat =
|
||||||
|
Array.isArray(flatPlayers) &&
|
||||||
|
flatPlayers.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
|
||||||
|
|
||||||
|
if (inFlat) return true;
|
||||||
|
|
||||||
|
// Optionaler Fallback: Team-Mitgliedschaft
|
||||||
const byTeamMembership =
|
const byTeamMembership =
|
||||||
!!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team)
|
!!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team);
|
||||||
|
|
||||||
return byTeamMembership
|
return byTeamMembership;
|
||||||
}, [mySteamId, session?.user?.team])
|
}, [mySteamId, session?.user?.team]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
@ -305,23 +323,23 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
credentials: 'same-origin', // wichtig: Cookies mitnehmen
|
credentials: 'same-origin', // wichtig: Cookies mitnehmen
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
})
|
})
|
||||||
const json = await res.json().catch(() => ({} as any))
|
const json: unknown = await res.json().catch(() => ({}));
|
||||||
|
const raw: unknown =
|
||||||
|
(isRecord(json) && Array.isArray(json.teams)) ? json.teams :
|
||||||
|
(isRecord(json) && Array.isArray(json.data)) ? json.data :
|
||||||
|
(isRecord(json) && Array.isArray(json.items)) ? json.items :
|
||||||
|
Array.isArray(json) ? json : [];
|
||||||
|
|
||||||
// ➜ egal ob {teams: [...]}, {data: [...]}, {items: [...]} oder direkt [...]
|
const arr: UnknownRec[] = Array.isArray(raw) ? (raw as UnknownRec[]) : [];
|
||||||
const raw =
|
|
||||||
Array.isArray(json?.teams) ? json.teams :
|
|
||||||
Array.isArray(json?.data) ? json.data :
|
|
||||||
Array.isArray(json?.items) ? json.items :
|
|
||||||
Array.isArray(json) ? json :
|
|
||||||
[]
|
|
||||||
|
|
||||||
const opts: TeamOption[] = raw
|
const opts: TeamOption[] = arr
|
||||||
.map((t: any) => ({
|
.map((r) => {
|
||||||
id: t.id ?? t._id ?? t.teamId ?? t.uuid ?? '',
|
const id = firstString(r.id, r._id, r.teamId, r.uuid);
|
||||||
name: t.name ?? t.title ?? t.displayName ?? t.tag ?? 'Unbenanntes Team',
|
const name = firstString(r.name, r.title, r.displayName, r.tag) || 'Unbenanntes Team';
|
||||||
logo: t.logo ?? t.logoUrl ?? t.image ?? null,
|
const logo = toStringOrNull(r.logo ?? r.logoUrl ?? r.image) ?? null;
|
||||||
}))
|
return { id, name, logo };
|
||||||
.filter((t: TeamOption) => !!t.id && !!t.name)
|
})
|
||||||
|
.filter((t) => !!t.id && !!t.name);
|
||||||
|
|
||||||
if (!ignore) setTeams(opts)
|
if (!ignore) setTeams(opts)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /src/app/[locale]/components/CreateTeamButton.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, forwardRef } from 'react'
|
import { useState, forwardRef } from 'react'
|
||||||
@ -6,10 +8,22 @@ import Modal from './Modal'
|
|||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
|
||||||
type CreateTeamButtonProps = {
|
type CreateTeamButtonProps = {
|
||||||
/** Optional: Parent kann damit eine Liste refreshen */
|
|
||||||
setRefetchKey?: (key: string) => void
|
setRefetchKey?: (key: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HSOverlayAPI = {
|
||||||
|
close: (el: Element | null) => void
|
||||||
|
};
|
||||||
|
|
||||||
|
type WindowWithHSOverlay = Window & {
|
||||||
|
HSOverlay?: HSOverlayAPI;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateTeamResponse = {
|
||||||
|
team: { name: string };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
|
const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
|
||||||
({ setRefetchKey }, ref) => {
|
({ setRefetchKey }, ref) => {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
@ -23,8 +37,9 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
|
|||||||
const closeCreateModalAndCleanup = () => {
|
const closeCreateModalAndCleanup = () => {
|
||||||
const modalEl = document.getElementById('modal-create-team')
|
const modalEl = document.getElementById('modal-create-team')
|
||||||
|
|
||||||
if (modalEl && (window as any).HSOverlay?.close) {
|
const win = window as WindowWithHSOverlay;
|
||||||
;(window as any).HSOverlay.close(modalEl)
|
if (modalEl && win.HSOverlay?.close) {
|
||||||
|
win.HSOverlay.close(modalEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
@ -56,7 +71,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
|
|||||||
body: JSON.stringify({ teamname, leader: session?.user?.steamId }),
|
body: JSON.stringify({ teamname, leader: session?.user?.steamId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await res.json()
|
const result: CreateTeamResponse = await res.json();
|
||||||
if (!res.ok) throw new Error(result.message || 'Fehler beim Erstellen')
|
if (!res.ok) throw new Error(result.message || 'Fehler beim Erstellen')
|
||||||
|
|
||||||
setStatus('success')
|
setStatus('success')
|
||||||
@ -71,9 +86,10 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
|
|||||||
setRefetchKey?.(Date.now().toString())
|
setRefetchKey?.(Date.now().toString())
|
||||||
})
|
})
|
||||||
}, 800)
|
}, 800)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setStatus('error')
|
setStatus('error');
|
||||||
setMessage(err.message || 'Fehler beim Erstellen des Teams')
|
const msg = err instanceof Error ? err.message : 'Fehler beim Erstellen des Teams';
|
||||||
|
setMessage(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export default function DatePickerWithTime({ value, onChange }: DatePickerWithTi
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newDate = new Date(year, month, value.getDate(), hour, minute);
|
const newDate = new Date(year, month, value.getDate(), hour, minute);
|
||||||
onChange(newDate);
|
onChange(newDate);
|
||||||
}, [hour, minute, year, month]);
|
}, [onChange, value, hour, minute, year, month]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showPicker && buttonRef.current) {
|
if (showPicker && buttonRef.current) {
|
||||||
|
|||||||
@ -1,22 +1,38 @@
|
|||||||
|
// /src/app/[locale]/components/EditButton.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import type { Match as FullMatch } from '@/types/match'
|
||||||
|
|
||||||
export default function EditButton({ match }: { match: any }) {
|
type Leaderish = string | { steamId?: string | null } | null | undefined
|
||||||
|
type TeamLeaderView = { leader?: Leaderish } | null | undefined
|
||||||
|
|
||||||
|
// Wir brauchen nur id, teamA, teamB – und jeweils den leader
|
||||||
|
type MatchForEditButton = Pick<FullMatch, 'id' | 'teamA' | 'teamB'> & {
|
||||||
|
teamA: TeamLeaderView
|
||||||
|
teamB: TeamLeaderView
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaderIdOf(l: Leaderish): string | null {
|
||||||
|
if (!l) return null
|
||||||
|
if (typeof l === 'string') return l
|
||||||
|
return typeof l.steamId === 'string' ? l.steamId : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditButton({ match }: { match: MatchForEditButton }) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
|
const me = session?.user?.steamId ?? null
|
||||||
|
|
||||||
const isLeader =
|
const leaderA = leaderIdOf(match.teamA?.leader)
|
||||||
session?.user?.steamId &&
|
const leaderB = leaderIdOf(match.teamB?.leader)
|
||||||
(session.user.steamId === match.teamA.leader ||
|
const isLeader = !!me && (me === leaderA || me === leaderB)
|
||||||
session.user.steamId === match.teamB.leader)
|
|
||||||
|
|
||||||
if (!isLeader) return null
|
if (!isLeader) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<Link
|
<Link
|
||||||
href={`/matches/${match.id}/edit`}
|
href={`/matches/${encodeURIComponent(String(match.id))}/edit`}
|
||||||
className="inline-block px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 transition"
|
className="inline-block px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 transition"
|
||||||
>
|
>
|
||||||
Match bearbeiten
|
Match bearbeiten
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// /src/app/components/EditMatchMetaModal.tsx
|
// /src/app/[locale]/components/EditMatchMetaModal.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@ -13,6 +13,14 @@ type ZonedParts = {
|
|||||||
year: number; month: number; day: number; hour: number; minute: number;
|
year: number; month: number; day: number; hour: number; minute: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UnknownRec = Record<string, unknown>;
|
||||||
|
const isRecord = (v: unknown): v is UnknownRec => !!v && typeof v === 'object';
|
||||||
|
const isTeamOption = (v: unknown): v is TeamOption =>
|
||||||
|
isRecord(v) &&
|
||||||
|
typeof v.id === 'string' &&
|
||||||
|
typeof v.name === 'string' &&
|
||||||
|
(v.logo == null || typeof v.logo === 'string');
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
show: boolean
|
show: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@ -78,27 +86,16 @@ export default function EditMatchMetaModal({
|
|||||||
defaultTeamAName,
|
defaultTeamAName,
|
||||||
defaultTeamBName,
|
defaultTeamBName,
|
||||||
defaultDateISO,
|
defaultDateISO,
|
||||||
// defaultMap,
|
|
||||||
defaultVoteLeadMinutes = 60,
|
|
||||||
onSaved,
|
onSaved,
|
||||||
defaultBestOf = 3,
|
defaultBestOf = 3,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
/* ───────── Utils ───────── */
|
/* ───────── Utils ───────── */
|
||||||
const normalizeBestOf = (bo: unknown): 3 | 5 => (Number(bo) === 5 ? 5 : 3)
|
const normalizeBestOf = (bo: unknown): 3 | 5 => (Number(bo) === 5 ? 5 : 3)
|
||||||
const toDatetimeLocal = (iso?: string | null) => {
|
|
||||||
if (!iso) return ''
|
|
||||||
const d = new Date(iso)
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
|
||||||
d.getHours()
|
|
||||||
)}:${pad(d.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────── Local state ───────── */
|
/* ───────── Local state ───────── */
|
||||||
const [title, setTitle] = useState(defaultTitle ?? '')
|
const [title, setTitle] = useState(defaultTitle ?? '')
|
||||||
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
||||||
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
||||||
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
|
|
||||||
|
|
||||||
const userTZ = getUserTimeZone();
|
const userTZ = getUserTimeZone();
|
||||||
const initDT = isoToLocalDateTimeStrings(defaultDateISO, userTZ);
|
const initDT = isoToLocalDateTimeStrings(defaultDateISO, userTZ);
|
||||||
@ -150,11 +147,12 @@ export default function EditMatchMetaModal({
|
|||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/teams', { cache: 'no-store' })
|
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||||||
const data = res.ok ? await res.json() : []
|
const dataUnknown: unknown = res.ok ? await res.json() : []
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? [])
|
const listUnknown: unknown = Array.isArray(dataUnknown) ? dataUnknown : (isRecord(dataUnknown) ? dataUnknown.teams : []);
|
||||||
setTeams((list ?? []).filter((t: any) => t?.id && t?.name))
|
const list = Array.isArray(listUnknown) ? listUnknown.filter(isTeamOption) : [];
|
||||||
} catch (e: any) {
|
setTeams(list)
|
||||||
|
} catch (e: unknown) {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
console.error('[EditMatchMetaModal] load teams failed:', e)
|
console.error('[EditMatchMetaModal] load teams failed:', e)
|
||||||
setTeams([])
|
setTeams([])
|
||||||
@ -197,10 +195,7 @@ export default function EditMatchMetaModal({
|
|||||||
const dt = isoToLocalDateTimeStrings(j?.matchDate ?? j?.demoDate ?? null, userTZ);
|
const dt = isoToLocalDateTimeStrings(j?.matchDate ?? j?.demoDate ?? null, userTZ);
|
||||||
setMatchDateStr(dt.dateStr);
|
setMatchDateStr(dt.dateStr);
|
||||||
setMatchTimeStr(dt.timeStr);
|
setMatchTimeStr(dt.timeStr);
|
||||||
const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes))
|
const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes)) ? Number(j.mapVote.leadMinutes) : 60;
|
||||||
? Number(j.mapVote.leadMinutes)
|
|
||||||
: 60;
|
|
||||||
setVoteLead(leadMin);
|
|
||||||
|
|
||||||
// Vote-Open = MatchStart - leadMin
|
// Vote-Open = MatchStart - leadMin
|
||||||
const matchISO = combineLocalDateTime(dt.dateStr, dt.timeStr);
|
const matchISO = combineLocalDateTime(dt.dateStr, dt.timeStr);
|
||||||
@ -213,10 +208,10 @@ export default function EditMatchMetaModal({
|
|||||||
setBestOf(boFromMeta)
|
setBestOf(boFromMeta)
|
||||||
setMetaBestOf(boFromMeta)
|
setMetaBestOf(boFromMeta)
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
console.error('[EditMatchMetaModal] reload meta failed:', e)
|
console.error('[EditMatchMetaModal] reload meta failed:', e)
|
||||||
setError(e?.message || 'Konnte aktuelle Match-Metadaten nicht laden.')
|
setError(e instanceof Error ? e.message : 'Konnte aktuelle Match-Metadaten nicht laden.')
|
||||||
} finally {
|
} finally {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setLoadingMeta(false)
|
setLoadingMeta(false)
|
||||||
@ -229,7 +224,7 @@ export default function EditMatchMetaModal({
|
|||||||
alive = false
|
alive = false
|
||||||
metaFetchedRef.current = false
|
metaFetchedRef.current = false
|
||||||
}
|
}
|
||||||
}, [show, matchId])
|
}, [show, matchId, userTZ])
|
||||||
|
|
||||||
/* ───────── Optionen für Selects ───────── */
|
/* ───────── Optionen für Selects ───────── */
|
||||||
const teamOptionsA = useMemo(
|
const teamOptionsA = useMemo(
|
||||||
@ -241,9 +236,6 @@ export default function EditMatchMetaModal({
|
|||||||
[teams, teamAId]
|
[teams, teamAId]
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ───────── Hinweis-Flag nur vs. /meta ───────── */
|
|
||||||
const showBoChangedHint = metaBestOf !== null && bestOf !== metaBestOf
|
|
||||||
|
|
||||||
/* ───────── Validation ───────── */
|
/* ───────── Validation ───────── */
|
||||||
const canSave = useMemo(() => {
|
const canSave = useMemo(() => {
|
||||||
if (saving || loadingMeta) return false
|
if (saving || loadingMeta) return false
|
||||||
@ -256,7 +248,7 @@ export default function EditMatchMetaModal({
|
|||||||
if (new Date(openISO).getTime() > new Date(matchISO).getTime()) return false
|
if (new Date(openISO).getTime() > new Date(matchISO).getTime()) return false
|
||||||
if (teamAId && teamBId && teamAId === teamBId) return false
|
if (teamAId && teamBId && teamAId === teamBId) return false
|
||||||
return true
|
return true
|
||||||
}, [saving, loadingMeta, teamAId, teamBId])
|
}, [saving, loadingMeta, teamAId, teamBId, matchDateStr, matchTimeStr, voteOpenDateStr, voteOpenTimeStr])
|
||||||
|
|
||||||
/* ───────── Save ───────── */
|
/* ───────── Save ───────── */
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@ -298,9 +290,9 @@ export default function EditMatchMetaModal({
|
|||||||
setSaved(true)
|
setSaved(true)
|
||||||
onClose()
|
onClose()
|
||||||
setTimeout(() => onSaved?.(), 0)
|
setTimeout(() => onSaved?.(), 0)
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.error('[EditMatchMetaModal] save error:', e)
|
console.error('[EditMatchMetaModal] save error:', e)
|
||||||
setError(e?.message || 'Speichern fehlgeschlagen')
|
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,10 @@
|
|||||||
/* ------------------------------------------------------------------
|
// /src/app/[locale]/components/EditMatchPlayersModal.tsx
|
||||||
/app/components/EditMatchPlayersModal.tsx
|
|
||||||
– zeigt ALLE Spieler des gewählten Teams & nutzt DroppableZone-IDs
|
|
||||||
"active" / "inactive" analog zur TeamMemberView.
|
|
||||||
------------------------------------------------------------------- */
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import {
|
import { DndContext, closestCenter, DragOverlay, type DragStartEvent, type DragEndEvent } from '@dnd-kit/core'
|
||||||
DndContext, closestCenter, DragOverlay,
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
} from '@dnd-kit/core'
|
|
||||||
import {
|
|
||||||
SortableContext, verticalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable'
|
|
||||||
|
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
import SortableMiniCard from '../components/SortableMiniCard'
|
import SortableMiniCard from '../components/SortableMiniCard'
|
||||||
@ -123,14 +115,14 @@ export default function EditMatchPlayersModal (props: Props) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [show, team?.id])
|
}, [show, team?.id, myInit, otherInitSet])
|
||||||
|
|
||||||
/* ---- Drag’n’Drop-Handler -------------------------------- */
|
/* ---- Drag’n’Drop-Handler -------------------------------- */
|
||||||
const onDragStart = ({ active }: any) => {
|
const onDragStart = ({ active }: DragStartEvent) => {
|
||||||
setDragItem(players.find(p => p.steamId === active.id) ?? null)
|
setDragItem(players.find(p => p.steamId === active.id) ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDragEnd = ({ active, over }: any) => {
|
const onDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
setDragItem(null)
|
setDragItem(null)
|
||||||
if (!over) return
|
if (!over) return
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
// /src/app/components/FaceitStat.tsx
|
// /src/app/[locale]/components/FaceitStat.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import FaceitLevelImage from './FaceitLevelBadge'
|
import FaceitLevelImage from './FaceitLevelBadge'
|
||||||
import FaceitElo from './FaceitElo'
|
import FaceitElo from './FaceitElo'
|
||||||
|
|
||||||
export default function FaceitStat({
|
export default function FaceitStat({
|
||||||
level,
|
|
||||||
elo,
|
elo,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// //src/app/[locale]/components/GameBanner.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, {useEffect, useMemo, useRef, useState} from 'react'
|
import React, {useEffect, useMemo, useRef, useState} from 'react'
|
||||||
@ -25,11 +27,9 @@ type Variant = 'connected' | 'disconnected'
|
|||||||
/** ✅ NEU: Props, alle optional als Overrides */
|
/** ✅ NEU: Props, alle optional als Overrides */
|
||||||
type GameBannerProps = {
|
type GameBannerProps = {
|
||||||
variant?: Variant
|
variant?: Variant
|
||||||
/** true => Banner anzeigen (überschreibt interne Sichtbarkeitslogik), false => nie anzeigen */
|
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
zIndex?: number
|
zIndex?: number
|
||||||
inline?: boolean
|
inline?: boolean
|
||||||
serverLabel?: string
|
|
||||||
mapKey?: string
|
mapKey?: string
|
||||||
mapLabel?: string
|
mapLabel?: string
|
||||||
bgUrl?: string
|
bgUrl?: string
|
||||||
@ -38,15 +38,59 @@ type GameBannerProps = {
|
|||||||
score?: string
|
score?: string
|
||||||
connectedCount?: number
|
connectedCount?: number
|
||||||
totalExpected?: number
|
totalExpected?: number
|
||||||
missingCount?: number
|
|
||||||
onReconnect?: () => void
|
onReconnect?: () => void
|
||||||
onDisconnect?: () => void
|
onDisconnect?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlayerLike = {
|
||||||
|
steamId?: string | number | null
|
||||||
|
steam_id?: string | number | null
|
||||||
|
id?: string | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayersMsg = {
|
||||||
|
type: 'players'
|
||||||
|
players: Array<PlayerLike | string | number>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerJoinMsg = {
|
||||||
|
type: 'player_join'
|
||||||
|
player: PlayerLike | string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerLeaveMsg = {
|
||||||
|
type: 'player_leave'
|
||||||
|
steamId?: string | number | null
|
||||||
|
steam_id?: string | number | null
|
||||||
|
id?: string | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreMsg = {
|
||||||
|
type: 'score'
|
||||||
|
team1?: number
|
||||||
|
team2?: number
|
||||||
|
ct?: number
|
||||||
|
t?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreWrapMsg = {
|
||||||
|
score: { team1?: number; team2?: number; ct?: number; t?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelemetryMsg =
|
||||||
|
| PlayersMsg
|
||||||
|
| PlayerJoinMsg
|
||||||
|
| PlayerLeaveMsg
|
||||||
|
| ScoreMsg
|
||||||
|
| ScoreWrapMsg
|
||||||
|
| Record<string, unknown>
|
||||||
|
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
|
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
|
||||||
|
|
||||||
/* ---------- helpers ---------- */
|
/* ---------- helpers ---------- */
|
||||||
const hashStr = (s: string) => { let h = 5381; for (let i=0;i<s.length;i++) h=((h<<5)+h)+s.charCodeAt(i); return h|0 }
|
const hashStr = (s: string) => { let h = 5381; for (let i=0;i<s.length;i++) h=((h<<5)+h)+s.charCodeAt(i); return h|0 }
|
||||||
|
|
||||||
const pickMapImageFromOptions = (mapKey?: string) => {
|
const pickMapImageFromOptions = (mapKey?: string) => {
|
||||||
if (!mapKey) return null
|
if (!mapKey) return null
|
||||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
||||||
@ -54,13 +98,35 @@ const pickMapImageFromOptions = (mapKey?: string) => {
|
|||||||
const idx = Math.abs(hashStr(mapKey)) % opt.images.length
|
const idx = Math.abs(hashStr(mapKey)) % opt.images.length
|
||||||
return opt.images[idx] ?? null
|
return opt.images[idx] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickMapIcon = (mapKey?: string) => {
|
const pickMapIcon = (mapKey?: string) => {
|
||||||
if (!mapKey) return null
|
if (!mapKey) return null
|
||||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
||||||
return opt?.icon ?? null
|
return opt?.icon ?? null
|
||||||
}
|
}
|
||||||
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
|
|
||||||
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
|
const sidOf = (p: PlayerLike | string | number | null | undefined): string => {
|
||||||
|
if (p == null) return ''
|
||||||
|
if (typeof p === 'string' || typeof p === 'number') return String(p)
|
||||||
|
const raw = p.steamId ?? p.steam_id ?? p.id
|
||||||
|
return raw == null ? '' : String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSet = (arr: Iterable<unknown>) => new Set(Array.from(arr, v => String(v)))
|
||||||
|
|
||||||
|
const parseWsData = (data: unknown): TelemetryMsg | null => {
|
||||||
|
try {
|
||||||
|
if (typeof data === 'string') return JSON.parse(data) as TelemetryMsg
|
||||||
|
if (data && typeof data === 'object') return data as TelemetryMsg
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toFiniteOrNull = (v: unknown): number | null => {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
||||||
const h = (host ?? '').trim() || '127.0.0.1'
|
const h = (host ?? '').trim() || '127.0.0.1'
|
||||||
const p = (port ?? '').trim() || '8081'
|
const p = (port ?? '').trim() || '8081'
|
||||||
@ -80,7 +146,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
|||||||
visible: visibleProp,
|
visible: visibleProp,
|
||||||
zIndex: zIndexProp,
|
zIndex: zIndexProp,
|
||||||
inline = false,
|
inline = false,
|
||||||
serverLabel, // aktuell nicht gerendert, nur angenommen
|
|
||||||
mapKey: mapKeyProp,
|
mapKey: mapKeyProp,
|
||||||
mapLabel: mapLabelProp,
|
mapLabel: mapLabelProp,
|
||||||
bgUrl: bgUrlProp,
|
bgUrl: bgUrlProp,
|
||||||
@ -89,7 +154,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
|||||||
score: scoreProp,
|
score: scoreProp,
|
||||||
connectedCount: connectedCountProp,
|
connectedCount: connectedCountProp,
|
||||||
totalExpected: totalExpectedProp,
|
totalExpected: totalExpectedProp,
|
||||||
missingCount: _missingCountProp, // aktuell nicht gerendert, nur angenommen
|
|
||||||
onReconnect,
|
onReconnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
} = props
|
} = props
|
||||||
@ -178,38 +242,45 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
let msg: any = null
|
const msg = parseWsData(ev.data)
|
||||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
if ('type' in msg && msg.type === 'players' && Array.isArray((msg as PlayersMsg).players)) {
|
||||||
const ids = msg.players.map(sidOf).filter(Boolean)
|
const ids = (msg as PlayersMsg).players.map(sidOf).filter(Boolean)
|
||||||
setTelemetrySet(toSet(ids))
|
setTelemetrySet(toSet(ids))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'player_join' && msg.player) {
|
|
||||||
const sid = sidOf(msg.player); if (!sid) return
|
if ('type' in msg && msg.type === 'player_join' && 'player' in (msg as PlayerJoinMsg)) {
|
||||||
|
const sid = sidOf((msg as PlayerJoinMsg).player)
|
||||||
|
if (!sid) return
|
||||||
setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next })
|
setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'player_leave') {
|
|
||||||
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? ''); if (!sid) return
|
if ('type' in msg && msg.type === 'player_leave') {
|
||||||
|
const m = msg as PlayerLeaveMsg
|
||||||
|
const sid = sidOf({ steamId: m.steamId, steam_id: m.steam_id, id: m.id })
|
||||||
|
if (!sid) return
|
||||||
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
|
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'score') {
|
|
||||||
const a = Number(msg.team1 ?? msg.ct)
|
if ('type' in msg && msg.type === 'score') {
|
||||||
const b = Number(msg.team2 ?? msg.t)
|
const m = msg as ScoreMsg
|
||||||
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
const a = toFiniteOrNull(m.team1 ?? m.ct)
|
||||||
|
const b = toFiniteOrNull(m.team2 ?? m.t)
|
||||||
|
setScore({ a, b })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.score) {
|
|
||||||
const a = Number(msg.score.team1 ?? msg.score.ct)
|
if ('score' in msg) {
|
||||||
const b = Number(msg.score.team2 ?? msg.score.t)
|
const m = (msg as ScoreWrapMsg).score
|
||||||
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
const a = toFiniteOrNull(m?.team1 ?? m?.ct)
|
||||||
|
const b = toFiniteOrNull(m?.team2 ?? m?.t)
|
||||||
|
setScore({ a, b })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Phase ignorieren
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,7 +301,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
|||||||
|
|
||||||
// Prop kann Sichtbarkeit erzwingen/abschalten
|
// Prop kann Sichtbarkeit erzwingen/abschalten
|
||||||
const canShowInternal = meIsParticipant && notExpired
|
const canShowInternal = meIsParticipant && notExpired
|
||||||
if (visibleProp === false) return null
|
|
||||||
const canShow = visibleProp === true ? true : canShowInternal
|
const canShow = visibleProp === true ? true : canShowInternal
|
||||||
|
|
||||||
// Connected Count + Variant
|
// Connected Count + Variant
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /src/app/[locale]/components/GameBannerController.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {useEffect, useMemo, useRef, useState} from 'react'
|
import {useEffect, useMemo, useRef, useState} from 'react'
|
||||||
@ -16,6 +18,43 @@ type LiveCfg = {
|
|||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlayerLike = {
|
||||||
|
steamId?: string | number | null
|
||||||
|
steam_id?: string | number | null
|
||||||
|
id?: string | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayersMsg = {
|
||||||
|
type: 'players'
|
||||||
|
players: Array<PlayerLike | string | number>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerJoinMsg = {
|
||||||
|
type: 'player_join'
|
||||||
|
player: PlayerLike | string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerLeaveMsg = {
|
||||||
|
type: 'player_leave'
|
||||||
|
steamId?: string | number | null
|
||||||
|
steam_id?: string | number | null
|
||||||
|
id?: string | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreMsg = {
|
||||||
|
type: 'score'
|
||||||
|
team1?: number
|
||||||
|
team2?: number
|
||||||
|
ct?: number
|
||||||
|
t?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreWrapMsg = {
|
||||||
|
score: { team1?: number; team2?: number; ct?: number; t?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelemetryMsg = PlayersMsg | PlayerJoinMsg | PlayerLeaveMsg | ScoreMsg | ScoreWrapMsg | Record<string, unknown>
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
|
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
|
||||||
|
|
||||||
/* ---------- WS helpers ---------- */
|
/* ---------- WS helpers ---------- */
|
||||||
@ -31,8 +70,26 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string)
|
|||||||
return `${proto}://${h}${portPart}${pa}`
|
return `${proto}://${h}${portPart}${pa}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
|
const sidOf = (p: PlayerLike | string | number | null | undefined): string => {
|
||||||
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
|
if (p == null) return ''
|
||||||
|
if (typeof p === 'string' || typeof p === 'number') return String(p)
|
||||||
|
const raw = p.steamId ?? p.steam_id ?? p.id
|
||||||
|
return raw == null ? '' : String(raw)
|
||||||
|
}
|
||||||
|
const toSet = (arr: Iterable<unknown>) => new Set(Array.from(arr, v => String(v)))
|
||||||
|
|
||||||
|
function parseWsData(data: unknown): TelemetryMsg | null {
|
||||||
|
try {
|
||||||
|
if (typeof data === 'string') return JSON.parse(data) as TelemetryMsg
|
||||||
|
if (data && typeof data === 'object') return data as TelemetryMsg
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toFiniteOrNull = (v: unknown): number | null => {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
export default function GameBannerController() {
|
export default function GameBannerController() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
@ -153,44 +210,45 @@ export default function GameBannerController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
let msg: any = null
|
const msg = parseWsData(ev.data)
|
||||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
if ('type' in msg && msg.type === 'players' && Array.isArray((msg as PlayersMsg).players)) {
|
||||||
const ids = msg.players.map(sidOf).filter(Boolean)
|
const ids = (msg as PlayersMsg).players.map(sidOf).filter(Boolean)
|
||||||
setTelemetrySet(toSet(ids))
|
setTelemetrySet(toSet(ids))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'player_join' && msg.player) {
|
if ('type' in msg && msg.type === 'player_join' && 'player' in (msg as PlayerJoinMsg)) {
|
||||||
const sid = sidOf(msg.player)
|
const sid = sidOf((msg as PlayerJoinMsg).player)
|
||||||
if (!sid) return
|
if (!sid) return
|
||||||
setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next })
|
setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'player_leave') {
|
if ('type' in msg && msg.type === 'player_leave') {
|
||||||
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '')
|
const m = msg as PlayerLeaveMsg
|
||||||
|
const sid = sidOf({ steamId: m.steamId, steam_id: m.steam_id, id: m.id })
|
||||||
if (!sid) return
|
if (!sid) return
|
||||||
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
|
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'score') {
|
if ('type' in msg && msg.type === 'score') {
|
||||||
const a = Number(msg.team1 ?? msg.ct)
|
const m = msg as ScoreMsg
|
||||||
const b = Number(msg.team2 ?? msg.t)
|
const a = toFiniteOrNull(m.team1 ?? m.ct)
|
||||||
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
const b = toFiniteOrNull(m.team2 ?? m.t)
|
||||||
return
|
setScore({ a, b })
|
||||||
}
|
|
||||||
if (msg.score) {
|
|
||||||
const a = Number(msg.score.team1 ?? msg.score.ct)
|
|
||||||
const b = Number(msg.score.team2 ?? msg.score.t)
|
|
||||||
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase wird absichtlich ignoriert
|
if ('score' in msg) {
|
||||||
|
const m = (msg as ScoreWrapMsg).score
|
||||||
|
const a = toFiniteOrNull(m?.team1 ?? m?.ct)
|
||||||
|
const b = toFiniteOrNull(m?.team2 ?? m?.t)
|
||||||
|
setScore({ a, b })
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// InvitePlayersModal.tsx
|
// /src/app/[locale]/components/InvitePlayersModal.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
@ -22,6 +22,50 @@ type Props = {
|
|||||||
directAdd?: boolean
|
directAdd?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnknownRec = Record<string, unknown>;
|
||||||
|
|
||||||
|
type ApiResultItem = { steamId: string; ok: boolean };
|
||||||
|
|
||||||
|
function parseResultsFromJson(json: unknown): ApiResultItem[] | null {
|
||||||
|
if (!isRecord(json)) return null;
|
||||||
|
if (Array.isArray(json.results)) {
|
||||||
|
const out: ApiResultItem[] = [];
|
||||||
|
for (const r of json.results) {
|
||||||
|
const steamIdVal = isRecord(r) ? r.steamId : undefined;
|
||||||
|
const okVal = isRecord(r) ? r.ok : undefined;
|
||||||
|
const steamId =
|
||||||
|
typeof steamIdVal === 'string' ? steamIdVal
|
||||||
|
: typeof steamIdVal === 'number' ? String(steamIdVal)
|
||||||
|
: null;
|
||||||
|
const ok = typeof okVal === 'boolean' ? okVal : false;
|
||||||
|
if (steamId) out.push({ steamId, ok });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (Array.isArray(json.invitationIds)) {
|
||||||
|
const ids: string[] = (json.invitationIds as unknown[]).map(v =>
|
||||||
|
typeof v === 'string' ? v : String(v)
|
||||||
|
);
|
||||||
|
return ids.map(steamId => ({ steamId, ok: true }));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(v: unknown): v is UnknownRec {
|
||||||
|
return !!v && typeof v === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTeamLeaderSteamId(team: Team | null | undefined): string | null {
|
||||||
|
if (!team) return null;
|
||||||
|
const leaderUnknown = (team as unknown as { leader?: unknown }).leader;
|
||||||
|
if (typeof leaderUnknown === 'string') return leaderUnknown;
|
||||||
|
if (isRecord(leaderUnknown) && typeof leaderUnknown.steamId === 'string') {
|
||||||
|
return leaderUnknown.steamId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) {
|
export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
@ -39,7 +83,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
const [onlyFree, setOnlyFree] = useState(false)
|
const [onlyFree, setOnlyFree] = useState(false)
|
||||||
|
|
||||||
// Sanftes UI-Update
|
// Sanftes UI-Update
|
||||||
const [isPending, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const [isFetching, setIsFetching] = useState(false)
|
const [isFetching, setIsFetching] = useState(false)
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
@ -100,48 +144,50 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
|
|
||||||
async function fetchUsers(opts: { resetLayout: boolean }) {
|
async function fetchUsers(opts: { resetLayout: boolean }) {
|
||||||
try {
|
try {
|
||||||
abortRef.current?.abort()
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
// Höhe einfrieren
|
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight);
|
||||||
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight)
|
|
||||||
|
|
||||||
// Start Fetch
|
setIsFetching(true);
|
||||||
setIsFetching(true)
|
|
||||||
|
|
||||||
// 🔽 Spinner NICHT sofort zeigen – erst nach kurzer Verzögerung
|
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current);
|
||||||
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current)
|
|
||||||
spinnerShowTimer.current = window.setTimeout(() => {
|
spinnerShowTimer.current = window.setTimeout(() => {
|
||||||
setSpinnerVisible(true)
|
setSpinnerVisible(true);
|
||||||
spinnerShownAt.current = Date.now()
|
spinnerShownAt.current = Date.now();
|
||||||
}, SPINNER_DELAY_MS)
|
}, SPINNER_DELAY_MS);
|
||||||
|
|
||||||
const qs = new URLSearchParams({ teamId: team.id })
|
const qs = new URLSearchParams({ teamId: team.id });
|
||||||
if (onlyFree) qs.set('onlyFree', 'true')
|
if (onlyFree) qs.set('onlyFree', 'true');
|
||||||
|
|
||||||
const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
|
const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
});
|
||||||
if (!res.ok) throw new Error('load failed')
|
if (!res.ok) throw new Error('load failed');
|
||||||
|
|
||||||
|
const dataUnknown: unknown = await res.json();
|
||||||
|
const users = (isRecord(dataUnknown) && Array.isArray(dataUnknown.users))
|
||||||
|
? (dataUnknown.users as Player[])
|
||||||
|
: [];
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setAllUsers(data.users || [])
|
setAllUsers(users);
|
||||||
setKnownUsers(prev => {
|
setKnownUsers(prev => {
|
||||||
const next = { ...prev }
|
const next = { ...prev };
|
||||||
for (const u of (data.users || [])) next[u.steamId] = u
|
for (const u of users) next[u.steamId] = u;
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
if (opts.resetLayout) {
|
if (opts.resetLayout) {
|
||||||
setSelectedIds([])
|
setSelectedIds([]);
|
||||||
setInvitedIds([])
|
setInvitedIds([]);
|
||||||
setIsSuccess(false)
|
setIsSuccess(false);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
if (e?.name !== 'AbortError') console.error('Fehler beim Laden der Benutzer:', e)
|
if (isRecord(e) && e.name === 'AbortError') return;
|
||||||
|
console.error('Fehler beim Laden der Benutzer:', e);
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
@ -194,67 +240,60 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleInvite = async () => {
|
const handleInvite = async () => {
|
||||||
if (isInviting) return
|
if (isInviting) return;
|
||||||
if (selectedIds.length === 0 || !steamId) return
|
if (selectedIds.length === 0 || !steamId) return;
|
||||||
const ids = [...selectedIds]
|
const ids = [...selectedIds];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsInviting(true)
|
setIsInviting(true);
|
||||||
const url = directAdd ? '/api/team/add-players' : '/api/team/invite'
|
const url = directAdd ? '/api/team/add-players' : '/api/team/invite';
|
||||||
const body = directAdd
|
const body = directAdd
|
||||||
? { teamId: team.id, steamIds: ids }
|
? { teamId: team.id, steamIds: ids }
|
||||||
: { teamId: team.id, userIds: ids, invitedBy: steamId }
|
: { teamId: team.id, userIds: ids, invitedBy: steamId };
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
});
|
||||||
|
|
||||||
let json: any = null
|
let jsonUnknown: unknown = null;
|
||||||
try { json = await res.clone().json() } catch {}
|
try { jsonUnknown = await res.clone().json(); } catch { /* ignore */ }
|
||||||
|
|
||||||
let results: { steamId: string; ok: boolean }[] = []
|
let results: ApiResultItem[] | null = parseResultsFromJson(jsonUnknown);
|
||||||
|
|
||||||
if (directAdd) {
|
// Fallback, falls API keine detailierten results liefert
|
||||||
if (json?.results && Array.isArray(json.results)) {
|
if (!results) {
|
||||||
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
|
results = ids.map(id => ({ steamId: id, ok: res.ok }));
|
||||||
} else {
|
|
||||||
results = ids.map(id => ({ steamId: id, ok: res.ok }))
|
|
||||||
}
|
|
||||||
} else if (json?.results && Array.isArray(json.results)) {
|
|
||||||
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
|
|
||||||
} else if (Array.isArray(json?.invitationIds)) {
|
|
||||||
const okSet = new Set<string>(json.invitationIds)
|
|
||||||
results = ids.map(id => ({ steamId: id, ok: okSet.has(id) }))
|
|
||||||
} else {
|
|
||||||
results = ids.map(id => ({ steamId: id, ok: false }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextStatus: Record<string, InviteStatus> = {}
|
const nextStatus: Record<string, InviteStatus> = {};
|
||||||
let okCount = 0
|
let okCount = 0;
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed'
|
const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed';
|
||||||
nextStatus[r.steamId] = st
|
nextStatus[r.steamId] = st;
|
||||||
if (r.ok) okCount++
|
if (r.ok) okCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInvitedStatus(prev => ({ ...prev, ...nextStatus }))
|
setInvitedStatus(prev => ({ ...prev, ...nextStatus }));
|
||||||
setInvitedIds(ids)
|
setInvitedIds(ids);
|
||||||
setSentCount(okCount)
|
setSentCount(okCount);
|
||||||
setIsSuccess(true)
|
setIsSuccess(true);
|
||||||
setSelectedIds([])
|
setSelectedIds([]);
|
||||||
if (okCount > 0) onSuccess()
|
if (okCount > 0) onSuccess();
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.error('Fehler beim Einladen:', err)
|
console.error('Fehler beim Einladen:', err);
|
||||||
setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(selectedIds.map(id => [id, 'failed'])) }))
|
setInvitedStatus(prev => ({
|
||||||
setInvitedIds(selectedIds)
|
...prev,
|
||||||
setSentCount(0)
|
...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus])),
|
||||||
setIsSuccess(true)
|
}));
|
||||||
|
setInvitedIds(selectedIds);
|
||||||
|
setSentCount(0);
|
||||||
|
setIsSuccess(true);
|
||||||
} finally {
|
} finally {
|
||||||
setIsInviting(false)
|
setIsInviting(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => { setCurrentPage(1) }, [searchTerm])
|
useEffect(() => { setCurrentPage(1) }, [searchTerm])
|
||||||
|
|
||||||
@ -407,7 +446,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
currentUserSteamId={steamId!}
|
currentUserSteamId={steamId!}
|
||||||
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
|
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
|
||||||
hideActions
|
hideActions
|
||||||
rank={user.premierRank}
|
rank={user.premierRank}
|
||||||
/>
|
/>
|
||||||
@ -480,12 +519,13 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
avatar={user.avatar}
|
avatar={user.avatar}
|
||||||
location={user.location}
|
location={user.location}
|
||||||
selected={false}
|
selected={false}
|
||||||
onSelect={handleSelect}
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
onSelect={handleSelect}
|
||||||
currentUserSteamId={steamId!}
|
currentUserSteamId={steamId!}
|
||||||
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
|
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
|
||||||
hideActions
|
hideActions
|
||||||
rank={user.premierRank}
|
rank={user.premierRank}
|
||||||
|
invitedStatus={invitedStatus[user.steamId]}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
@ -511,7 +551,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
selected={false}
|
selected={false}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
currentUserSteamId={steamId!}
|
currentUserSteamId={steamId!}
|
||||||
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
|
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
|
||||||
hideActions
|
hideActions
|
||||||
rank={user.premierRank}
|
rank={user.premierRank}
|
||||||
invitedStatus={invitedStatus[user.steamId]}
|
invitedStatus={invitedStatus[user.steamId]}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
|
|||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
|
|
||||||
const [newLeaderId, setNewLeaderId] = useState<string>('')
|
const [newLeaderId, setNewLeaderId] = useState<string>('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show && team.leader?.steamId) {
|
if (show && team.leader?.steamId) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// /src/app/components/MapVoteBanner.tsx
|
// /src/app/[locale]/components/MapVoteBanner.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
@ -8,12 +8,52 @@ import { useSSEStore } from '@/lib/useSSEStore'
|
|||||||
import type { MapVoteState } from '../../../types/mapvote'
|
import type { MapVoteState } from '../../../types/mapvote'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
type TeamLite = { id?: string | null; name?: string | null; leader?: { steamId?: string | null } | null };
|
||||||
|
type MatchLite = { id: string; bestOf?: number | null; matchDate?: string | null; demoDate?: string | null; teamA?: TeamLite | null; teamB?: TeamLite | null };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
match: any
|
match: MatchLite;
|
||||||
initialNow: number
|
initialNow: number;
|
||||||
matchBaseTs: number | null
|
matchBaseTs: number | null;
|
||||||
sseOpensAtTs?: number | null
|
sseOpensAtTs?: number | null;
|
||||||
sseLeadMinutes?: number | null
|
sseLeadMinutes?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReloadEventType =
|
||||||
|
| 'map-vote-updated' | 'map-vote-reset' | 'map-vote-locked' | 'map-vote-unlocked'
|
||||||
|
| 'match-updated' | 'match-lineup-updated';
|
||||||
|
|
||||||
|
type SSEPayload = {
|
||||||
|
matchId?: string;
|
||||||
|
leadMinutes?: number;
|
||||||
|
opensAt?: string | number | Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TMapvote = ReturnType<typeof useTranslations<'mapvote'>>;
|
||||||
|
|
||||||
|
const RELOAD_TYPES = new Set<ReloadEventType>([
|
||||||
|
'map-vote-updated','map-vote-reset','map-vote-locked','map-vote-unlocked',
|
||||||
|
'match-updated','match-lineup-updated',
|
||||||
|
]);
|
||||||
|
|
||||||
|
type UnknownRec = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isObject(v: unknown): v is UnknownRec {
|
||||||
|
return !!v && typeof v === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapEvent(e: unknown): { type?: string; payload: SSEPayload } {
|
||||||
|
const type =
|
||||||
|
isObject(e) && typeof (e as UnknownRec)['type'] === 'string'
|
||||||
|
? ((e as UnknownRec)['type'] as string)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const rawPayload =
|
||||||
|
isObject(e) && 'payload' in e ? (e as UnknownRec)['payload'] : e;
|
||||||
|
|
||||||
|
const payload = isObject(rawPayload) ? (rawPayload as SSEPayload) : ({} as SSEPayload);
|
||||||
|
|
||||||
|
return { type, payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCountdown(ms: number) {
|
function formatCountdown(ms: number) {
|
||||||
@ -25,18 +65,28 @@ function formatCountdown(ms: number) {
|
|||||||
const pad = (n:number)=>String(n).padStart(2,'0')
|
const pad = (n:number)=>String(n).padStart(2,'0')
|
||||||
return `${h}:${pad(m)}:${pad(s)}`
|
return `${h}:${pad(m)}:${pad(s)}`
|
||||||
}
|
}
|
||||||
function formatLead(minutes: number) {
|
|
||||||
if (!Number.isFinite(minutes) || minutes <= 0) return 'zum Matchbeginn'
|
function formatLead(minutes: number, tMapvote: TMapvote) {
|
||||||
const h = Math.floor(minutes / 60)
|
if (!Number.isFinite(minutes) || minutes <= 0) return tMapvote('to-match-start');
|
||||||
const m = minutes % 60
|
const h = Math.floor(minutes / 60);
|
||||||
if (h > 0 && m > 0) return `${h}h ${m}min`
|
const m = minutes % 60;
|
||||||
if (h > 0) return `${h}h`
|
if (h > 0 && m > 0) return `${h}h ${m}min`;
|
||||||
return `${m}min`
|
if (h > 0) return `${h}h`;
|
||||||
|
return `${m}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(err: unknown): boolean {
|
||||||
|
if (typeof DOMException !== 'undefined' && err instanceof DOMException) {
|
||||||
|
return err.name === 'AbortError';
|
||||||
|
}
|
||||||
|
return isObject(err) && typeof err.name === 'string' && err.name === 'AbortError';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapVoteBanner({
|
export default function MapVoteBanner({
|
||||||
match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes,
|
match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const tMapvote = useTranslations<'mapvote'>('mapvote');
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
@ -52,25 +102,29 @@ export default function MapVoteBanner({
|
|||||||
const [now, setNow] = useState(initialNow)
|
const [now, setNow] = useState(initialNow)
|
||||||
useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, [])
|
useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, [])
|
||||||
|
|
||||||
// Übersetzungen
|
|
||||||
const tCommon = useTranslations('common')
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
const ac = new AbortController();
|
||||||
try {
|
try {
|
||||||
setError(null)
|
setError(null);
|
||||||
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
|
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store', signal: ac.signal });
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const j = await r.json().catch(() => ({}))
|
let message = 'Laden fehlgeschlagen';
|
||||||
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
const parsed = (await r.json().catch(() => null)) as unknown;
|
||||||
|
if (isObject(parsed) && typeof parsed.message === 'string') {
|
||||||
|
message = parsed.message;
|
||||||
}
|
}
|
||||||
const json = await r.json()
|
throw new Error(message);
|
||||||
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
|
|
||||||
setState(json)
|
|
||||||
} catch (e: any) {
|
|
||||||
setState(null)
|
|
||||||
setError(e?.message ?? 'Unbekannter Fehler')
|
|
||||||
}
|
}
|
||||||
}, [match.id])
|
const json: MapVoteState = await r.json();
|
||||||
|
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)');
|
||||||
|
setState(json);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (isAbortError(e)) return;
|
||||||
|
setState(null);
|
||||||
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
|
||||||
|
}
|
||||||
|
return () => ac.abort();
|
||||||
|
}, [match.id]);
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
|
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
|
||||||
@ -78,60 +132,70 @@ export default function MapVoteBanner({
|
|||||||
const matchDateTs = useMemo(() => (typeof matchBaseTs === 'number' ? matchBaseTs : null), [matchBaseTs])
|
const matchDateTs = useMemo(() => (typeof matchBaseTs === 'number' ? matchBaseTs : null), [matchBaseTs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return;
|
||||||
const { type } = lastEvent as any
|
const { type, payload } = unwrapEvent(lastEvent);
|
||||||
const evt = (lastEvent as any).payload ?? lastEvent
|
if (payload.matchId !== match.id) return;
|
||||||
if (evt?.matchId !== match.id) return
|
if (!type || !RELOAD_TYPES.has(type as ReloadEventType)) return;
|
||||||
|
|
||||||
const RELOAD_TYPES = new Set([
|
const rawLead = payload.leadMinutes;
|
||||||
'map-vote-updated','map-vote-reset','map-vote-locked','map-vote-unlocked',
|
const parsedLead = Number.isFinite(rawLead) ? Number(rawLead) : undefined;
|
||||||
'match-updated','match-lineup-updated',
|
const nextOpensAtISO = payload.opensAt
|
||||||
])
|
? (typeof payload.opensAt === 'string'
|
||||||
if (!RELOAD_TYPES.has(type)) return
|
? payload.opensAt
|
||||||
|
: new Date(payload.opensAt).toISOString())
|
||||||
const rawLead = evt?.leadMinutes
|
: undefined;
|
||||||
const parsedLead = (rawLead !== undefined && rawLead !== null) ? Number(rawLead) : undefined
|
|
||||||
const nextOpensAtISO =
|
|
||||||
evt?.opensAt
|
|
||||||
? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString())
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (nextOpensAtISO) {
|
if (nextOpensAtISO) {
|
||||||
setOpensAtOverride(new Date(nextOpensAtISO).getTime())
|
setOpensAtOverride(new Date(nextOpensAtISO).getTime());
|
||||||
} else if (Number.isFinite(parsedLead) && matchDateTs != null) {
|
} else if (parsedLead !== undefined && matchDateTs != null) {
|
||||||
setOpensAtOverride(matchDateTs - (parsedLead as number) * 60_000)
|
setOpensAtOverride(matchDateTs - parsedLead * 60_000);
|
||||||
}
|
}
|
||||||
if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number)
|
if (parsedLead !== undefined) setLeadOverride(parsedLead);
|
||||||
|
|
||||||
if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) {
|
if (nextOpensAtISO !== undefined || parsedLead !== undefined) {
|
||||||
setState(prev => ({
|
setState(prev => {
|
||||||
...(prev ?? {} as any),
|
if (!prev) return prev; // bleibt null, bis ein kompletter State geladen ist
|
||||||
...(nextOpensAtISO !== undefined ? { opensAt: nextOpensAtISO } : {}),
|
const patch: Partial<MapVoteState> = {};
|
||||||
...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}),
|
if (nextOpensAtISO !== undefined) patch.opensAt = nextOpensAtISO;
|
||||||
}) as any)
|
if (parsedLead !== undefined) patch.leadMinutes = parsedLead;
|
||||||
|
return { ...prev, ...patch }; // => MapVoteState
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
load()
|
load();
|
||||||
}
|
}
|
||||||
}, [lastEvent, match.id, matchDateTs, load])
|
}, [lastEvent, match.id, matchDateTs, load]);
|
||||||
|
|
||||||
|
const stateOpensAt = state?.opensAt;
|
||||||
|
const stateLeadMinutes = state?.leadMinutes;
|
||||||
|
|
||||||
const opensAt = useMemo(() => {
|
const opensAt = useMemo(() => {
|
||||||
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs
|
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs;
|
||||||
if (opensAtOverride != null) return opensAtOverride
|
if (opensAtOverride != null) return opensAtOverride;
|
||||||
if (state?.opensAt) return new Date(state.opensAt).getTime()
|
if (stateOpensAt) return new Date(stateOpensAt).getTime();
|
||||||
if (matchDateTs == null) return new Date(initialNow).getTime()
|
if (matchDateTs == null) return new Date(initialNow).getTime();
|
||||||
const lead = (typeof sseLeadMinutes === 'number')
|
const lead =
|
||||||
|
typeof sseLeadMinutes === 'number'
|
||||||
? sseLeadMinutes
|
? sseLeadMinutes
|
||||||
: (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? (state!.leadMinutes as number) : 60))
|
: (leadOverride ?? (Number.isFinite(stateLeadMinutes) ? (stateLeadMinutes as number) : 60));
|
||||||
return matchDateTs - lead * 60_000
|
return matchDateTs - lead * 60_000;
|
||||||
}, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes])
|
}, [
|
||||||
|
sseOpensAtTs,
|
||||||
|
opensAtOverride,
|
||||||
|
stateOpensAt,
|
||||||
|
matchDateTs,
|
||||||
|
initialNow,
|
||||||
|
sseLeadMinutes,
|
||||||
|
leadOverride,
|
||||||
|
stateLeadMinutes,
|
||||||
|
]);
|
||||||
|
|
||||||
const leadMinutes = useMemo(() => {
|
const leadMinutes = useMemo(() => {
|
||||||
if (matchDateTs != null && opensAt != null) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000))
|
if (matchDateTs != null && opensAt != null) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000));
|
||||||
if (typeof sseLeadMinutes === 'number') return sseLeadMinutes
|
if (typeof sseLeadMinutes === 'number') return sseLeadMinutes;
|
||||||
if (leadOverride != null) return leadOverride
|
if (leadOverride != null) return leadOverride;
|
||||||
if (Number.isFinite(state?.leadMinutes)) return state!.leadMinutes as number
|
if (Number.isFinite(stateLeadMinutes)) return stateLeadMinutes as number;
|
||||||
return 60
|
return 60;
|
||||||
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes])
|
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, stateLeadMinutes]);
|
||||||
|
|
||||||
const isOpen = mounted && now >= opensAt
|
const isOpen = mounted && now >= opensAt
|
||||||
const msToOpen = Math.max(opensAt - now, 0)
|
const msToOpen = Math.max(opensAt - now, 0)
|
||||||
@ -185,7 +249,7 @@ export default function MapVoteBanner({
|
|||||||
onClick={gotoFullPage}
|
onClick={gotoFullPage}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && gotoFullPage()}
|
onKeyDown={(e) => e.key === 'Enter' && gotoFullPage()}
|
||||||
className={`group relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ${ringClass}`}
|
className={`group relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ${ringClass}`}
|
||||||
aria-label="Map-Vote öffnen"
|
aria-label={`Mapvote ${tMapvote("open-small")}`}
|
||||||
>
|
>
|
||||||
{(isVotingOpen || isLocked) && (
|
{(isVotingOpen || isLocked) && (
|
||||||
<>
|
<>
|
||||||
@ -205,12 +269,12 @@ export default function MapVoteBanner({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium text-gray-900 dark:text-neutral-100">Map-Vote</div>
|
<div className="font-medium text-gray-900 dark:text-neutral-100">Map-Vote</div>
|
||||||
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
|
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
|
||||||
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
|
{tMapvote("mode")}: BO{match.bestOf ?? state?.bestOf ?? 3}
|
||||||
{isEnded
|
{isEnded
|
||||||
? ' • Auswahl fixiert'
|
? ' • Auswahl fixiert'
|
||||||
: isLive
|
: isLive
|
||||||
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
|
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
|
||||||
: ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`}
|
: ` • startet ${formatLead(leadMinutes, tMapvote)} vor Matchbeginn`}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</div>}
|
{error && <div className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
@ -219,16 +283,16 @@ export default function MapVoteBanner({
|
|||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{isEnded ? (
|
{isEnded ? (
|
||||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200">
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200">
|
||||||
Voting abgeschlossen
|
{tMapvote("completed")}
|
||||||
</span>
|
</span>
|
||||||
) : isLive ? (
|
) : isLive ? (
|
||||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-100">
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-100">
|
||||||
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
|
{iCanAct ? tMapvote("vote-now") : `Mapvote ${tMapvote("open")}`}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-100 text-neutral-800 dark:bg-neutral-700/40 dark:text-neutral-200"
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-100 text-neutral-800 dark:bg-neutral-700/40 dark:text-neutral-200"
|
||||||
suppressHydrationWarning>
|
suppressHydrationWarning>
|
||||||
Öffnet in {mounted ? formatCountdown(msToOpen) : '–:–:–'}
|
{tMapvote('opens-in')} {mounted ? formatCountdown(msToOpen) : '–:–:–'} • {formatLead(leadMinutes, tMapvote)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// MapVotePanel.tsx
|
// /src/app/[locale]/components/MapVotePanel.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
@ -19,11 +19,47 @@ import { MAP_OPTIONS } from '@/lib/mapOptions'
|
|||||||
import { Tabs } from './Tabs'
|
import { Tabs } from './Tabs'
|
||||||
import Chart from './Chart'
|
import Chart from './Chart'
|
||||||
import { MAPVOTE_REFRESH } from '@/lib/sseEvents'
|
import { MAPVOTE_REFRESH } from '@/lib/sseEvents'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
/* =================== Utilities & constants =================== */
|
/* =================== Utilities & constants =================== */
|
||||||
|
|
||||||
type Props = { match: Match }
|
type Props = { match: Match }
|
||||||
|
|
||||||
|
// --- Safe type guards (no-any helpers) --------------------------------------
|
||||||
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||||
|
return !!v && typeof v === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isString(v: unknown): v is string { return typeof v === 'string'; }
|
||||||
|
|
||||||
|
// --- Match-ready typings -----------------------------------------------------
|
||||||
|
type FirstMap = { label?: string; bg?: string };
|
||||||
|
type MatchReadyPayload = { matchId: string; firstMap?: FirstMap };
|
||||||
|
type MatchReadyEvent = { type: 'match-ready'; payload: MatchReadyPayload };
|
||||||
|
|
||||||
|
type MapvoteRefreshEvent =
|
||||||
|
typeof MAPVOTE_REFRESH extends Set<infer U> ? U : never;
|
||||||
|
|
||||||
|
function isRefreshEvent(t: unknown): t is MapvoteRefreshEvent {
|
||||||
|
return typeof t === 'string' && MAPVOTE_REFRESH.has(t as MapvoteRefreshEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMatchReadyEvent(e: unknown): e is MatchReadyEvent {
|
||||||
|
if (!isRecord(e)) return false;
|
||||||
|
if ((e as { type?: unknown }).type !== 'match-ready') return false;
|
||||||
|
const p = (e as { payload?: unknown }).payload;
|
||||||
|
return isRecord(p) && isString((p as { matchId?: unknown }).matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap ohne any
|
||||||
|
function unwrapEvent(e: unknown): Record<string, unknown> {
|
||||||
|
if (!isRecord(e)) return {};
|
||||||
|
const p = (e as Record<string, unknown>).payload;
|
||||||
|
if (isRecord(p) && isRecord((p as Record<string, unknown>).payload)) return (p as Record<string, unknown>).payload as Record<string, unknown>;
|
||||||
|
if (isRecord(p)) return p as Record<string, unknown>;
|
||||||
|
return e as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
const HOLD_MS = 1200
|
const HOLD_MS = 1200
|
||||||
const COMPLETE_THRESHOLD = 1.0
|
const COMPLETE_THRESHOLD = 1.0
|
||||||
|
|
||||||
@ -47,6 +83,10 @@ const winrateCache = new Map<string, Record<string, number>>()
|
|||||||
|
|
||||||
type BatchByPlayer = Record<string, Record<string, number>> // steamId -> { mapKey -> pct 0..100 }
|
type BatchByPlayer = Record<string, Record<string, number>> // steamId -> { mapKey -> pct 0..100 }
|
||||||
|
|
||||||
|
type WinrateApi = {
|
||||||
|
byPlayer?: Record<string, { byMap?: Record<string, { pct?: number | string }> }>;
|
||||||
|
};
|
||||||
|
|
||||||
/** Liest /api/user/winrate und normiert auf 0..100 (Float), NICHT ×10 */
|
/** Liest /api/user/winrate und normiert auf 0..100 (Float), NICHT ×10 */
|
||||||
async function fetchWinratesBatch(
|
async function fetchWinratesBatch(
|
||||||
steamIds: string[],
|
steamIds: string[],
|
||||||
@ -56,36 +96,43 @@ async function fetchWinratesBatch(
|
|||||||
if (!ids.length) return {};
|
if (!ids.length) return {};
|
||||||
|
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
|
|
||||||
// steamIds als CSV in EINEM Param
|
|
||||||
q.set('steamIds', ids.join(','));
|
q.set('steamIds', ids.join(','));
|
||||||
|
|
||||||
// types als wiederholte Parameter
|
|
||||||
(opts?.types ?? []).forEach(t => q.append('types', t));
|
(opts?.types ?? []).forEach(t => q.append('types', t));
|
||||||
|
|
||||||
if (opts?.onlyActive === false) q.append('onlyActive', 'false');
|
if (opts?.onlyActive === false) q.append('onlyActive', 'false');
|
||||||
|
|
||||||
const r = await fetch(`/api/user/winrate?${q.toString()}`, { cache: 'no-store' });
|
const r = await fetch(`/api/user/winrate?${q.toString()}`, { cache: 'no-store' });
|
||||||
if (!r.ok) return {};
|
if (!r.ok) return {};
|
||||||
|
|
||||||
const json = await r.json().catch(() => null);
|
const json = (await r.json().catch(() => null)) as WinrateApi | null;
|
||||||
const out: BatchByPlayer = {};
|
|
||||||
const byPlayer = json?.byPlayer ?? {};
|
const byPlayer = json?.byPlayer ?? {};
|
||||||
for (const [steamId, block] of Object.entries<any>(byPlayer)) {
|
const out: BatchByPlayer = {};
|
||||||
|
|
||||||
|
for (const [steamId, block] of Object.entries(byPlayer)) {
|
||||||
const maps = block?.byMap ?? {};
|
const maps = block?.byMap ?? {};
|
||||||
const normalized: Record<string, number> = {};
|
const normalized: Record<string, number> = {};
|
||||||
for (const [mapKey, agg] of Object.entries<any>(maps)) {
|
for (const [mapKey, agg] of Object.entries(maps)) {
|
||||||
const pctX10 = Number(agg?.pct);
|
const pctX10 = Number(agg?.pct);
|
||||||
if (Number.isFinite(pctX10)) normalized[mapKey] = pctX10 / 10;
|
if (Number.isFinite(pctX10)) normalized[mapKey] = pctX10 / 10;
|
||||||
}
|
}
|
||||||
out[steamId] = normalized;
|
out[steamId] = normalized;
|
||||||
}
|
}
|
||||||
for (const id of Object.keys(out)) winrateCache.set(id, out[id]);
|
Object.keys(out).forEach(id => winrateCache.set(id, out[id]));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =================== Component =================== */
|
/* =================== Component =================== */
|
||||||
|
|
||||||
|
// Ergänze Match um optionale Felder, die hier verwendet werden
|
||||||
|
type MatchLike = Match & {
|
||||||
|
players?: MatchPlayer[];
|
||||||
|
teamA?: { id?: string; name?: string; logo?: string | null; leader?: { steamId?: string | null }; players?: MatchPlayer[] };
|
||||||
|
teamB?: { id?: string; name?: string; logo?: string | null; leader?: { steamId?: string | null }; players?: MatchPlayer[] };
|
||||||
|
teamAUsers?: { steamId: string }[];
|
||||||
|
teamBUsers?: { steamId: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StepAction = 'ban' | 'pick' | 'decider';
|
||||||
|
|
||||||
export default function MapVotePanel({ match }: Props) {
|
export default function MapVotePanel({ match }: Props) {
|
||||||
/* -------- External stores / env -------- */
|
/* -------- External stores / env -------- */
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -100,7 +147,6 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
const [adminEditMode, setAdminEditMode] = useState(false)
|
const [adminEditMode, setAdminEditMode] = useState(false)
|
||||||
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
|
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
|
||||||
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
|
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
|
||||||
|
|
||||||
const [tab, setTab] = useState<'pool' | 'winrate'>('pool')
|
const [tab, setTab] = useState<'pool' | 'winrate'>('pool')
|
||||||
|
|
||||||
/* -------- Timers / open window -------- */
|
/* -------- Timers / open window -------- */
|
||||||
@ -154,18 +200,18 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
}, [overlayOpen, overlayIsForThisMatch])
|
}, [overlayOpen, overlayIsForThisMatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!isMatchReadyEvent(lastEvent)) return;
|
||||||
if (lastEvent.type !== 'match-ready') return
|
if (lastEvent.payload.matchId !== match.id) return;
|
||||||
if (lastEvent.payload?.matchId !== match.id) return
|
|
||||||
|
const fm = lastEvent.payload.firstMap; // FirstMap | undefined
|
||||||
|
|
||||||
const fm = lastEvent.payload?.firstMap ?? {}
|
|
||||||
showWithDelay({
|
showWithDelay({
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
mapLabel: fm?.label ?? 'Erste Map',
|
mapLabel: fm?.label ?? 'Erste Map',
|
||||||
mapBg: fm?.bg ?? '/assets/img/maps/lobby_mapveto_png.webp',
|
mapBg: fm?.bg ?? '/assets/img/maps/lobby_mapveto_png.webp',
|
||||||
nextHref: `/match-details/${match.id}/radar`,
|
nextHref: `/match-details/${match.id}/radar`,
|
||||||
}, 3000)
|
}, 3000);
|
||||||
}, [lastEvent, match.id, showWithDelay])
|
}, [lastEvent, match.id, showWithDelay]);
|
||||||
|
|
||||||
/* -------- Data load (initial + SSE refresh) -------- */
|
/* -------- Data load (initial + SSE refresh) -------- */
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@ -174,15 +220,15 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
|
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({} as { message?: string }))
|
||||||
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
||||||
}
|
}
|
||||||
const json = await r.json()
|
const json = await r.json()
|
||||||
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
|
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
|
||||||
setState(json)
|
setState(json)
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setState(null)
|
setState(null)
|
||||||
setError(e?.message ?? 'Unbekannter Fehler')
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@ -226,9 +272,14 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const decisionByMap = useMemo(() => {
|
const decisionByMap = useMemo(() => {
|
||||||
const map = new Map<string, { action: 'ban' | 'pick' | 'decider'; teamId: string | null }>()
|
const map = new Map<string, { action: StepAction; teamId: string | null }>()
|
||||||
for (const s of state?.steps ?? []) {
|
for (const s of state?.steps ?? []) {
|
||||||
if (s.map) map.set(s.map, { action: s.action as any, teamId: s.teamId ?? null })
|
if (s.map) {
|
||||||
|
const a = s.action
|
||||||
|
if (a === 'ban' || a === 'pick' || a === 'decider') {
|
||||||
|
map.set(s.map, { action: a, teamId: s.teamId ?? null })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}, [state?.steps])
|
}, [state?.steps])
|
||||||
@ -249,24 +300,24 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
body: JSON.stringify({ adminEdit: enabled }),
|
body: JSON.stringify({ adminEdit: enabled }),
|
||||||
})
|
})
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({} as { message?: string }))
|
||||||
throw new Error(j?.message || 'Konnte Admin-Edit nicht setzen')
|
throw new Error(j?.message || 'Konnte Admin-Edit nicht setzen')
|
||||||
}
|
}
|
||||||
return r.json()
|
return r.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePickOrBan = async (map: string) => {
|
const handlePickOrBan = useCallback(async (map: string) => {
|
||||||
if (!isMyTurn || !currentStep) return
|
if (!isMyTurn || !currentStep) return;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/matches/${match.id}/mapvote`, {
|
const r = await fetch(`/api/matches/${match.id}/mapvote`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ map }),
|
body: JSON.stringify({ map }),
|
||||||
})
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({} as { message?: string }));
|
||||||
alert(j.message ?? 'Aktion fehlgeschlagen')
|
alert(j.message ?? 'Aktion fehlgeschlagen');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setState(prev =>
|
setState(prev =>
|
||||||
prev
|
prev
|
||||||
@ -277,11 +328,11 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: prev
|
: prev
|
||||||
)
|
);
|
||||||
} catch {
|
} catch {
|
||||||
alert('Netzwerkfehler')
|
alert('Netzwerkfehler');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [isMyTurn, currentStep, match.id, setState]);
|
||||||
|
|
||||||
/* -------- Press-and-hold logic -------- */
|
/* -------- Press-and-hold logic -------- */
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
@ -369,17 +420,22 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
}, [state?.steps])
|
}, [state?.steps])
|
||||||
|
|
||||||
/* -------- Players & ranks -------- */
|
/* -------- Players & ranks -------- */
|
||||||
|
const m = match as MatchLike
|
||||||
|
|
||||||
|
// Hilfstyp für optionale Team-Id an MatchPlayer
|
||||||
|
type MaybeTeam = { team?: { id?: string | null } };
|
||||||
|
|
||||||
const playersA = useMemo<MatchPlayer[]>(() => {
|
const playersA = useMemo<MatchPlayer[]>(() => {
|
||||||
const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined
|
const teamPlayers = m.teamA?.players
|
||||||
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
|
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
|
||||||
const all = (match as any).players as MatchPlayer[] | undefined
|
const all = m.players
|
||||||
const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined
|
const teamAUsers = m.teamAUsers
|
||||||
if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) {
|
if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) {
|
||||||
const setA = new Set(teamAUsers.map(u => u.steamId))
|
const setA = new Set(teamAUsers.map(u => u.steamId))
|
||||||
return all.filter(p => setA.has(p.user.steamId))
|
return all.filter(p => setA.has(p.user.steamId))
|
||||||
}
|
}
|
||||||
if (Array.isArray(all) && match.teamA?.id) {
|
if (Array.isArray(all) && m.teamA?.id) {
|
||||||
return all.filter(p => (p as any).team?.id === match.teamA?.id)
|
return all.filter(p => (p as unknown as MaybeTeam).team?.id === m.teamA?.id)
|
||||||
}
|
}
|
||||||
const votePlayers = state?.teams?.teamA?.players as
|
const votePlayers = state?.teams?.teamA?.players as
|
||||||
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
|
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
|
||||||
@ -395,19 +451,19 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}, [match, state?.teams?.teamA?.players])
|
}, [m, state?.teams?.teamA?.players])
|
||||||
|
|
||||||
const playersB = useMemo<MatchPlayer[]>(() => {
|
const playersB = useMemo<MatchPlayer[]>(() => {
|
||||||
const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined
|
const teamPlayers = m.teamB?.players
|
||||||
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
|
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
|
||||||
const all = (match as any).players as MatchPlayer[] | undefined
|
const all = m.players
|
||||||
const teamBUsers = (match as any).teamBUsers as { steamId: string }[] | undefined
|
const teamBUsers = m.teamBUsers
|
||||||
if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) {
|
if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) {
|
||||||
const setB = new Set(teamBUsers.map(u => u.steamId))
|
const setB = new Set(teamBUsers.map(u => u.steamId))
|
||||||
return all.filter(p => setB.has(p.user.steamId))
|
return all.filter(p => setB.has(p.user.steamId))
|
||||||
}
|
}
|
||||||
if (Array.isArray(all) && match.teamB?.id) {
|
if (Array.isArray(all) && m.teamB?.id) {
|
||||||
return all.filter(p => (p as any).team?.id === match.teamB?.id)
|
return all.filter(p => (p as unknown as MaybeTeam).team?.id === m.teamB?.id)
|
||||||
}
|
}
|
||||||
const votePlayers = state?.teams?.teamB?.players as
|
const votePlayers = state?.teams?.teamB?.players as
|
||||||
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
|
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
|
||||||
@ -423,14 +479,14 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}, [match, state?.teams?.teamB?.players])
|
}, [m, state?.teams?.teamB?.players])
|
||||||
|
|
||||||
const teamAPlayersForRank = useMemo(
|
const teamAPlayersForRank = useMemo(
|
||||||
() => playersA.map(p => ({ premierRank: p.user.premierRank ?? 0 })) as any,
|
() => playersA.map(p => ({ premierRank: p.user.premierRank ?? 0 })),
|
||||||
[playersA]
|
[playersA]
|
||||||
)
|
)
|
||||||
const teamBPlayersForRank = useMemo(
|
const teamBPlayersForRank = useMemo(
|
||||||
() => playersB.map(p => ({ premierRank: p.user.premierRank ?? 0 })) as any,
|
() => playersB.map(p => ({ premierRank: p.user.premierRank ?? 0 })),
|
||||||
[playersB]
|
[playersB]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -490,10 +546,6 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
[state, playersA, playersB, setState]
|
[state, playersA, playersB, setState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const amInTeamA = !!mySteamId && playersA.some(p => p.user?.steamId === mySteamId)
|
|
||||||
const amInTeamB = !!mySteamId && playersB.some(p => p.user?.steamId === mySteamId)
|
|
||||||
const myTeamId = amInTeamA ? match.teamA?.id : amInTeamB ? match.teamB?.id : null
|
|
||||||
|
|
||||||
// Links/Rechts anhand des eigenen Teams
|
// Links/Rechts anhand des eigenen Teams
|
||||||
let teamLeftKey: 'teamA' | 'teamB' = 'teamA'
|
let teamLeftKey: 'teamA' | 'teamB' = 'teamA'
|
||||||
let teamRightKey: 'teamA' | 'teamB' = 'teamB'
|
let teamRightKey: 'teamA' | 'teamB' = 'teamB'
|
||||||
@ -502,22 +554,15 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
teamRightKey = 'teamA'
|
teamRightKey = 'teamA'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Farben an Radar anlehnen
|
const teamLeft = teamLeftKey === 'teamA' ? m.teamA : m.teamB
|
||||||
const LEFT_RING = 'ring-2 ring-green-500/70 shadow-[0_10px_30px_rgba(34,197,94,0.25)]';
|
const teamRight = teamRightKey === 'teamA' ? m.teamA : m.teamB
|
||||||
const RIGHT_RING = 'ring-2 ring-red-500/70 shadow-[0_10px_30px_rgba(239,68,68,0.25)]';
|
|
||||||
|
|
||||||
const BASE_PANEL = 'relative rounded-lg p-2 transition-all duration-300 ease-out';
|
|
||||||
const INACTIVE_FADE = 'opacity-75 grayscale-[5%]';
|
|
||||||
|
|
||||||
const teamLeft = (match as any)[teamLeftKey]
|
|
||||||
const teamRight = (match as any)[teamRightKey]
|
|
||||||
const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB
|
const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB
|
||||||
const playersRight = teamRightKey === 'teamA' ? playersA : playersB
|
const playersRight = teamRightKey === 'teamA' ? playersA : playersB
|
||||||
const rankLeft = teamLeftKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank
|
const rankLeft = teamLeftKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank
|
||||||
const rankRight = teamRightKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank
|
const rankRight = teamRightKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank
|
||||||
|
|
||||||
const leftTeamId = state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id
|
const leftTeamId = state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id ?? null
|
||||||
const rightTeamId = state?.teams?.[teamRightKey]?.id ?? teamRight?.id
|
const rightTeamId = state?.teams?.[teamRightKey]?.id ?? teamRight?.id ?? null
|
||||||
|
|
||||||
const leftIsActiveTurn =
|
const leftIsActiveTurn =
|
||||||
!!currentStep?.teamId &&
|
!!currentStep?.teamId &&
|
||||||
@ -568,28 +613,15 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
|
|
||||||
// 4) exakt diese Liste fürs Radar verwenden
|
// 4) exakt diese Liste fürs Radar verwenden
|
||||||
const activeMapKeys = sortedMapPool;
|
const activeMapKeys = sortedMapPool;
|
||||||
const activeMapLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf]);
|
|
||||||
|
|
||||||
// Helper: Durchschnitt (nur finite Werte)
|
// Labels nur aus keys ableiten → stabil solange keys gleich bleiben
|
||||||
function avg(values: number[]) {
|
const radarLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf])
|
||||||
const valid = values.filter(v => Number.isFinite(v))
|
|
||||||
if (!valid.length) return 0
|
|
||||||
return valid.reduce((a, b) => a + b, 0) / valid.length
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) State für Radar-Daten je Team + Team-Ø
|
// 2) State für Radar-Daten je Team
|
||||||
const [teamRadarLeft, setTeamRadarLeft] = useState<number[]>(activeMapKeys.map(() => 0))
|
const [teamRadarLeft, setTeamRadarLeft] = useState<number[]>(activeMapKeys.map(() => 0))
|
||||||
const [teamRadarRight, setTeamRadarRight] = useState<number[]>(activeMapKeys.map(() => 0))
|
const [teamRadarRight, setTeamRadarRight] = useState<number[]>(activeMapKeys.map(() => 0))
|
||||||
const lastFetchSigRef = useRef<string>('');
|
const lastFetchSigRef = useRef<string>('');
|
||||||
|
|
||||||
// Hilfs-Memos: eindeutige Id-Liste + Schlüssel
|
|
||||||
const allSteamIds = useMemo(() => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
playersLeft.forEach(p => ids.add(p.user.steamId));
|
|
||||||
playersRight.forEach(p => ids.add(p.user.steamId));
|
|
||||||
return Array.from(ids);
|
|
||||||
}, [playersLeft, playersRight]);
|
|
||||||
|
|
||||||
// stabile Id-Menge über beide Teams (reihenfolgeunabhängig)
|
// stabile Id-Menge über beide Teams (reihenfolgeunabhängig)
|
||||||
const idsKey = useMemo(() => {
|
const idsKey = useMemo(() => {
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
@ -604,12 +636,6 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
[activeMapKeys]
|
[activeMapKeys]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Labels nur aus keys ableiten → stabil solange mapsKey gleich bleibt
|
|
||||||
const radarLabels = useMemo(() => {
|
|
||||||
// nur MAP_OPTIONS / state.mapVisuals lesen, Ergebnis aber von mapsKey abhängig
|
|
||||||
return activeMapKeys.map(labelOf)
|
|
||||||
}, [mapsKey, labelOf, activeMapKeys])
|
|
||||||
|
|
||||||
// Datasets-Array: nur neu, wenn sich Werte oder Namen ändern
|
// Datasets-Array: nur neu, wenn sich Werte oder Namen ändern
|
||||||
const radarDatasets = useMemo(() => ([
|
const radarDatasets = useMemo(() => ([
|
||||||
{
|
{
|
||||||
@ -631,98 +657,63 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
// Icons ebenfalls stabilisieren
|
// Icons ebenfalls stabilisieren
|
||||||
const radarIcons = useMemo(
|
const radarIcons = useMemo(
|
||||||
() => activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`),
|
() => activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`),
|
||||||
[mapsKey] // reicht, solange keys unverändert
|
[activeMapKeys]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return;
|
||||||
|
|
||||||
const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e
|
const evt = unwrapEvent(lastEvent);
|
||||||
const evt = unwrap(lastEvent)
|
const type = (isRecord(lastEvent) && isString((lastEvent as Record<string, unknown>).type))
|
||||||
const type = lastEvent.type ?? evt?.type
|
? (lastEvent as Record<'type', string>).type
|
||||||
|
: (isString((evt as Record<string, unknown>).type) ? (evt as Record<'type', string>).type : undefined);
|
||||||
|
|
||||||
const evtMatchId: string | null = evt?.matchId ?? null
|
const evtMatchId = isString((evt as Record<string, unknown>).matchId) ? (evt as Record<'matchId', string>).matchId : null;
|
||||||
const evtTeamId : string | null = evt?.teamId ?? null
|
const evtTeamId = isString((evt as Record<string, unknown>).teamId) ? (evt as Record<'teamId', string>).teamId : null;
|
||||||
const actionType: string | null = evt?.actionType ?? null
|
const actionType = isString((evt as Record<string, unknown>).actionType) ? (evt as Record<'actionType', string>).actionType : null;
|
||||||
const actionData: string | null = evt?.actionData ?? null // z.B. newLeaderSteamId
|
const actionData = isString((evt as Record<string, unknown>).actionData) ? (evt as Record<'actionData', string>).actionData : null;
|
||||||
|
|
||||||
// 1) Relevanz wie bisher
|
const isForThisMatchByMatchId = !!evtMatchId && evtMatchId === match.id;
|
||||||
const isForThisMatchByMatchId =
|
const isForThisMatchByTeamId = !!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id);
|
||||||
!!evtMatchId && evtMatchId === match.id
|
|
||||||
|
|
||||||
const isForThisMatchByTeamId =
|
|
||||||
!!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id)
|
|
||||||
|
|
||||||
// 2) Notifications abdecken (changed + self)
|
|
||||||
const isLeaderChangeNotification =
|
const isLeaderChangeNotification =
|
||||||
type === 'notification' && (actionType === 'team-leader-changed' || actionType === 'team-leader-self')
|
type === 'notification' && (actionType === 'team-leader-changed' || actionType === 'team-leader-self');
|
||||||
|
|
||||||
// 3) Gehört der neue Leader zu unseren Teams?
|
const byNewLeaderId = isLeaderChangeNotification && !!actionData && teamSteamIds.has(actionData);
|
||||||
const byNewLeaderId =
|
const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId;
|
||||||
isLeaderChangeNotification && !!actionData && teamSteamIds.has(actionData)
|
|
||||||
|
|
||||||
// 4) Relevanz
|
|
||||||
const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId
|
|
||||||
|
|
||||||
// 5) Offensichtliche Leader-Änderungen -> hart refreshen, auch ohne Relevanzbeweis
|
|
||||||
const forceRefresh =
|
const forceRefresh =
|
||||||
type === 'team-leader-changed' ||
|
type === 'team-leader-changed' || type === 'team-updated' || isLeaderChangeNotification;
|
||||||
type === 'team-updated' ||
|
|
||||||
isLeaderChangeNotification
|
|
||||||
|
|
||||||
if (!isRelevant && !forceRefresh) return
|
if (!isRelevant && !forceRefresh) return;
|
||||||
|
|
||||||
// map-vote-updated: opensAt-Override wie gehabt ...
|
|
||||||
if (type === 'map-vote-updated') {
|
|
||||||
const { opensAt, leadMinutes } = evt ?? {}
|
|
||||||
if (opensAt) {
|
|
||||||
const ts = new Date(opensAt).getTime()
|
|
||||||
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts)
|
|
||||||
setState(prev => (prev ? { ...prev, opensAt } : prev))
|
|
||||||
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) {
|
|
||||||
const ts = matchBaseTs - Number(leadMinutes) * 60_000
|
|
||||||
setOpensAtOverrideTs(ts)
|
|
||||||
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Leader-Events zuerst lokal anwenden (ohne Reload) ----------------------
|
|
||||||
const isLeaderChangeEvent =
|
|
||||||
type === 'team-leader-changed' ||
|
|
||||||
(type === 'notification' &&
|
|
||||||
(actionType === 'team-leader-changed' || actionType === 'team-leader-self'));
|
|
||||||
|
|
||||||
if (isLeaderChangeEvent) {
|
|
||||||
applyLeaderChange(evtTeamId ?? null, actionData ?? null);
|
|
||||||
return; // kein load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- map-vote-/sonstige Events wie gehabt -----------------------------------
|
|
||||||
let shouldRefresh = MAPVOTE_REFRESH.has(type);
|
|
||||||
|
|
||||||
// Optional: harte Reloads weiter einschränken, falls gewünscht
|
|
||||||
// (z.B. kein Reload für "team-updated", wenn dich nur Leader interessiert)
|
|
||||||
// if (type === 'team-updated') shouldRefresh = false;
|
|
||||||
|
|
||||||
if (type === 'map-vote-updated') {
|
if (type === 'map-vote-updated') {
|
||||||
const { opensAt, leadMinutes } = evt ?? {};
|
const opensAt = isString((evt as Record<string, unknown>).opensAt) ? (evt as Record<'opensAt', string>).opensAt : undefined;
|
||||||
|
const leadMinRaw = typeof (evt as Record<string, unknown>).leadMinutes === 'number' ? (evt as Record<'leadMinutes', number>).leadMinutes : undefined;
|
||||||
if (opensAt) {
|
if (opensAt) {
|
||||||
const ts = new Date(opensAt).getTime();
|
const ts = new Date(opensAt).getTime();
|
||||||
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts);
|
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts);
|
||||||
setState(prev => (prev ? { ...prev, opensAt } : prev));
|
setState(prev => (prev ? { ...prev, opensAt } : prev));
|
||||||
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) {
|
} else if (Number.isFinite(leadMinRaw ?? NaN) && matchBaseTs != null) {
|
||||||
const ts = matchBaseTs - Number(leadMinutes) * 60_000;
|
const ts = matchBaseTs - (leadMinRaw as number) * 60_000;
|
||||||
setOpensAtOverrideTs(ts);
|
setOpensAtOverrideTs(ts);
|
||||||
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev));
|
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLeaderChangeEvent =
|
||||||
|
type === 'team-leader-changed' || isLeaderChangeNotification;
|
||||||
|
|
||||||
|
if (isLeaderChangeEvent) {
|
||||||
|
applyLeaderChange(evtTeamId ?? null, actionData ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRefresh = isRefreshEvent(type);
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
}, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, load, matchBaseTs, teamSteamIds])
|
}, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, load, matchBaseTs, teamSteamIds, applyLeaderChange]);
|
||||||
|
|
||||||
// Effect NUR an stabile Keys + Tab hängen
|
// Effect NUR an stabile Keys + Tab hängen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -766,7 +757,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => { aborted = true; };
|
return () => { aborted = true; };
|
||||||
}, [tab, idsKey, mapsKey]);
|
}, [tab, idsKey, mapsKey, activeMapKeys, playersLeft, playersRight]);
|
||||||
|
|
||||||
/* =================== Render =================== */
|
/* =================== Render =================== */
|
||||||
|
|
||||||
@ -805,9 +796,9 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
try {
|
try {
|
||||||
await postAdminEdit(next)
|
await postAdminEdit(next)
|
||||||
await load()
|
await load()
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setAdminEditMode(v => !v)
|
setAdminEditMode(v => !v)
|
||||||
alert(e?.message ?? 'Fehler beim Umschalten des Admin-Edits')
|
alert(e instanceof Error ? e.message : 'Fehler beim Umschalten des Admin-Edits')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -825,7 +816,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' })
|
const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' })
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({} as { message?: string }))
|
||||||
alert(j.message ?? 'Reset fehlgeschlagen')
|
alert(j.message ?? 'Reset fehlgeschlagen')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -866,7 +857,9 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
🔒 Admin-Edit aktiv – Voting pausiert
|
🔒 Admin-Edit aktiv – Voting pausiert
|
||||||
{(() => {
|
{(() => {
|
||||||
const all: Array<{ steamId: string; name?: string | null }> = []
|
const all: Array<{ steamId: string; name?: string | null }> = []
|
||||||
const pushMaybe = (x: any) => { if (x?.steamId) all.push({ steamId: x.steamId, name: x.name }) }
|
const pushMaybe = (x: { steamId?: string | null; name?: string | null } | null | undefined) => {
|
||||||
|
if (x?.steamId) all.push({ steamId: x.steamId, name: x.name })
|
||||||
|
}
|
||||||
pushMaybe(state?.teams?.teamA?.leader)
|
pushMaybe(state?.teams?.teamA?.leader)
|
||||||
pushMaybe(state?.teams?.teamB?.leader)
|
pushMaybe(state?.teams?.teamB?.leader)
|
||||||
;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe)
|
;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe)
|
||||||
@ -967,7 +960,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img
|
<Image
|
||||||
src={getTeamLogo(teamLeft?.logo)}
|
src={getTeamLogo(teamLeft?.logo)}
|
||||||
alt={teamLeft?.name ?? 'Team'}
|
alt={teamLeft?.name ?? 'Team'}
|
||||||
className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
||||||
@ -991,7 +984,6 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
matchType={match.matchType}
|
matchType={match.matchType}
|
||||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||||
isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId}
|
isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId}
|
||||||
isActiveTurn={leftIsActiveTurn}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -1027,17 +1019,6 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
|
const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
|
||||||
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
|
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
|
||||||
|
|
||||||
// DECIDER-Chooser (letztes Ban davor)
|
|
||||||
const steps = state?.steps ?? []
|
|
||||||
const decIdx = steps.findIndex(s => s.action === 'decider')
|
|
||||||
let deciderChooserTeamId: string | null = null
|
|
||||||
if (decIdx >= 0) {
|
|
||||||
for (let i = decIdx - 1; i >= 0; i--) {
|
|
||||||
const s = steps[i]
|
|
||||||
if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveTeamId =
|
const effectiveTeamId =
|
||||||
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
|
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
|
||||||
|
|
||||||
@ -1056,7 +1037,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
return (
|
return (
|
||||||
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
|
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
|
||||||
{pickedByLeft ? (
|
{pickedByLeft ? (
|
||||||
<img
|
<Image
|
||||||
src={getTeamLogo(teamLeft?.logo)}
|
src={getTeamLogo(teamLeft?.logo)}
|
||||||
alt={teamLeft?.name ?? 'Team'}
|
alt={teamLeft?.name ?? 'Team'}
|
||||||
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
||||||
@ -1076,9 +1057,9 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
onMouseDown={() => onHoldStart(map, isAvailable)}
|
onMouseDown={() => onHoldStart(map, isAvailable)}
|
||||||
onMouseUp={() => cancelOrSubmitIfComplete(map)}
|
onMouseUp={() => cancelOrSubmitIfComplete(map)}
|
||||||
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
|
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
|
||||||
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
|
onTouchStart={onTouchStart(map, isAvailable)}
|
||||||
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
|
onTouchEnd={onTouchEnd(map)}
|
||||||
onTouchCancel={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
|
onTouchCancel={onTouchEnd(map)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
|
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
@ -1117,7 +1098,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{pickedByRight ? (
|
{pickedByRight ? (
|
||||||
<img
|
<Image
|
||||||
src={getTeamLogo(teamRight?.logo)}
|
src={getTeamLogo(teamRight?.logo)}
|
||||||
alt={teamRight?.name ?? 'Team'}
|
alt={teamRight?.name ?? 'Team'}
|
||||||
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
||||||
@ -1166,7 +1147,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
<div className="min-w-0 text-right">
|
<div className="min-w-0 text-right">
|
||||||
<div className="font-bold text-lg truncate">{teamRight?.name ?? 'Team'}</div>
|
<div className="font-bold text-lg truncate">{teamRight?.name ?? 'Team'}</div>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<Image
|
||||||
src={getTeamLogo(teamRight?.logo)}
|
src={getTeamLogo(teamRight?.logo)}
|
||||||
alt={teamRight?.name ?? 'Team'}
|
alt={teamRight?.name ?? 'Team'}
|
||||||
className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
||||||
@ -1186,7 +1167,6 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
matchType={match.matchType}
|
matchType={match.matchType}
|
||||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||||
isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId}
|
isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId}
|
||||||
isActiveTurn={rightIsActiveTurn}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -1279,7 +1259,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
>
|
>
|
||||||
{/* Hintergrundbild */}
|
{/* Hintergrundbild */}
|
||||||
{bg && (
|
{bg && (
|
||||||
<img
|
<Image
|
||||||
src={bg}
|
src={bg}
|
||||||
alt={label}
|
alt={label}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
@ -1289,7 +1269,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
|
|
||||||
{/* Team-Logo in der Ecke (Picker) */}
|
{/* Team-Logo in der Ecke (Picker) */}
|
||||||
{cornerLogo && (
|
{cornerLogo && (
|
||||||
<img
|
<Image
|
||||||
src={cornerLogo}
|
src={cornerLogo}
|
||||||
alt="Picker-Team"
|
alt="Picker-Team"
|
||||||
className={`absolute ${cornerPos} w-6 h-6 rounded-full object-contain bg-white/90 border border-white/70 shadow-sm`}
|
className={`absolute ${cornerPos} w-6 h-6 rounded-full object-contain bg-white/90 border border-white/70 shadow-sm`}
|
||||||
@ -1319,7 +1299,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 z-20">
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 z-20">
|
||||||
{mapLogo ? (
|
{mapLogo ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<Image
|
||||||
src={mapLogo}
|
src={mapLogo}
|
||||||
alt={label}
|
alt={label}
|
||||||
className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg"
|
className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg"
|
||||||
|
|||||||
@ -14,7 +14,6 @@ type Props = {
|
|||||||
rank?: number
|
rank?: number
|
||||||
matchType?: 'premier' | 'competitive' | string
|
matchType?: 'premier' | 'competitive' | string
|
||||||
isLeader?: boolean
|
isLeader?: boolean
|
||||||
isActiveTurn?: boolean
|
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ export default function MapVoteProfileCard({
|
|||||||
avatar,
|
avatar,
|
||||||
rank = 0,
|
rank = 0,
|
||||||
isLeader = false,
|
isLeader = false,
|
||||||
isActiveTurn = false,
|
|
||||||
onClick,
|
onClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const isRight = side === 'B'
|
const isRight = side === 'B'
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { format } from 'date-fns'
|
|
||||||
import { de } from 'date-fns/locale'
|
|
||||||
import Table from './Table'
|
import Table from './Table'
|
||||||
import PremierRankBadge from './PremierRankBadge'
|
import PremierRankBadge from './PremierRankBadge'
|
||||||
import CompRankBadge from './CompRankBadge'
|
import CompRankBadge from './CompRankBadge'
|
||||||
@ -34,17 +32,6 @@ const KPR_CAP = 1.2
|
|||||||
const APR_CAP = 0.6
|
const APR_CAP = 0.6
|
||||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||||
|
|
||||||
type ApiStats = {
|
|
||||||
stats: Array<{
|
|
||||||
date: string
|
|
||||||
kills: number
|
|
||||||
deaths: number
|
|
||||||
assists?: number | null
|
|
||||||
totalDamage?: number | null
|
|
||||||
rounds?: number | null
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrefetchedFaceit = {
|
type PrefetchedFaceit = {
|
||||||
level: number|null
|
level: number|null
|
||||||
elo: number|null
|
elo: number|null
|
||||||
@ -214,21 +201,22 @@ function hasVacBan(p: MatchPlayer): boolean {
|
|||||||
return !!(b?.vacBanned || (b?.numberOfVACBans ?? 0) > 0)
|
return !!(b?.vacBanned || (b?.numberOfVACBans ?? 0) > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
type VoteAction = 'BAN' | 'PICK' | 'DECIDER'
|
|
||||||
type VoteStep = { order: number; action: VoteAction; map?: string | null }
|
|
||||||
|
|
||||||
// 1) Normalisieren: String → "de_mirage"
|
// 1) Normalisieren: String → "de_mirage"
|
||||||
const norm = (m?: unknown): string => {
|
const norm = (m?: unknown): string => {
|
||||||
if (!m) return ''
|
if (!m) return ''
|
||||||
if (typeof m === 'string') return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
if (typeof m === 'string') {
|
||||||
if (typeof m === 'object') {
|
return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
||||||
|
}
|
||||||
|
if (typeof m === 'object' && m) {
|
||||||
const o = m as Record<string, unknown>
|
const o = m as Record<string, unknown>
|
||||||
// häufige Felder, die den Mapkey tragen:
|
const candidates: unknown[] = [
|
||||||
const cand =
|
o['key'], o['map'], o['name'], o['id'], o['value'], o['slug']
|
||||||
o.key ?? o.map ?? o.name ?? o.id ?? (o as any)?.value ?? (o as any)?.slug
|
]
|
||||||
return typeof cand === 'string'
|
for (const c of candidates) {
|
||||||
? cand.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
if (typeof c === 'string') {
|
||||||
: ''
|
return c.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@ -247,26 +235,32 @@ const mapLabelFromKey = (key?: string) => {
|
|||||||
function extractSeriesMaps(match: Match, bestOf: number): string[] {
|
function extractSeriesMaps(match: Match, bestOf: number): string[] {
|
||||||
const n = Math.max(1, bestOf)
|
const n = Math.max(1, bestOf)
|
||||||
|
|
||||||
// a) klassische Steps (nur PICK/DECIDER)
|
// a) Steps (nur PICK/DECIDER)
|
||||||
const fromSteps: unknown[] =
|
const stepsRaw = Array.isArray(match.mapVote?.steps) ? match.mapVote!.steps as unknown[] : []
|
||||||
(match.mapVote?.steps ?? [])
|
type StepRec = { action?: unknown; order?: unknown; map?: unknown }
|
||||||
.filter((s: any) => s && (s.action === 'PICK' || s.action === 'DECIDER'))
|
const steps = stepsRaw
|
||||||
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0))
|
.map(s => (typeof s === 'object' && s ? s as StepRec : {}))
|
||||||
.map((s: any) => s.map)
|
.filter(s => s.action === 'PICK' || s.action === 'DECIDER')
|
||||||
|
.sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0))
|
||||||
|
.map(s => s.map)
|
||||||
|
|
||||||
// b) häufige Ergebnis-Felder
|
// b) Ergebnis-Felder
|
||||||
const mv: any = match.mapVote ?? {}
|
const mv = match.mapVote as unknown as Record<string, unknown> | undefined
|
||||||
const candidates: unknown[] = [
|
const mvResult = (mv?.['result'] && typeof mv['result'] === 'object') ? mv['result'] as Record<string, unknown> : undefined
|
||||||
mv.result?.maps,
|
const mvFinal = (mv?.['final'] && typeof mv['final'] === 'object') ? mv['final'] as Record<string, unknown> : undefined
|
||||||
mv.result?.picks, // [{map: '...'}] / [{key:'...'}]
|
|
||||||
mv.result?.series, // ['de_mirage', ...] oder [{key:...}]
|
const getArr = (v: unknown): unknown[] => Array.isArray(v) ? v : []
|
||||||
mv.final?.maps,
|
const picks = getArr(mvResult?.['picks']).map(x => (typeof x === 'object' && x ? (x as Record<string, unknown>)['map'] ?? (x as Record<string, unknown>)['key'] : x))
|
||||||
(match as any)?.series?.maps,
|
const series = getArr(mvResult?.['series'])
|
||||||
(match as any)?.maps,
|
const resMaps = getArr(mvResult?.['maps'])
|
||||||
].flat().filter(Boolean)
|
const finMaps = getArr(mvFinal?.['maps'])
|
||||||
|
|
||||||
|
const more: unknown[] = [
|
||||||
|
...resMaps, ...picks, ...series, ...finMaps
|
||||||
|
]
|
||||||
|
|
||||||
// c) flach ziehen + normalisieren + entduplizieren (stabile Reihenfolge)
|
// c) flach ziehen + normalisieren + entduplizieren (stabile Reihenfolge)
|
||||||
const chain = [...fromSteps, ...candidates]
|
const chain = [...steps, ...more]
|
||||||
.map(norm)
|
.map(norm)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
@ -388,14 +382,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
const [playerFaceits, setPlayerFaceits] = useState<Record<string, PrefetchedFaceit | null>>({})
|
const [playerFaceits, setPlayerFaceits] = useState<Record<string, PrefetchedFaceit | null>>({})
|
||||||
|
|
||||||
// ⬇️ bestOf nur im State halten: community → 3 (oder was du magst), sonst 1
|
// ⬇️ bestOf nur im State halten: community → 3 (oder was du magst), sonst 1
|
||||||
const [bestOf, setBestOf] = useState<1 | 3 | 5>(() =>
|
const [bestOf] = useState<1 | 3 | 5>(() =>
|
||||||
match.matchType === 'community' ? 3 : 1
|
match.matchType === 'community' ? 3 : 1
|
||||||
)
|
)
|
||||||
|
|
||||||
// Alle Maps der Serie (BO3/BO5) – abhängig von bestOf-State
|
// Alle Maps der Serie (BO3/BO5) – abhängig von bestOf-State
|
||||||
const allMaps = useMemo(
|
const allMaps = useMemo(
|
||||||
() => extractSeriesMaps(match, bestOf),
|
() => extractSeriesMaps(match, bestOf),
|
||||||
[match.mapVote?.steps, (match as any)?.mapVote?.result?.maps, match.map, bestOf]
|
[match, bestOf]
|
||||||
)
|
)
|
||||||
const [activeMapIdx, setActiveMapIdx] = useState(0)
|
const [activeMapIdx, setActiveMapIdx] = useState(0)
|
||||||
|
|
||||||
@ -427,6 +421,15 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
|
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
|
||||||
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
|
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null
|
||||||
|
|
||||||
|
const getString = (v: unknown): string | undefined =>
|
||||||
|
typeof v === 'string' ? v : undefined
|
||||||
|
|
||||||
|
const getNumber = (v: unknown): number | undefined =>
|
||||||
|
typeof v === 'number' ? v : undefined
|
||||||
|
|
||||||
// → Welche Seite ist "mein Team"?
|
// → Welche Seite ist "mein Team"?
|
||||||
const mySteamId = session?.user?.steamId
|
const mySteamId = session?.user?.steamId
|
||||||
const mySide: 'A' | 'B' | null = mySteamId
|
const mySide: 'A' | 'B' | null = mySteamId
|
||||||
@ -472,8 +475,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [match.teamA?.players, match.teamB?.players, playerFaceits, playerSummaries])
|
||||||
}, [match.teamA?.players, match.teamB?.players])
|
|
||||||
|
|
||||||
// beim mount user-tz aus DB laden
|
// beim mount user-tz aus DB laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -487,7 +489,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
const sp = new URLSearchParams(window.location.search)
|
const sp = new URLSearchParams(window.location.search)
|
||||||
const m = Number(sp.get('m'))
|
const m = Number(sp.get('m'))
|
||||||
if (Number.isFinite(m) && m >= 0 && m < allMaps.length) setActiveMapIdx(m)
|
if (Number.isFinite(m) && m >= 0 && m < allMaps.length) setActiveMapIdx(m)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [allMaps.length])
|
}, [allMaps.length])
|
||||||
|
|
||||||
const setActive = (idx: number) => {
|
const setActive = (idx: number) => {
|
||||||
@ -519,7 +520,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
const fromVote = extractSeriesMaps(match, bestOf)
|
const fromVote = extractSeriesMaps(match, bestOf)
|
||||||
const n = Math.max(1, bestOf)
|
const n = Math.max(1, bestOf)
|
||||||
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({length: n - fromVote.length}, () => '')]
|
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({length: n - fromVote.length}, () => '')]
|
||||||
}, [bestOf, match.mapVote?.steps?.length])
|
}, [match, bestOf])
|
||||||
|
|
||||||
// Ticker für Mapvote-Zeitfenster
|
// Ticker für Mapvote-Zeitfenster
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -541,7 +542,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
? leadOverride
|
? leadOverride
|
||||||
: (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
|
: (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
|
||||||
return matchBaseTs - lead * 60_000
|
return matchBaseTs - lead * 60_000
|
||||||
}, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride])
|
}, [opensAtOverride, match.mapVote, matchBaseTs, leadOverride])
|
||||||
|
|
||||||
const sseOpensAtTs = voteOpensAtTs
|
const sseOpensAtTs = voteOpensAtTs
|
||||||
const sseLeadMinutes = leadOverride
|
const sseLeadMinutes = leadOverride
|
||||||
@ -554,26 +555,41 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
// SSE-Listener (nur relevante Events)
|
// SSE-Listener (nur relevante Events)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
const outer = lastEvent as any
|
|
||||||
const maybeInner = outer?.payload
|
|
||||||
const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner)
|
|
||||||
? maybeInner
|
|
||||||
: outer
|
|
||||||
|
|
||||||
const type = base?.type
|
const outer = lastEvent as unknown
|
||||||
const evt = base?.payload ?? base
|
|
||||||
if (!evt?.matchId || evt.matchId !== match.id) return
|
|
||||||
|
|
||||||
const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}`
|
// evtl. „doppelt“ verschachteltes Event-Format abrollen
|
||||||
|
const maybeInner = isRecord(outer) && 'payload' in outer ? (outer as Record<string, unknown>).payload : undefined
|
||||||
|
const base = isRecord(maybeInner) && 'type' in maybeInner && 'payload' in maybeInner
|
||||||
|
? (maybeInner as Record<string, unknown>)
|
||||||
|
: (isRecord(outer) ? (outer as Record<string, unknown>) : {})
|
||||||
|
|
||||||
|
// Typ & Payload sicher lesen
|
||||||
|
const type = getString(base.type)
|
||||||
|
const payloadRaw = 'payload' in base ? (base as Record<string, unknown>).payload : base
|
||||||
|
const evt = isRecord(payloadRaw) ? payloadRaw : {}
|
||||||
|
|
||||||
|
const evtMatchId = getString(evt.matchId)
|
||||||
|
if (!evtMatchId || evtMatchId !== match.id) return
|
||||||
|
|
||||||
|
const opensAtRaw = evt.opensAt
|
||||||
|
const leadMinutesRaw = evt.leadMinutes
|
||||||
|
|
||||||
|
const key = `${type ?? ''}|${evtMatchId}|${opensAtRaw ?? ''}|${
|
||||||
|
Number.isFinite(leadMinutesRaw as number) ? leadMinutesRaw : ''
|
||||||
|
}`
|
||||||
if (key === lastHandledKeyRef.current) return
|
if (key === lastHandledKeyRef.current) return
|
||||||
lastHandledKeyRef.current = key
|
lastHandledKeyRef.current = key
|
||||||
|
|
||||||
if (type === 'map-vote-updated') {
|
if (type === 'map-vote-updated') {
|
||||||
if (evt?.opensAt) setOpensAtOverride(new Date(evt.opensAt).getTime())
|
const opensAt = opensAtRaw
|
||||||
if (Number.isFinite(evt?.leadMinutes)) {
|
if (opensAt) setOpensAtOverride(new Date(opensAt as string | number | Date).getTime())
|
||||||
const lead = Number(evt.leadMinutes)
|
|
||||||
|
const leadNum = getNumber(leadMinutesRaw)
|
||||||
|
if (Number.isFinite(leadNum)) {
|
||||||
|
const lead = leadNum as number
|
||||||
setLeadOverride(lead)
|
setLeadOverride(lead)
|
||||||
if (!evt?.opensAt) {
|
if (!opensAt) {
|
||||||
const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
|
const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
|
||||||
setOpensAtOverride(baseTs - lead * 60_000)
|
setOpensAtOverride(baseTs - lead * 60_000)
|
||||||
}
|
}
|
||||||
@ -583,7 +599,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
|
const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
|
||||||
if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) router.refresh()
|
if (type && REFRESH_TYPES.has(type)) router.refresh()
|
||||||
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
|
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -600,7 +616,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
const url = `${window.location.pathname}?${sp.toString()}${window.location.hash}`
|
const url = `${window.location.pathname}?${sp.toString()}${window.location.hash}`
|
||||||
window.history.replaceState(null, '', url)
|
window.history.replaceState(null, '', url)
|
||||||
}
|
}
|
||||||
}, [currentMapKey, allMaps, bestOf, isPickBanPhase]) // ← Dependencies
|
}, [currentMapKey, allMaps, bestOf, isPickBanPhase, activeMapIdx])
|
||||||
|
|
||||||
// Löschen
|
// Löschen
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
@ -896,7 +912,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{isCommunity && match.teamA?.logo && (
|
{isCommunity && match.teamA?.logo && (
|
||||||
<img
|
<Image
|
||||||
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
|
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
alt={match.teamA.name ?? 'Team A'}
|
alt={match.teamA.name ?? 'Team A'}
|
||||||
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
|
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
|
||||||
@ -936,7 +952,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isCommunity && match.teamB?.logo && (
|
{isCommunity && match.teamB?.logo && (
|
||||||
<img
|
<Image
|
||||||
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
|
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
alt={match.teamB.name ?? 'Team B'}
|
alt={match.teamB.name ?? 'Team B'}
|
||||||
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
|
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
|
||||||
@ -1037,7 +1053,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
<MiniPlayerCard
|
<MiniPlayerCard
|
||||||
open={!!hoverPlayer}
|
open={!!hoverPlayer}
|
||||||
player={hoverPlayer}
|
player={hoverPlayer}
|
||||||
anchor={null}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
// Nicht schließen, wenn entweder Anchor ODER die Card selbst gerade gehovert ist
|
// Nicht schließen, wenn entweder Anchor ODER die Card selbst gerade gehovert ist
|
||||||
const anchorHovered = !!anchorEl?.matches(':hover')
|
const anchorHovered = !!anchorEl?.matches(':hover')
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /src/app/[locale]/components/MatchPlayerCard.tsx
|
||||||
|
|
||||||
import Table from './Table'
|
import Table from './Table'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { MatchPlayer } from '../../../types/match'
|
import { MatchPlayer } from '../../../types/match'
|
||||||
@ -6,7 +8,32 @@ type Props = {
|
|||||||
player: MatchPlayer
|
player: MatchPlayer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Safeties für optionale/lockere Stats-Felder */
|
||||||
|
type StatsShape = {
|
||||||
|
adr?: unknown
|
||||||
|
hsPercent?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdr(stats: unknown): number | null {
|
||||||
|
if (stats && typeof stats === 'object') {
|
||||||
|
const v = (stats as StatsShape).adr
|
||||||
|
return typeof v === 'number' ? v : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHsPercent(stats: unknown): number | null {
|
||||||
|
if (stats && typeof stats === 'object') {
|
||||||
|
const v = (stats as StatsShape).hsPercent
|
||||||
|
return typeof v === 'number' ? v : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export default function MatchPlayerCard({ player }: Props) {
|
export default function MatchPlayerCard({ player }: Props) {
|
||||||
|
const adr = getAdr(player.stats)
|
||||||
|
const hsPercent = getHsPercent(player.stats)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell hoverable className="w-[48px] p-1 text-center align-middle whitespace-nowrap">
|
<Table.Cell hoverable className="w-[48px] p-1 text-center align-middle whitespace-nowrap">
|
||||||
@ -26,8 +53,9 @@ export default function MatchPlayerCard({ player }: Props) {
|
|||||||
<Table.Cell hoverable>{player.stats?.kills ?? '-'}</Table.Cell>
|
<Table.Cell hoverable>{player.stats?.kills ?? '-'}</Table.Cell>
|
||||||
<Table.Cell hoverable>{player.stats?.deaths ?? '-'}</Table.Cell>
|
<Table.Cell hoverable>{player.stats?.deaths ?? '-'}</Table.Cell>
|
||||||
<Table.Cell hoverable>{player.stats?.assists ?? '-'}</Table.Cell>
|
<Table.Cell hoverable>{player.stats?.assists ?? '-'}</Table.Cell>
|
||||||
<Table.Cell hoverable>{(player.stats as any)?.adr ?? '-'}</Table.Cell>
|
<Table.Cell hoverable>{adr ?? '-'}</Table.Cell>
|
||||||
<Table.Cell hoverable>{(player.stats as any)?.adr ?? '-'}</Table.Cell>
|
{/* Falls du kein HS% hast, kannst du diese Spalte auch entfernen oder nochmals ADR zeigen */}
|
||||||
|
<Table.Cell hoverable>{hsPercent ?? '-'}</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell className="text-end">
|
<Table.Cell className="text-end">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// MatchReadyOverlay.tsx
|
// /src/app/[locale]/components/MatchReadyOverlay.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@ -7,6 +7,7 @@ import { useSSEStore } from '@/lib/useSSEStore'
|
|||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -22,6 +23,27 @@ type Props = {
|
|||||||
|
|
||||||
type Presence = 'online' | 'away' | 'offline'
|
type Presence = 'online' | 'away' | 'offline'
|
||||||
|
|
||||||
|
type Participant = {
|
||||||
|
steamId: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
team: 'A' | 'B' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadyResponse = {
|
||||||
|
participants?: Participant[]
|
||||||
|
ready?: Record<string, string>
|
||||||
|
total?: number
|
||||||
|
countReady?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type LooseObj = Record<string, unknown>
|
||||||
|
type SseEventLoose = {
|
||||||
|
type?: string
|
||||||
|
payload?: LooseObj
|
||||||
|
ts?: number
|
||||||
|
} & LooseObj
|
||||||
|
|
||||||
function fmt(ms: number) {
|
function fmt(ms: number) {
|
||||||
const sec = Math.max(0, Math.ceil(ms / 1000))
|
const sec = Math.max(0, Math.ceil(ms / 1000))
|
||||||
const m = Math.floor(sec / 60)
|
const m = Math.floor(sec / 60)
|
||||||
@ -78,7 +100,6 @@ export default function MatchReadyOverlay({
|
|||||||
const shouldRender = Boolean(isVisibleBase && iAmAllowed)
|
const shouldRender = Boolean(isVisibleBase && iAmAllowed)
|
||||||
|
|
||||||
// Ready-Status
|
// Ready-Status
|
||||||
type Participant = { steamId: string; name: string; avatar: string; team: 'A' | 'B' | null }
|
|
||||||
const [participants, setParticipants] = useState<Participant[]>([])
|
const [participants, setParticipants] = useState<Participant[]>([])
|
||||||
const [readyMap, setReadyMap] = useState<Record<string, string>>({})
|
const [readyMap, setReadyMap] = useState<Record<string, string>>({})
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
@ -90,36 +111,59 @@ export default function MatchReadyOverlay({
|
|||||||
// ----- AUDIO -----
|
// ----- AUDIO -----
|
||||||
const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const audioStartedRef = useRef(false)
|
const audioStartedRef = useRef(false)
|
||||||
const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } }
|
const stopBeeps = () => {
|
||||||
|
if (beepsRef.current) {
|
||||||
|
clearInterval(beepsRef.current)
|
||||||
|
beepsRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ensureAudioUnlocked = async () => {
|
// 1) Typ anpassen – keine privaten Felder spiegeln
|
||||||
|
type MaybeSound = {
|
||||||
|
play?: (name: string) => void
|
||||||
|
ensureUnlocked?: () => Promise<void> | void
|
||||||
|
unlock?: () => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) überall sound über unknown casten
|
||||||
|
const startBeeps = useCallback(() => {
|
||||||
|
const S = sound as unknown as MaybeSound
|
||||||
|
try { S.play?.('ready') } catch {}
|
||||||
|
stopBeeps()
|
||||||
|
beepsRef.current = setInterval(() => {
|
||||||
|
try { S.play?.('beep') } catch {}
|
||||||
|
}, 1000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const playMenuAccept = useCallback(() => {
|
||||||
|
const S = sound as unknown as MaybeSound
|
||||||
|
try { S.play?.('menu_accept') } catch {}
|
||||||
|
try { void new Audio('/assets/sounds/menu_accept.wav').play() } catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 3) ensureAudioUnlocked ohne public ctx/audioContext im Interface
|
||||||
|
const ensureAudioUnlocked = useCallback(async (): Promise<boolean> => {
|
||||||
|
const S = sound as unknown as MaybeSound
|
||||||
try {
|
try {
|
||||||
if (typeof (sound as any).ensureUnlocked === 'function') { await (sound as any).ensureUnlocked(); return true }
|
if (typeof S.ensureUnlocked === 'function') { await S.ensureUnlocked(); return true }
|
||||||
if (typeof (sound as any).unlock === 'function') { await (sound as any).unlock(); return true }
|
if (typeof S.unlock === 'function') { await S.unlock(); return true }
|
||||||
const ctx = (sound as any).ctx || (sound as any).audioContext
|
// Zugriff „locker“, um private Felder nicht in Typen zu erzwingen
|
||||||
|
const ctx =
|
||||||
|
(sound as unknown as { ctx?: AudioContext }).ctx ??
|
||||||
|
(sound as unknown as { audioContext?: AudioContext }).audioContext
|
||||||
if (ctx && typeof ctx.resume === 'function' && ctx.state !== 'running') await ctx.resume()
|
if (ctx && typeof ctx.resume === 'function' && ctx.state !== 'running') await ctx.resume()
|
||||||
return true
|
return true
|
||||||
} catch { return false }
|
} catch {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const startBeeps = () => {
|
|
||||||
try { sound.play('ready') } catch {}
|
|
||||||
stopBeeps()
|
|
||||||
beepsRef.current = setInterval(() => { try { sound.play('beep') } catch {} }, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const playMenuAccept = () => {
|
|
||||||
try { (sound as any).play?.('menu_accept') } catch {}
|
|
||||||
try { new Audio('/assets/sounds/menu_accept.wav').play() } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- sofort verbinden helper ---
|
|
||||||
const startConnectingNow = useCallback(() => {
|
const startConnectingNow = useCallback(() => {
|
||||||
if (finished) return
|
if (finished) return
|
||||||
stopBeeps()
|
stopBeeps()
|
||||||
setFinished(true)
|
setFinished(true)
|
||||||
setConnecting(true)
|
setConnecting(true)
|
||||||
try { sound.play('loading') } catch {}
|
try { (sound as unknown as MaybeSound).play?.('loading') } catch {}
|
||||||
|
|
||||||
const doConnect = () => {
|
const doConnect = () => {
|
||||||
try { window.location.href = effectiveConnectHref }
|
try { window.location.href = effectiveConnectHref }
|
||||||
@ -134,7 +178,7 @@ export default function MatchReadyOverlay({
|
|||||||
}
|
}
|
||||||
try { onTimeout?.() } catch {}
|
try { onTimeout?.() } catch {}
|
||||||
}
|
}
|
||||||
setTimeout(doConnect, 200)
|
window.setTimeout(doConnect, 200)
|
||||||
}, [finished, effectiveConnectHref, onTimeout])
|
}, [finished, effectiveConnectHref, onTimeout])
|
||||||
|
|
||||||
// Ready-API nur nach Accept
|
// Ready-API nur nach Accept
|
||||||
@ -142,8 +186,8 @@ export default function MatchReadyOverlay({
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' })
|
const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' })
|
||||||
if (!r.ok) return
|
if (!r.ok) return
|
||||||
const j = await r.json()
|
const j = (await r.json()) as ReadyResponse
|
||||||
const parts: Participant[] = j.participants ?? []
|
const parts: Participant[] = Array.isArray(j.participants) ? j.participants : []
|
||||||
setParticipants(parts)
|
setParticipants(parts)
|
||||||
setReadyMap(j.ready ?? {})
|
setReadyMap(j.ready ?? {})
|
||||||
setTotal(j.total ?? 0)
|
setTotal(j.total ?? 0)
|
||||||
@ -152,7 +196,9 @@ export default function MatchReadyOverlay({
|
|||||||
// Team-Guard füttern
|
// Team-Guard füttern
|
||||||
const ids = parts.map(p => String(p.steamId)).filter(Boolean)
|
const ids = parts.map(p => String(p.steamId)).filter(Boolean)
|
||||||
if (ids.length) setAllowedIds(ids)
|
if (ids.length) setAllowedIds(ids)
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}, [matchId])
|
}, [matchId])
|
||||||
|
|
||||||
// Accept
|
// Accept
|
||||||
@ -178,7 +224,7 @@ export default function MatchReadyOverlay({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// „Es lädt nicht?“ nach 30s
|
// „Es lädt nicht?“ nach 10s
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let id: number | null = null
|
let id: number | null = null
|
||||||
if (connecting) {
|
if (connecting) {
|
||||||
@ -192,26 +238,39 @@ export default function MatchReadyOverlay({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return }
|
if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return }
|
||||||
setShowBackdrop(true)
|
setShowBackdrop(true)
|
||||||
const id = setTimeout(() => setShowContent(true), 300)
|
const id = window.setTimeout(() => setShowContent(true), 300)
|
||||||
return () => clearTimeout(id)
|
return () => window.clearTimeout(id)
|
||||||
}, [shouldRender])
|
}, [shouldRender])
|
||||||
|
|
||||||
// Nach Accept kurzer Refresh
|
// Nach Accept kurzer Refresh
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accepted) return
|
if (!accepted) return
|
||||||
const id = setTimeout(loadReady, 250)
|
const id = window.setTimeout(loadReady, 250)
|
||||||
return () => clearTimeout(id)
|
return () => window.clearTimeout(id)
|
||||||
}, [accepted, loadReady])
|
}, [accepted, loadReady])
|
||||||
|
|
||||||
// SSE
|
// SSE
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
const ev = lastEvent as unknown as SseEventLoose
|
||||||
const payload = (lastEvent as any).payload?.payload ?? (lastEvent as any).payload ?? lastEvent
|
|
||||||
|
const type =
|
||||||
|
typeof ev.type === 'string'
|
||||||
|
? ev.type
|
||||||
|
: typeof ev.payload?.type === 'string'
|
||||||
|
? (ev.payload.type as string)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// payload kann an verschiedenen Stellen liegen
|
||||||
|
const payload =
|
||||||
|
(ev.payload?.payload as LooseObj | undefined) ??
|
||||||
|
(ev.payload as LooseObj | undefined) ??
|
||||||
|
(ev as LooseObj)
|
||||||
|
|
||||||
// participants aus Event übernehmen (falls geschickt)
|
// participants aus Event übernehmen (falls geschickt)
|
||||||
const payloadParticipants: string[] | undefined = Array.isArray(payload?.participants)
|
const rawParticipants = payload?.participants
|
||||||
? payload.participants.map((sid: any) => String(sid)).filter(Boolean)
|
const payloadParticipants: string[] | undefined = Array.isArray(rawParticipants)
|
||||||
|
? (rawParticipants as unknown[]).map(sid => String(sid)).filter(Boolean)
|
||||||
: undefined
|
: undefined
|
||||||
if (payloadParticipants && payloadParticipants.length) {
|
if (payloadParticipants && payloadParticipants.length) {
|
||||||
setAllowedIds(payloadParticipants)
|
setAllowedIds(payloadParticipants)
|
||||||
@ -219,21 +278,26 @@ export default function MatchReadyOverlay({
|
|||||||
|
|
||||||
if (type === 'ready-updated' && payload?.matchId === matchId) {
|
if (type === 'ready-updated' && payload?.matchId === matchId) {
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
const otherSteamId = payload?.steamId as string | undefined
|
const otherSteamId = (payload?.steamId as string | undefined) ?? undefined
|
||||||
if (otherSteamId && otherSteamId !== mySteamId) playMenuAccept()
|
if (otherSteamId && otherSteamId !== mySteamId) playMenuAccept()
|
||||||
}
|
}
|
||||||
loadReady()
|
void loadReady()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'user-status-updated') {
|
if (type === 'user-status-updated') {
|
||||||
const steamId: string | undefined = payload?.steamId ?? payload?.user?.steamId
|
const steamId =
|
||||||
|
(payload?.steamId as string | undefined) ||
|
||||||
|
(payload?.user && typeof (payload.user as LooseObj).steamId === 'string'
|
||||||
|
? String((payload.user as LooseObj).steamId)
|
||||||
|
: undefined)
|
||||||
|
|
||||||
const status = payload?.status as Presence | undefined
|
const status = payload?.status as Presence | undefined
|
||||||
if (steamId && (status === 'online' || status === 'away' || status === 'offline')) {
|
if (steamId && (status === 'online' || status === 'away' || status === 'offline')) {
|
||||||
setStatusMap(prev => (prev[steamId] === status ? prev : { ...prev, [steamId]: status }))
|
setStatusMap(prev => (prev[steamId] === status ? prev : { ...prev, [steamId]: status }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [accepted, lastEvent, matchId, mySteamId, loadReady])
|
}, [accepted, lastEvent, matchId, mySteamId, loadReady, playMenuAccept])
|
||||||
|
|
||||||
// Mount-Animation
|
// Mount-Animation
|
||||||
const [fadeIn, setFadeIn] = useState(false)
|
const [fadeIn, setFadeIn] = useState(false)
|
||||||
@ -269,8 +333,8 @@ export default function MatchReadyOverlay({
|
|||||||
window.addEventListener('keydown', once, { once: true })
|
window.addEventListener('keydown', once, { once: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const id = setTimeout(tryPlay, 0)
|
const id = window.setTimeout(tryPlay, 0)
|
||||||
return () => clearTimeout(id)
|
return () => window.clearTimeout(id)
|
||||||
}, [shouldRender, forceGif, prefersReducedMotion])
|
}, [shouldRender, forceGif, prefersReducedMotion])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -311,7 +375,7 @@ export default function MatchReadyOverlay({
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return () => { cleanup(); stopBeeps() }
|
return () => { cleanup(); stopBeeps() }
|
||||||
}, [showContent])
|
}, [showContent, ensureAudioUnlocked, startBeeps])
|
||||||
|
|
||||||
// Auto-Connect wenn alle bereit
|
// Auto-Connect wenn alle bereit
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -342,7 +406,7 @@ export default function MatchReadyOverlay({
|
|||||||
}
|
}
|
||||||
rafRef.current = requestAnimationFrame(step)
|
rafRef.current = requestAnimationFrame(step)
|
||||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
||||||
}, [shouldRender, effectiveDeadline, accepted, finished, onTimeout, startConnectingNow])
|
}, [shouldRender, effectiveDeadline, accepted, finished, startConnectingNow])
|
||||||
|
|
||||||
// Map-Icon
|
// Map-Icon
|
||||||
const mapIconUrl = useMemo(() => {
|
const mapIconUrl = useMemo(() => {
|
||||||
@ -383,19 +447,21 @@ export default function MatchReadyOverlay({
|
|||||||
>
|
>
|
||||||
{p ? (
|
{p ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<Image
|
||||||
src={p.avatar}
|
src={p.avatar}
|
||||||
alt={p.name}
|
alt={p.name}
|
||||||
|
fill
|
||||||
|
sizes="36px"
|
||||||
className={[
|
className={[
|
||||||
'w-full h-full object-cover rounded-[2px] transition-opacity',
|
'object-cover rounded-[2px] transition-opacity',
|
||||||
isReady ? '' : 'opacity-40 filter grayscale'
|
isReady ? '' : 'opacity-40 grayscale'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
/>
|
/>
|
||||||
{!isReady && <div className="absolute inset-0 bg-black/30 pointer-events-none" />}
|
{!isReady && <div className="absolute inset-0 bg-black/30 pointer-events-none" />}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full grid place-items-center">
|
<div className="w-full h-full grid place-items-center">
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6 opacity-60" fill="currentColor">
|
<svg viewBox="0 0 24 24" className="w-6 h-6 opacity-60" fill="currentColor" aria-hidden>
|
||||||
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-5 0-9 2.5-9 5.5A1.5 1.5 0 0 0 4.5 21h15A1.5 1.5 0 0 0 21 19.5C21 16.5 17 14 12 14Z" />
|
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-5 0-9 2.5-9 5.5A1.5 1.5 0 0 0 4.5 21h15A1.5 1.5 0 0 0 21 19.5C21 16.5 17 14 12 14Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -433,21 +499,26 @@ export default function MatchReadyOverlay({
|
|||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
<img
|
<Image
|
||||||
src={mapBg}
|
src={mapBg}
|
||||||
alt={mapLabel}
|
alt={mapLabel}
|
||||||
className="absolute inset-0 w-full h-full object-cover brightness-90"
|
fill
|
||||||
|
priority
|
||||||
|
sizes="(max-width: 768px) 95vw, 720px"
|
||||||
|
className="absolute inset-0 object-cover brightness-90"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Motion-Layer */}
|
{/* Motion-Layer */}
|
||||||
{useGif ? (
|
{useGif ? (
|
||||||
<div className="absolute inset-0 opacity-50 pointer-events-none">
|
<div className="absolute inset-0 opacity-50 pointer-events-none">
|
||||||
<img
|
<Image
|
||||||
src="/assets/vids/overlay_cs2_accept.webp"
|
src="/assets/vids/overlay_cs2_accept.webp"
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
fill
|
||||||
|
sizes="(max-width: 768px) 95vw, 720px"
|
||||||
|
className="absolute inset-0 object-cover"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="eager"
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
|
||||||
</div>
|
</div>
|
||||||
@ -477,7 +548,7 @@ export default function MatchReadyOverlay({
|
|||||||
|
|
||||||
{/* Icon + Label */}
|
{/* Icon + Label */}
|
||||||
<div className="mt-[10px] flex items-center justify-center text-[#8af784]">
|
<div className="mt-[10px] flex items-center justify-center text-[#8af784]">
|
||||||
<img src={mapIconUrl} alt={`${mapLabel} Icon`} className="w-5 h-5 object-contain" />
|
<Image src={mapIconUrl} alt={`${mapLabel} Icon`} width={20} height={20} className="object-contain" />
|
||||||
<span className="ml-2 text-[15px] [transform:scale(1,0.9)]">{mapLabel}</span>
|
<span className="ml-2 text-[15px] [transform:scale(1,0.9)]">{mapLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
// MiniCard.tsx
|
// /src/app/[locale]/components/MiniCard.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import Image from 'next/image'
|
|
||||||
import PremierRankBadge from './PremierRankBadge'
|
import PremierRankBadge from './PremierRankBadge'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
import UserAvatarWithStatus from './UserAvatarWithStatus'
|
import UserAvatarWithStatus from './UserAvatarWithStatus'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
|
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
|
||||||
|
|
||||||
@ -22,7 +21,8 @@ type MiniCardProps = {
|
|||||||
teamLeaderSteamId?: string | null
|
teamLeaderSteamId?: string | null
|
||||||
location?: string
|
location?: string
|
||||||
rank?: number
|
rank?: number
|
||||||
dragListeners?: any
|
/** optionale Event-/DOM-Attribute (z. B. von dnd-kit: listeners/attributes) */
|
||||||
|
dragListeners?: React.HTMLAttributes<HTMLDivElement>
|
||||||
hoverEffect?: boolean
|
hoverEffect?: boolean
|
||||||
onPromote?: (steamId: string) => void
|
onPromote?: (steamId: string) => void
|
||||||
hideActions?: boolean
|
hideActions?: boolean
|
||||||
@ -42,10 +42,8 @@ export default function MiniCard({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onKick,
|
onKick,
|
||||||
isLeader = false,
|
isLeader = false,
|
||||||
draggable,
|
|
||||||
currentUserSteamId,
|
currentUserSteamId,
|
||||||
teamLeaderSteamId,
|
teamLeaderSteamId,
|
||||||
location,
|
|
||||||
rank,
|
rank,
|
||||||
dragListeners,
|
dragListeners,
|
||||||
hoverEffect = false,
|
hoverEffect = false,
|
||||||
@ -56,24 +54,25 @@ export default function MiniCard({
|
|||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
invitedStatus,
|
invitedStatus,
|
||||||
isInvite = false,
|
isInvite = false,
|
||||||
invitationId
|
|
||||||
}: MiniCardProps) {
|
}: MiniCardProps) {
|
||||||
//const isSelectable = typeof onSelect === 'function'
|
const canEdit =
|
||||||
const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
|
(isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
|
||||||
|
|
||||||
const statusBg =
|
const statusBg =
|
||||||
invitedStatus === 'sent' ? 'bg-green-500 dark:bg-green-700' :
|
invitedStatus === 'sent'
|
||||||
invitedStatus === 'added' ? 'bg-teal-500 dark:bg-teal-700' :
|
? 'bg-green-500 dark:bg-green-700'
|
||||||
invitedStatus === 'failed' ? 'bg-red-500 dark:bg-red-700' :
|
: invitedStatus === 'added'
|
||||||
invitedStatus === 'pending' ? 'bg-yellow-500 dark:bg-yellow-700':
|
? 'bg-teal-500 dark:bg-teal-700'
|
||||||
'bg-white dark:bg-neutral-800'
|
: invitedStatus === 'failed'
|
||||||
|
? 'bg-red-500 dark:bg-red-700'
|
||||||
|
: invitedStatus === 'pending'
|
||||||
|
? 'bg-yellow-500 dark:bg-yellow-700'
|
||||||
|
: 'bg-white dark:bg-neutral-800'
|
||||||
|
|
||||||
// Rand unabhängig vom Status (nur bei Auswahl Blau; sonst neutral oder transparent)
|
const baseBorder = selected
|
||||||
const baseBorder =
|
|
||||||
selected
|
|
||||||
? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400'
|
? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400'
|
||||||
: invitedStatus
|
: invitedStatus
|
||||||
? 'border-transparent' // kein grüner/roter Rand, Fokus liegt auf dem BG
|
? 'border-transparent'
|
||||||
: 'border-gray-200 dark:border-neutral-700'
|
: 'border-gray-200 dark:border-neutral-700'
|
||||||
|
|
||||||
const cardClasses = `
|
const cardClasses = `
|
||||||
@ -112,26 +111,47 @@ export default function MiniCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
invitedStatus === 'sent' ? 'Eingeladen' :
|
invitedStatus === 'sent'
|
||||||
invitedStatus === 'added' ? 'Hinzugefügt' :
|
? 'Eingeladen'
|
||||||
invitedStatus === 'failed'? 'Fehlgeschlagen' :
|
: invitedStatus === 'added'
|
||||||
invitedStatus === 'pending'? 'Wird gesendet…' : null
|
? 'Hinzugefügt'
|
||||||
|
: invitedStatus === 'failed'
|
||||||
|
? 'Fehlgeschlagen'
|
||||||
|
: invitedStatus === 'pending'
|
||||||
|
? 'Wird gesendet…'
|
||||||
|
: null
|
||||||
|
|
||||||
const statusPillClasses =
|
const statusPillClasses =
|
||||||
invitedStatus === 'sent' ? 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-200' :
|
invitedStatus === 'sent'
|
||||||
invitedStatus === 'added' ? 'bg-teal-100 text-teal-600 dark:bg-teal-900/40 dark:text-teal-300' :
|
? 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-200'
|
||||||
invitedStatus === 'failed' ? 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-300' :
|
: invitedStatus === 'added'
|
||||||
invitedStatus === 'pending' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': '';
|
? 'bg-teal-100 text-teal-600 dark:bg-teal-900/40 dark:text-teal-300'
|
||||||
|
: invitedStatus === 'failed'
|
||||||
|
? 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-300'
|
||||||
|
: invitedStatus === 'pending'
|
||||||
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300'
|
||||||
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
|
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
|
||||||
{canEdit && !hideActions && !hideOverlay && (
|
{canEdit && !hideActions && !hideOverlay && (
|
||||||
<div className={`absolute inset-0 bg-white dark:bg-black bg-opacity-80 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${
|
<div
|
||||||
|
className={`absolute inset-0 bg-white dark:bg-black bg-opacity-80 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${
|
||||||
hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'
|
hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'
|
||||||
}`}>
|
}`}
|
||||||
<span className="text-gray-800 dark:text-neutral-200 font-semibold text-sm mb-1 truncate px-2 max-w-[90%] text-center">{title}</span>
|
>
|
||||||
|
<span className="text-gray-800 dark:text-neutral-200 font-semibold text-sm mb-1 truncate px-2 max-w-[90%] text-center">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
<div className="pointer-events-auto" onPointerDown={stopDrag}>
|
<div className="pointer-events-auto" onPointerDown={stopDrag}>
|
||||||
<Button className="max-w-[120px]" title={isInvite ? 'Zurückziehen' : 'Kicken'} color="red" variant="solid" size="sm" onClick={isInvite ? handleRevokeClick : handleKickClick} />
|
<Button
|
||||||
|
className="max-w-[120px]"
|
||||||
|
title={isInvite ? 'Zurückziehen' : 'Kicken'}
|
||||||
|
color="red"
|
||||||
|
variant="solid"
|
||||||
|
size="sm"
|
||||||
|
onClick={isInvite ? handleRevokeClick : handleKickClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{typeof onPromote === 'function' && (
|
{typeof onPromote === 'function' && (
|
||||||
<div className="pointer-events-auto" onPointerDown={stopDrag}>
|
<div className="pointer-events-auto" onPointerDown={stopDrag}>
|
||||||
@ -166,14 +186,6 @@ export default function MiniCard({
|
|||||||
) : (
|
) : (
|
||||||
<PremierRankBadge rank={rank ?? 0} />
|
<PremierRankBadge rank={rank ?? 0} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ /*
|
|
||||||
{location ? (
|
|
||||||
<span className={`fi fi-${location.toLowerCase()} text-xl mt-1`} title={location} />
|
|
||||||
) : (
|
|
||||||
<span className="text-xl mt-1" title="Weltweit">🌐</span>
|
|
||||||
)}
|
|
||||||
*/ }
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useDroppable } from "@dnd-kit/core"
|
import { useDroppable } from "@dnd-kit/core"
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
type MiniCardDummyProps = {
|
type MiniCardDummyProps = {
|
||||||
title: string
|
title: string
|
||||||
@ -11,7 +12,7 @@ type MiniCardDummyProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MiniCardDummy({ title, onClick, children, zoneId }: MiniCardDummyProps) {
|
export default function MiniCardDummy({ title, onClick, children, zoneId }: MiniCardDummyProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef } = useDroppable({
|
||||||
id: `${zoneId ?? 'dummy'}-drop`,
|
id: `${zoneId ?? 'dummy'}-drop`,
|
||||||
data: { containerId: zoneId, role: 'dummy' }, // ⬅️ wichtig für Zone-Highlight
|
data: { containerId: zoneId, role: 'dummy' }, // ⬅️ wichtig für Zone-Highlight
|
||||||
})
|
})
|
||||||
@ -32,7 +33,7 @@ export default function MiniCardDummy({ title, onClick, children, zoneId }: Mini
|
|||||||
{children ? (
|
{children ? (
|
||||||
children
|
children
|
||||||
) : (
|
) : (
|
||||||
<img
|
<Image
|
||||||
src="https://via.placeholder.com/64x64.png?text=+"
|
src="https://via.placeholder.com/64x64.png?text=+"
|
||||||
alt="Dummy Avatar"
|
alt="Dummy Avatar"
|
||||||
className="w-16 h-16 object-cover"
|
className="w-16 h-16 object-cover"
|
||||||
|
|||||||
@ -2,10 +2,18 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useRouter } from '@/i18n/navigation'
|
import { useRouter } from '@/i18n/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
import type { MatchPlayer } from '../../../types/match'
|
import type { MatchPlayer } from '../../../types/match'
|
||||||
import PremierRankBadge from './PremierRankBadge'
|
import PremierRankBadge from './PremierRankBadge'
|
||||||
import FaceitLevelImage from './FaceitLevelBadge'
|
import FaceitLevelImage from './FaceitLevelBadge'
|
||||||
@ -13,25 +21,42 @@ import FaceitLevelImage from './FaceitLevelBadge'
|
|||||||
export type MiniPlayerCardProps = {
|
export type MiniPlayerCardProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
player: MatchPlayer
|
player: MatchPlayer
|
||||||
anchor: DOMRect | null
|
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
prefetchedSummary?: PlayerSummary | null
|
prefetchedSummary?: PlayerSummary | null
|
||||||
prefetchedFaceit?: { level: number|null; elo: number|null; nickname: string|null; url: string|null } | null
|
prefetchedFaceit?: {
|
||||||
|
level: number | null
|
||||||
|
elo: number | null
|
||||||
|
nickname: string | null
|
||||||
|
url: string | null
|
||||||
|
} | null
|
||||||
anchorEl?: HTMLElement | null
|
anchorEl?: HTMLElement | null
|
||||||
onCardMount?: (el: HTMLDivElement | null) => void
|
onCardMount?: (el: HTMLDivElement | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BanStatus = {
|
||||||
|
vacBanned?: boolean | null
|
||||||
|
numberOfVACBans?: number | null
|
||||||
|
numberOfGameBans?: number | null
|
||||||
|
communityBanned?: boolean | null
|
||||||
|
economyBan?: string | null
|
||||||
|
daysSinceLastBan?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
type UserWithFaceit = {
|
type UserWithFaceit = {
|
||||||
steamId?: string | null
|
steamId?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
avatar?: string | null
|
avatar?: string | null
|
||||||
premierRank?: number | null
|
premierRank?: number | null
|
||||||
|
// flache Ban-Felder (manche APIs liefern das so)
|
||||||
vacBanned?: boolean | null
|
vacBanned?: boolean | null
|
||||||
numberOfVACBans?: number | null
|
numberOfVACBans?: number | null
|
||||||
numberOfGameBans?: number | null
|
numberOfGameBans?: number | null
|
||||||
communityBanned?: boolean | null
|
communityBanned?: boolean | null
|
||||||
economyBan?: string | null
|
economyBan?: string | null
|
||||||
daysSinceLastBan?: number | null
|
daysSinceLastBan?: number | null
|
||||||
|
// oder geschachtelt:
|
||||||
|
banStatus?: BanStatus
|
||||||
|
// Faceit
|
||||||
faceitNickname?: string | null
|
faceitNickname?: string | null
|
||||||
faceitUrl?: string | null
|
faceitUrl?: string | null
|
||||||
faceitLevel?: number | null
|
faceitLevel?: number | null
|
||||||
@ -56,10 +81,17 @@ export type PlayerSummary = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Sparkline({ values }: { values: number[] }) {
|
function Sparkline({ values }: { values: number[] }) {
|
||||||
const W = 200, H = 40, pad = 6, n = Math.max(1, values.length)
|
const W = 200,
|
||||||
const max = Math.max(...values, 1), min = Math.min(...values, 0), range = Math.max(0.05, max - min)
|
H = 40,
|
||||||
|
pad = 6,
|
||||||
|
n = Math.max(1, values.length)
|
||||||
|
const max = Math.max(...values, 1),
|
||||||
|
min = Math.min(...values, 0),
|
||||||
|
range = Math.max(0.05, max - min)
|
||||||
const step = (W - pad * 2) / Math.max(1, n - 1)
|
const step = (W - pad * 2) / Math.max(1, n - 1)
|
||||||
const pts = values.map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`).join(' ')
|
const pts = values
|
||||||
|
.map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`)
|
||||||
|
.join(' ')
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-[200px] h-[40px] text-blue-300/90">
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-[200px] h-[40px] text-blue-300/90">
|
||||||
<polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.95" strokeWidth="2" />
|
<polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.95" strokeWidth="2" />
|
||||||
@ -68,12 +100,22 @@ function Sparkline({ values }: { values: number[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MiniPlayerCard({
|
export default function MiniPlayerCard({
|
||||||
open, player, anchor, onClose, prefetchedSummary, prefetchedFaceit, anchorEl, onCardMount
|
open,
|
||||||
|
player,
|
||||||
|
onClose,
|
||||||
|
prefetchedSummary,
|
||||||
|
prefetchedFaceit,
|
||||||
|
anchorEl,
|
||||||
|
onCardMount,
|
||||||
}: MiniPlayerCardProps) {
|
}: MiniPlayerCardProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({ top: 0, left: 0, side: 'right' })
|
const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
side: 'right',
|
||||||
|
})
|
||||||
const [measured, setMeasured] = useState(false)
|
const [measured, setMeasured] = useState(false)
|
||||||
|
|
||||||
// Hover-Intent
|
// Hover-Intent
|
||||||
@ -88,14 +130,19 @@ export default function MiniPlayerCard({
|
|||||||
|
|
||||||
// Summary nur aus Prefetch (kein Fetch)
|
// Summary nur aus Prefetch (kein Fetch)
|
||||||
const [summary, setSummary] = useState<PlayerSummary | null>(prefetchedSummary ?? null)
|
const [summary, setSummary] = useState<PlayerSummary | null>(prefetchedSummary ?? null)
|
||||||
useEffect(() => { setSummary(prefetchedSummary ?? null) }, [prefetchedSummary, player.user?.steamId])
|
useEffect(() => {
|
||||||
|
setSummary(prefetchedSummary ?? null)
|
||||||
|
}, [prefetchedSummary, player.user?.steamId])
|
||||||
|
|
||||||
// FACEIT aus Prefetch / user-Fallback
|
// FACEIT aus Prefetch / user-Fallback
|
||||||
const faceit = useMemo<FaceitState>(() => {
|
const faceit = useMemo<FaceitState>(() => {
|
||||||
const url =
|
const url =
|
||||||
prefetchedFaceit?.url
|
prefetchedFaceit?.url ??
|
||||||
?? (u.faceitUrl ? u.faceitUrl.replace('{lang}', 'en')
|
(u.faceitUrl
|
||||||
: (u.faceitNickname ? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}` : null))
|
? u.faceitUrl.replace('{lang}', 'en')
|
||||||
|
: u.faceitNickname
|
||||||
|
? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}`
|
||||||
|
: null)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
level: prefetchedFaceit?.level ?? u.faceitLevel ?? null,
|
level: prefetchedFaceit?.level ?? u.faceitLevel ?? null,
|
||||||
@ -111,11 +158,12 @@ export default function MiniPlayerCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Positionierung
|
// Positionierung
|
||||||
const doPosition = () => {
|
const doPosition = useCallback(() => {
|
||||||
if (!cardRef.current || !anchorEl) return
|
if (!cardRef.current || !anchorEl) return
|
||||||
const a = anchorEl.getBoundingClientRect()
|
const a = anchorEl.getBoundingClientRect()
|
||||||
const cardEl = cardRef.current
|
const cardEl = cardRef.current
|
||||||
const vw = window.innerWidth, vh = window.innerHeight
|
const vw = window.innerWidth,
|
||||||
|
vh = window.innerHeight
|
||||||
const { width: cw, height: ch } = cardEl.getBoundingClientRect()
|
const { width: cw, height: ch } = cardEl.getBoundingClientRect()
|
||||||
|
|
||||||
const rightLeft = a.right
|
const rightLeft = a.right
|
||||||
@ -130,13 +178,18 @@ export default function MiniPlayerCard({
|
|||||||
|
|
||||||
setPos({ top: Math.round(top), left: Math.round(left), side })
|
setPos({ top: Math.round(top), left: Math.round(left), side })
|
||||||
setMeasured(true)
|
setMeasured(true)
|
||||||
}
|
}, [anchorEl])
|
||||||
|
|
||||||
const schedule = () => requestAnimationFrame(() => requestAnimationFrame(doPosition))
|
const schedule = useCallback(() => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(doPosition))
|
||||||
|
}, [doPosition])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (open) { setMeasured(false); schedule() }
|
if (open) {
|
||||||
}, [open, anchorEl])
|
setMeasured(false)
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
}, [open, anchorEl, schedule])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@ -155,7 +208,7 @@ export default function MiniPlayerCard({
|
|||||||
window.removeEventListener('resize', onScrollOrResize)
|
window.removeEventListener('resize', onScrollOrResize)
|
||||||
ro?.disconnect()
|
ro?.disconnect()
|
||||||
}
|
}
|
||||||
}, [open, anchorEl])
|
}, [open, anchorEl, schedule])
|
||||||
|
|
||||||
// Bei Spielerwechsel sanft neu einmessen
|
// Bei Spielerwechsel sanft neu einmessen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -166,7 +219,7 @@ export default function MiniPlayerCard({
|
|||||||
}
|
}
|
||||||
setMeasured(false)
|
setMeasured(false)
|
||||||
schedule()
|
schedule()
|
||||||
}, [open, anchorEl, player.user?.steamId])
|
}, [open, anchorEl, player.user?.steamId, schedule])
|
||||||
|
|
||||||
// Anchor-Hover steuert Open/Close
|
// Anchor-Hover steuert Open/Close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -174,21 +227,32 @@ export default function MiniPlayerCard({
|
|||||||
|
|
||||||
const armClose = () => {
|
const armClose = () => {
|
||||||
if (!closeT.current) {
|
if (!closeT.current) {
|
||||||
closeT.current = window.setTimeout(() => { closeT.current = null; onClose?.() }, CLOSE_DELAY)
|
closeT.current = window.setTimeout(() => {
|
||||||
|
closeT.current = null
|
||||||
|
onClose?.()
|
||||||
|
}, CLOSE_DELAY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const disarmClose = () => {
|
const disarmClose = () => {
|
||||||
if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null }
|
if (closeT.current) {
|
||||||
|
window.clearTimeout(closeT.current)
|
||||||
|
closeT.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEnter = () => {
|
const onEnter = () => {
|
||||||
disarmClose()
|
disarmClose()
|
||||||
if (!open && !openT.current) {
|
if (!open && !openT.current) {
|
||||||
openT.current = window.setTimeout(() => { openT.current = null }, OPEN_DELAY)
|
openT.current = window.setTimeout(() => {
|
||||||
|
openT.current = null
|
||||||
|
}, OPEN_DELAY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onLeave = () => {
|
const onLeave = () => {
|
||||||
if (openT.current) { window.clearTimeout(openT.current); openT.current = null }
|
if (openT.current) {
|
||||||
|
window.clearTimeout(openT.current)
|
||||||
|
openT.current = null
|
||||||
|
}
|
||||||
armClose()
|
armClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,24 +269,31 @@ export default function MiniPlayerCard({
|
|||||||
}
|
}
|
||||||
}, [anchorEl, open, onClose])
|
}, [anchorEl, open, onClose])
|
||||||
|
|
||||||
// BAN-Badges
|
// BAN-Badges (verschachtelt oder flach)
|
||||||
const nestedBan = (player.user as any)?.banStatus
|
const nestedBan: BanStatus | undefined =
|
||||||
|
(player.user as { banStatus?: BanStatus } | undefined)?.banStatus
|
||||||
const flat = u
|
const flat = u
|
||||||
const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0)
|
const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0)
|
||||||
const isBannedNested =
|
const isBannedNested = !!(
|
||||||
!!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 ||
|
nestedBan?.vacBanned ||
|
||||||
(nestedBan?.numberOfGameBans ?? 0) > 0 || nestedBan?.communityBanned ||
|
(nestedBan?.numberOfVACBans ?? 0) > 0 ||
|
||||||
(nestedBan?.economyBan && nestedBan.economyBan !== 'none'))
|
(nestedBan?.numberOfGameBans ?? 0) > 0 ||
|
||||||
|
nestedBan?.communityBanned ||
|
||||||
|
(nestedBan?.economyBan && nestedBan.economyBan !== 'none')
|
||||||
|
)
|
||||||
const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0
|
const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0
|
||||||
const isBannedFlat =
|
const isBannedFlat =
|
||||||
!!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 || (flat.numberOfGameBans ?? 0) > 0 ||
|
!!flat.vacBanned ||
|
||||||
!!flat.communityBanned || (!!flat.economyBan && flat.economyBan !== 'none')
|
(flat.numberOfVACBans ?? 0) > 0 ||
|
||||||
|
(flat.numberOfGameBans ?? 0) > 0 ||
|
||||||
|
!!flat.communityBanned ||
|
||||||
|
(!!flat.economyBan && flat.economyBan !== 'none')
|
||||||
const hasVac = nestedBan ? hasVacNested : hasVacFlat
|
const hasVac = nestedBan ? hasVacNested : hasVacFlat
|
||||||
const isBanned = nestedBan ? isBannedNested : isBannedFlat
|
const isBanned = nestedBan ? isBannedNested : isBannedFlat
|
||||||
|
|
||||||
const banTooltip = useMemo(() => {
|
const banTooltip = useMemo(() => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
const src = nestedBan ?? flat
|
const src = (nestedBan ?? flat) as Required<BanStatus>
|
||||||
if (src.vacBanned) parts.push('VAC-Ban aktiv')
|
if (src.vacBanned) parts.push('VAC-Ban aktiv')
|
||||||
if ((src.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${src.numberOfVACBans}`)
|
if ((src.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${src.numberOfVACBans}`)
|
||||||
if ((src.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${src.numberOfGameBans}`)
|
if ((src.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${src.numberOfGameBans}`)
|
||||||
@ -244,10 +315,18 @@ export default function MiniPlayerCard({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="pointer-events-auto fixed z-[10000] w-[320px] rounded-lg border border-white/10 bg-neutral-900/95 p-3 text-white shadow-2xl backdrop-blur transition-opacity duration-100"
|
className="pointer-events-auto fixed z-[10000] w-[320px] rounded-lg border border-white/10 bg-neutral-900/95 p-3 text-white shadow-2xl backdrop-blur transition-opacity duration-100"
|
||||||
style={{ top: pos.top, left: pos.left, opacity: measured ? 1 : 0 }}
|
style={{ top: pos.top, left: pos.left, opacity: measured ? 1 : 0 }}
|
||||||
onMouseEnter={() => { if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null } }}
|
onMouseEnter={() => {
|
||||||
|
if (closeT.current) {
|
||||||
|
window.clearTimeout(closeT.current)
|
||||||
|
closeT.current = null
|
||||||
|
}
|
||||||
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (!closeT.current) {
|
if (!closeT.current) {
|
||||||
closeT.current = window.setTimeout(() => { closeT.current = null; onClose?.() }, CLOSE_DELAY)
|
closeT.current = window.setTimeout(() => {
|
||||||
|
closeT.current = null
|
||||||
|
onClose?.()
|
||||||
|
}, CLOSE_DELAY)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -263,7 +342,9 @@ export default function MiniPlayerCard({
|
|||||||
|
|
||||||
{/* Header mit Links rechts */}
|
{/* Header mit Links rechts */}
|
||||||
<div
|
<div
|
||||||
onClick={() => { steam64 ? router.push(`/profile/${steam64}`) : null }}
|
onClick={() => {
|
||||||
|
if (steam64) router.push(`/profile/${steam64}`)
|
||||||
|
}}
|
||||||
className="flex cursor-pointer transition bg-white dark:bg-neutral-800 dark:border-neutral-700 hover:bg-neutral-200 hover:dark:bg-neutral-700 items-center justify-between mb-2 rounded-md bg-white/5 ring-1 ring-white/10 px-2 py-2"
|
className="flex cursor-pointer transition bg-white dark:bg-neutral-800 dark:border-neutral-700 hover:bg-neutral-200 hover:dark:bg-neutral-700 items-center justify-between mb-2 rounded-md bg-white/5 ring-1 ring-white/10 px-2 py-2"
|
||||||
>
|
>
|
||||||
{/* Links: Avatar + Name + Badges */}
|
{/* Links: Avatar + Name + Badges */}
|
||||||
@ -276,11 +357,9 @@ export default function MiniPlayerCard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{/* Name + BAN/VAC direkt daneben (wie MatchDetails) */}
|
{/* Name + BAN/VAC direkt daneben */}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="truncate text-sm font-semibold">
|
<span className="truncate text-sm font-semibold">{u.name ?? 'Unbekannt'}</span>
|
||||||
{u.name ?? 'Unbekannt'}
|
|
||||||
</span>
|
|
||||||
{isBanned && (
|
{isBanned && (
|
||||||
<span
|
<span
|
||||||
title={banTooltip}
|
title={banTooltip}
|
||||||
@ -292,7 +371,7 @@ export default function MiniPlayerCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* darunter: Premier + Faceit (unverändert) */}
|
{/* darunter: Premier + Faceit */}
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<PremierRankBadge rank={u.premierRank ?? 0} />
|
<PremierRankBadge rank={u.premierRank ?? 0} />
|
||||||
{faceit.nickname && <FaceitLevelImage elo={faceit.elo ?? 0} className="-ml-0.5" />}
|
{faceit.nickname && <FaceitLevelImage elo={faceit.elo ?? 0} className="-ml-0.5" />}
|
||||||
@ -323,7 +402,7 @@ export default function MiniPlayerCard({
|
|||||||
title={`Faceit-Profil${faceit.nickname ? ` von ${faceit.nickname}` : ''}`}
|
title={`Faceit-Profil${faceit.nickname ? ` von ${faceit.nickname}` : ''}`}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
|
className="inline-flex size-8 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<img src="/assets/img/logos/faceit.svg" alt="" className="h-4 w-4" aria-hidden />
|
<Image src="/assets/img/logos/faceit.svg" alt="" width={16} height={16} />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -346,10 +425,16 @@ export default function MiniPlayerCard({
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'text-[11px] font-medium',
|
'text-[11px] font-medium',
|
||||||
summary.perfDelta > 0 ? 'text-emerald-300' : summary.perfDelta < 0 ? 'text-rose-300' : 'text-neutral-300',
|
summary.perfDelta > 0
|
||||||
|
? 'text-emerald-300'
|
||||||
|
: summary.perfDelta < 0
|
||||||
|
? 'text-rose-300'
|
||||||
|
: 'text-neutral-300',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{summary.perfDelta === 0 ? '±0.00' : `${summary.perfDelta > 0 ? '+' : ''}${summary.perfDelta.toFixed(2)}`}
|
{summary.perfDelta === 0
|
||||||
|
? '±0.00'
|
||||||
|
: `${summary.perfDelta > 0 ? '+' : ''}${summary.perfDelta.toFixed(2)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// /src/app/components/Modal.tsx
|
// /src/app/[locale]/components/Modal.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
@ -29,6 +29,19 @@ type ModalProps = {
|
|||||||
scrollBody?: boolean
|
scrollBody?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────── HSOverlay-Hilfstypen (nur lokal verwendet) ───────── */
|
||||||
|
type HSOverlayInstance = {
|
||||||
|
element: HTMLElement
|
||||||
|
destroy?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type HSOverlayAPI = {
|
||||||
|
collection?: HSOverlayInstance[]
|
||||||
|
autoInit?: () => void
|
||||||
|
open?: (el: HTMLElement) => void
|
||||||
|
close?: (el: HTMLElement) => void
|
||||||
|
}
|
||||||
|
|
||||||
export default function Modal({
|
export default function Modal({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
@ -44,11 +57,16 @@ export default function Modal({
|
|||||||
scrollBody = true,
|
scrollBody = true,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const modalEl = document.getElementById(id)
|
const modalEl = document.getElementById(id) as HTMLElement | null
|
||||||
const hs = (window as any).HSOverlay
|
|
||||||
|
// Kollisionsfrei zum globalen Typ: wir lesen Window.HSOverlay und erweitern ihn lokal
|
||||||
|
type HSOverlayBase = { open?: (el: HTMLElement) => void; close?: (el: HTMLElement) => void }
|
||||||
|
const hs =
|
||||||
|
(window as unknown as { HSOverlay?: HSOverlayBase & HSOverlayAPI }).HSOverlay
|
||||||
|
|
||||||
if (!modalEl || !hs) return
|
if (!modalEl || !hs) return
|
||||||
|
|
||||||
const getCollection = (): any[] =>
|
const getCollection = (): HSOverlayInstance[] =>
|
||||||
Array.isArray(hs.collection) ? hs.collection : []
|
Array.isArray(hs.collection) ? hs.collection : []
|
||||||
|
|
||||||
const destroyIfExists = () => {
|
const destroyIfExists = () => {
|
||||||
@ -60,7 +78,7 @@ export default function Modal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => onClose?.()
|
const handleClose = () => onClose?.()
|
||||||
modalEl.addEventListener('hsOverlay:close', handleClose)
|
modalEl.addEventListener('hsOverlay:close', handleClose as EventListener)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (show) {
|
if (show) {
|
||||||
@ -71,14 +89,16 @@ export default function Modal({
|
|||||||
hs.close?.(modalEl)
|
hs.close?.(modalEl)
|
||||||
destroyIfExists()
|
destroyIfExists()
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
modalEl.removeEventListener('hsOverlay:close', handleClose)
|
modalEl.removeEventListener('hsOverlay:close', handleClose as EventListener)
|
||||||
destroyIfExists()
|
destroyIfExists()
|
||||||
// Fallback: Globale Backdrops wegräumen, falls die Lib zickt
|
// Fallback: Globale Backdrops wegräumen, falls die Lib zickt
|
||||||
document.querySelectorAll('.hs-overlay-backdrop')?.forEach(el => el.remove())
|
document.querySelectorAll('.hs-overlay-backdrop')?.forEach((el) => el.remove())
|
||||||
document.body.classList.remove('overflow-hidden','[&.hs-overlay-open]') // je nach Lib-Version
|
document.body.classList.remove('overflow-hidden', '[&.hs-overlay-open]')
|
||||||
}
|
}
|
||||||
}, [show, id, onClose])
|
}, [show, id, onClose])
|
||||||
|
|
||||||
@ -93,8 +113,8 @@ export default function Modal({
|
|||||||
if (e.target === e.currentTarget) onClose?.()
|
if (e.target === e.currentTarget) onClose?.()
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
"hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden " +
|
'hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden ' +
|
||||||
(show ? "" : "pointer-events-none")
|
(show ? '' : 'pointer-events-none')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
@ -140,7 +160,7 @@ export default function Modal({
|
|||||||
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
|
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
|
||||||
{!hideCloseButton && (
|
{!hideCloseButton && (
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size="sm"
|
||||||
data-hs-overlay={`#${id}`}
|
data-hs-overlay={`#${id}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="py-2 px-3 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-700"
|
className="py-2 px-3 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-700"
|
||||||
@ -151,7 +171,7 @@ export default function Modal({
|
|||||||
|
|
||||||
{onSave && (
|
{onSave && (
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size="sm"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={disableSave}
|
disabled={disableSave}
|
||||||
className={`py-2 px-3 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 hover:bg-${closeButtonColor}-700 focus:bg-${closeButtonColor}-700 text-white`}
|
className={`py-2 px-3 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 hover:bg-${closeButtonColor}-700 focus:bg-${closeButtonColor}-700 text-white`}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ type Props = {
|
|||||||
initialInvitationMap: Record<string, string>
|
initialInvitationMap: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SsePayloadLoose = Record<string, unknown>;
|
||||||
|
|
||||||
/* helpers */
|
/* helpers */
|
||||||
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
|
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
|
||||||
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
||||||
@ -21,17 +23,27 @@ const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
|||||||
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
|
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sichere Helpers, um den "leader" zu vergleichen, egal ob string oder Player-Objekt
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object'
|
||||||
|
const leaderKeyOf = (leader: unknown): string => {
|
||||||
|
if (typeof leader === 'string') return leader
|
||||||
|
if (isRecord(leader) && typeof leader.steamId === 'string') return leader.steamId
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const eqTeam = (a: Team, b: Team) => {
|
const eqTeam = (a: Team, b: Team) => {
|
||||||
if (a.id !== b.id) return false
|
if (a.id !== b.id) return false
|
||||||
if ((a.name ?? '') !== (b.name ?? '')) return false
|
if ((a.name ?? '') !== (b.name ?? '')) return false
|
||||||
if ((a.logo ?? '') !== (b.logo ?? '')) return false
|
if ((a.logo ?? '') !== (b.logo ?? '')) return false
|
||||||
if ((a.leader as any) !== (b.leader as any)) return false
|
if (leaderKeyOf((a as unknown as { leader?: unknown }).leader) !== leaderKeyOf((b as unknown as { leader?: unknown }).leader)) return false
|
||||||
if (a.joinPolicy !== b.joinPolicy) return false
|
if (a.joinPolicy !== b.joinPolicy) return false
|
||||||
return (
|
return (
|
||||||
eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
|
eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
|
||||||
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
|
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const eqTeamList = (a: Team[], b: Team[]) => {
|
const eqTeamList = (a: Team[], b: Team[]) => {
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false
|
||||||
const mapA = new Map(a.map(t => [t.id, t]))
|
const mapA = new Map(a.map(t => [t.id, t]))
|
||||||
@ -41,9 +53,10 @@ const eqTeamList = (a: Team[], b: Team[]) => {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
function parseTeamsResponse(raw: any): Team[] {
|
|
||||||
|
function parseTeamsResponse(raw: unknown): Team[] {
|
||||||
if (Array.isArray(raw)) return raw as Team[]
|
if (Array.isArray(raw)) return raw as Team[]
|
||||||
if (raw && Array.isArray(raw.teams)) return raw.teams as Team[]
|
if (isRecord(raw) && Array.isArray(raw.teams)) return raw.teams as Team[]
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,13 +77,20 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
fetch('/api/user/invitations', { cache: 'no-store' }),
|
fetch('/api/user/invitations', { cache: 'no-store' }),
|
||||||
])
|
])
|
||||||
if (!teamRes.ok || !invitesRes.ok) return
|
if (!teamRes.ok || !invitesRes.ok) return
|
||||||
const rawTeams = await teamRes.json()
|
const rawTeams: unknown = await teamRes.json()
|
||||||
const rawInv = await invitesRes.json()
|
const rawInv: unknown = await invitesRes.json()
|
||||||
const nextTeams: Team[] = parseTeamsResponse(rawTeams)
|
const nextTeams: Team[] = parseTeamsResponse(rawTeams)
|
||||||
|
|
||||||
const mapping: Record<string, string> = {}
|
const mapping: Record<string, string> = {}
|
||||||
for (const inv of rawInv?.invitations || []) {
|
if (isRecord(rawInv) && Array.isArray(rawInv.invitations)) {
|
||||||
if (inv.type === 'team-join-request') mapping[inv.teamId] = inv.id
|
for (const inv of rawInv.invitations as Array<Record<string, unknown>>) {
|
||||||
|
const type = inv.type
|
||||||
|
const teamId = inv.teamId
|
||||||
|
const id = inv.id
|
||||||
|
if (type === 'team-join-request' && typeof teamId === 'string' && typeof id === 'string') {
|
||||||
|
mapping[teamId] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams))
|
setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams))
|
||||||
@ -92,7 +112,8 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Nur nachladen, falls keine Initialdaten übergeben wurden
|
// Nur nachladen, falls keine Initialdaten übergeben wurden
|
||||||
if (!initialTeams?.length) fetchTeamsAndInvitations()
|
if (!initialTeams?.length) fetchTeamsAndInvitations()
|
||||||
}, [])
|
// Warnung beheben: explizit auf die Länge hören
|
||||||
|
}, [initialTeams?.length])
|
||||||
|
|
||||||
const teamsRef = useRef(teams)
|
const teamsRef = useRef(teams)
|
||||||
useEffect(() => { teamsRef.current = teams }, [teams])
|
useEffect(() => { teamsRef.current = teams }, [teams])
|
||||||
@ -103,19 +124,25 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
|
|
||||||
// Signatur: Typ + die paar Payload-Felder, die sich bei uns ändern
|
// Signatur: Typ + die paar Payload-Felder, die sich bei uns ändern
|
||||||
|
const p = (lastEvent.payload ?? {}) as SsePayloadLoose;
|
||||||
|
|
||||||
const sig = JSON.stringify({
|
const sig = JSON.stringify({
|
||||||
t: lastEvent.type,
|
t: lastEvent.type,
|
||||||
tid: lastEvent.payload?.teamId ?? null,
|
tid: typeof p.teamId === 'string' ? p.teamId : null,
|
||||||
jp: lastEvent.payload?.joinPolicy ?? null,
|
jp: typeof p.joinPolicy === 'string' ? p.joinPolicy : null,
|
||||||
f: lastEvent.payload?.filename ?? null,
|
f: typeof p.filename === 'string' ? p.filename : null,
|
||||||
v: lastEvent.payload?.version ?? null,
|
v: typeof p.version === 'string' ? p.version : null,
|
||||||
})
|
});
|
||||||
if (lastSigRef.current === sig) return
|
if (lastSigRef.current === sig) return
|
||||||
lastSigRef.current = sig
|
lastSigRef.current = sig
|
||||||
|
|
||||||
const { type, payload } = lastEvent
|
const { type } = lastEvent
|
||||||
|
// payload hier erneut „locker“ casten
|
||||||
|
const payload = (lastEvent.payload ?? {}) as SsePayloadLoose
|
||||||
|
const teamId = typeof payload.teamId === 'string' ? payload.teamId : undefined
|
||||||
|
|
||||||
if (TEAM_EVENTS.has(type)) {
|
if (TEAM_EVENTS.has(type)) {
|
||||||
if (!payload?.teamId || teamsRef.current.some(t => t.id === payload.teamId)) {
|
if (!teamId || teamsRef.current.some(t => t.id === teamId)) {
|
||||||
fetchTeamsAndInvitations()
|
fetchTeamsAndInvitations()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -127,7 +154,8 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
|
|
||||||
const visibleTeams = useMemo(() => {
|
const visibleTeams = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
let list = q ? teams.filter(t => (t.name ?? '').toLowerCase().includes(q)) : teams.slice()
|
const base = q ? teams.filter(t => (t.name ?? '').toLowerCase().includes(q)) : teams.slice()
|
||||||
|
const list = base // <- wird nicht neu zugewiesen, daher const
|
||||||
if (sortBy === 'name-asc') {
|
if (sortBy === 'name-asc') {
|
||||||
list.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', 'de', { sensitivity: 'base' }))
|
list.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', 'de', { sensitivity: 'base' }))
|
||||||
} else {
|
} else {
|
||||||
@ -168,7 +196,7 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={e => setSortBy(e.target.value as any)}
|
onChange={e => setSortBy(e.target.value as 'name-asc' | 'members-desc')}
|
||||||
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||||
>
|
>
|
||||||
<option value="name-asc">Sortieren: Name (A–Z)</option>
|
<option value="name-asc">Sortieren: Name (A–Z)</option>
|
||||||
@ -212,12 +240,14 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
team={team}
|
team={team}
|
||||||
currentUserSteamId={currentSteamId}
|
currentUserSteamId={currentSteamId}
|
||||||
invitationId={teamToInvitationId[team.id]}
|
invitationId={teamToInvitationId[team.id]}
|
||||||
onUpdateInvitation={(teamId, newValue) => {
|
onUpdateInvitation={(teamId: string, newValue: string | null) => {
|
||||||
setTeamToInvitationId(prev => {
|
setTeamToInvitationId(prev => {
|
||||||
const updated = { ...prev }
|
const updated: Record<string, string> = { ...prev }
|
||||||
if (!newValue) delete updated[teamId]
|
if (newValue === null) {
|
||||||
else if (newValue === 'pending') updated[teamId] = updated[teamId] ?? 'pending'
|
delete updated[teamId]
|
||||||
else updated[teamId] = newValue
|
} else {
|
||||||
|
updated[teamId] = newValue
|
||||||
|
}
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import NotificationCenter from './NotificationCenter'
|
|||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSSEStore } from '@/lib/useSSEStore'
|
import { useSSEStore } from '@/lib/useSSEStore'
|
||||||
import { NOTIFICATION_EVENTS, isSseEventType } from '@/lib/sseEvents'
|
import { NOTIFICATION_EVENTS } from '@/lib/sseEvents'
|
||||||
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
||||||
|
|
||||||
type Notification = {
|
type Notification = {
|
||||||
@ -23,22 +23,43 @@ type ActionData =
|
|||||||
| { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
|
| { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
|
||||||
| { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
|
| { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
|
||||||
|
|
||||||
// --- API Helper ---
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
async function apiJSON(url: string, body?: any, method = 'POST') {
|
async function apiJSON<T = unknown>(url: string, body?: unknown, method: HttpMethod = 'POST'): Promise<T> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
|
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
|
||||||
return res.json().catch(() => ({}))
|
return res.json().catch(() => ({} as T))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiNotification = {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
read: boolean
|
||||||
|
actionType?: string
|
||||||
|
actionData?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
type NotificationsResponse = { notifications: ApiNotification[] }
|
||||||
|
|
||||||
|
type SsePayload = {
|
||||||
|
id?: string;
|
||||||
|
message?: string;
|
||||||
|
type?: string;
|
||||||
|
actionType?: string;
|
||||||
|
actionData?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
invitationId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function NotificationBell() {
|
export default function NotificationBell() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
|
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
|
||||||
const bellRef = useRef<HTMLButtonElement | null>(null);
|
const bellRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const telemetryBannerPx = useGameBannerStore(s => s.gameBannerPx)
|
const telemetryBannerPx = useGameBannerStore(s => s.gameBannerPx)
|
||||||
|
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
@ -50,36 +71,6 @@ export default function NotificationBell() {
|
|||||||
const baseBottom = 24 // px, entspricht bottom-6
|
const baseBottom = 24 // px, entspricht bottom-6
|
||||||
const bottomPx = baseBottom + (telemetryBannerPx || 0)
|
const bottomPx = baseBottom + (telemetryBannerPx || 0)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastEvent) return
|
|
||||||
if (!isSseEventType(lastEvent.type)) return
|
|
||||||
|
|
||||||
const data = lastEvent.payload
|
|
||||||
|
|
||||||
// ⬅️ Einladung zurückgezogen: betroffene Notifications entfernen und abbrechen
|
|
||||||
if (lastEvent.type === 'team-invite-revoked') {
|
|
||||||
const invId = data?.invitationId as string | undefined
|
|
||||||
const teamId = data?.teamId as string | undefined
|
|
||||||
setNotifications(prev =>
|
|
||||||
prev.filter(n => {
|
|
||||||
const isInvite = n.actionType === 'team-invite' || n.actionType === 'invitation'
|
|
||||||
if (!isInvite) return true
|
|
||||||
if (invId) return n.actionData !== invId && n.id !== invId
|
|
||||||
if (teamId) return n.actionData !== teamId
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nur Events, die wir als sichtbare Notifications zeigen wollen
|
|
||||||
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
|
|
||||||
if (data?.type === 'heartbeat') return
|
|
||||||
|
|
||||||
const msg = (data?.message ?? '').trim()
|
|
||||||
if (!msg) return
|
|
||||||
}, [lastEvent])
|
|
||||||
|
|
||||||
// 1) Initial laden
|
// 1) Initial laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
@ -88,8 +79,8 @@ export default function NotificationBell() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/notifications')
|
const res = await fetch('/api/notifications')
|
||||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||||
const data = await res.json()
|
const data: NotificationsResponse = await res.json()
|
||||||
const loaded: Notification[] = data.notifications.map((n: any) => ({
|
const loaded: Notification[] = data.notifications.map((n: ApiNotification) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
text: n.message,
|
text: n.message,
|
||||||
read: n.read,
|
read: n.read,
|
||||||
@ -106,11 +97,16 @@ export default function NotificationBell() {
|
|||||||
|
|
||||||
// 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
|
// 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return;
|
||||||
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
|
|
||||||
|
|
||||||
const data = lastEvent.payload
|
// optional: nur Events, die dein Set kennt
|
||||||
if (data?.type === 'heartbeat') return
|
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return;
|
||||||
|
|
||||||
|
// falls du zusätzlich deinen Typeguard nutzen willst:
|
||||||
|
// if (!isSseEventType(lastEvent.type)) return;
|
||||||
|
|
||||||
|
const data = lastEvent.payload as SsePayload | undefined;
|
||||||
|
if (data?.type === 'heartbeat') return;
|
||||||
|
|
||||||
const newNotification: Notification = {
|
const newNotification: Notification = {
|
||||||
id: data?.id ?? crypto.randomUUID(),
|
id: data?.id ?? crypto.randomUUID(),
|
||||||
@ -119,11 +115,11 @@ export default function NotificationBell() {
|
|||||||
actionType: data?.actionType,
|
actionType: data?.actionType,
|
||||||
actionData: data?.actionData,
|
actionData: data?.actionData,
|
||||||
createdAt: data?.createdAt ?? new Date().toISOString(),
|
createdAt: data?.createdAt ?? new Date().toISOString(),
|
||||||
}
|
};
|
||||||
|
|
||||||
setNotifications(prev => [newNotification, ...prev])
|
setNotifications(prev => [newNotification, ...prev]);
|
||||||
setPreviewText(newNotification.text) // <-- nur das hier
|
setPreviewText(newNotification.text);
|
||||||
}, [lastEvent])
|
}, [lastEvent]);
|
||||||
|
|
||||||
// 2) Timer separat steuern: triggert bei neuem previewText
|
// 2) Timer separat steuern: triggert bei neuem previewText
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -146,7 +142,6 @@ export default function NotificationBell() {
|
|||||||
}
|
}
|
||||||
}, [previewText])
|
}, [previewText])
|
||||||
|
|
||||||
|
|
||||||
// 3) Actions
|
// 3) Actions
|
||||||
const markAllAsRead = async () => {
|
const markAllAsRead = async () => {
|
||||||
await apiJSON('/api/notifications/mark-all-read', undefined, 'POST')
|
await apiJSON('/api/notifications/mark-all-read', undefined, 'POST')
|
||||||
@ -155,9 +150,7 @@ export default function NotificationBell() {
|
|||||||
|
|
||||||
const markOneAsRead = async (notificationId: string) => {
|
const markOneAsRead = async (notificationId: string) => {
|
||||||
await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST')
|
await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST')
|
||||||
setNotifications(prev =>
|
setNotifications(prev => prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)))
|
||||||
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => {
|
const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => {
|
||||||
@ -174,21 +167,19 @@ export default function NotificationBell() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// actionData parsen: erlaubt JSON {kind, inviteId/requestId, teamId} ODER nackte ID
|
// actionData parsen: erlaubt JSON ActionData ODER nackte ID
|
||||||
let kind: 'invite' | 'join-request' | undefined
|
let kind: ActionData['kind'] | undefined
|
||||||
let invitationId: string | undefined
|
let invitationId: string | undefined
|
||||||
let requestId: string | undefined
|
let requestId: string | undefined
|
||||||
let teamId: string | undefined
|
let teamId: string | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(n.actionData) as
|
const data = JSON.parse(n.actionData) as ActionData | string
|
||||||
| { kind?: 'invite' | 'join-request'; inviteId?: string; requestId?: string; teamId?: string }
|
|
||||||
| string
|
|
||||||
|
|
||||||
if (typeof data === 'object' && data) {
|
if (typeof data === 'object' && data) {
|
||||||
kind = data.kind
|
kind = data.kind
|
||||||
invitationId = data.inviteId
|
if (data.kind === 'invite') invitationId = data.inviteId
|
||||||
requestId = data.requestId
|
if (data.kind === 'join-request') requestId = data.requestId
|
||||||
teamId = data.teamId
|
teamId = data.teamId
|
||||||
} else if (typeof data === 'string') {
|
} else if (typeof data === 'string') {
|
||||||
// nackte ID: sowohl als invitationId als auch requestId nutzbar
|
// nackte ID: sowohl als invitationId als auch requestId nutzbar
|
||||||
@ -217,16 +208,11 @@ export default function NotificationBell() {
|
|||||||
|
|
||||||
// Optimistic Update (Buttons ausblenden)
|
// Optimistic Update (Buttons ausblenden)
|
||||||
const snapshot = notifications
|
const snapshot = notifications
|
||||||
setNotifications(prev =>
|
setNotifications(prev => prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)))
|
||||||
prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (kind === 'invite') {
|
if (kind === 'invite') {
|
||||||
await apiJSON(`/api/user/invitations/${action}`, {
|
await apiJSON('/api/user/invitations/' + action, { invitationId, teamId })
|
||||||
invitationId,
|
|
||||||
teamId,
|
|
||||||
})
|
|
||||||
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||||
if (action === 'accept') router.refresh()
|
if (action === 'accept') router.refresh()
|
||||||
return
|
return
|
||||||
@ -251,11 +237,10 @@ export default function NotificationBell() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const onNotificationClick = (notification: Notification) => {
|
const onNotificationClick = (notification: Notification) => {
|
||||||
if (!notification.actionData) return
|
if (!notification.actionData) return
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(notification.actionData)
|
const data = JSON.parse(notification.actionData) as Partial<ActionData> & { redirectUrl?: string }
|
||||||
if (data.redirectUrl) router.push(data.redirectUrl)
|
if (data.redirectUrl) router.push(data.redirectUrl)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[NotificationBell] Ungültige actionData:', err)
|
console.error('[NotificationBell] Ungültige actionData:', err)
|
||||||
@ -264,32 +249,32 @@ export default function NotificationBell() {
|
|||||||
|
|
||||||
// 4) Render
|
// 4) Render
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed right-6 z-50" style={{ bottom: bottomPx }}>
|
||||||
className="fixed right-6 z-50"
|
|
||||||
style={{ bottom: bottomPx }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
ref={bellRef}
|
ref={bellRef}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => e.stopPropagation()} // verhindert, dass der Outside-Handler denselben Pointer verwendet
|
onMouseDown={e => e.stopPropagation()} // verhindert, dass der Outside-Handler denselben Pointer verwendet
|
||||||
onClick={() => setOpen(prev => !prev)}
|
onClick={() => setOpen(prev => !prev)}
|
||||||
className={`relative flex items-center transition-all duration-300 ease-in-out
|
className={`relative flex items-center transition-all duration-300 ease-in-out
|
||||||
${showPreview ? 'w-[400px] pl-4 pr-11' : 'w-[44px] justify-center'}
|
${showPreview ? 'w-[400px] pl-4 pr-11' : 'w-[44px] justify-center'}
|
||||||
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
|
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
|
||||||
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
|
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
|
||||||
>
|
>
|
||||||
{previewText && (
|
{previewText && <span className="truncate text-sm text-gray-800 dark:text-white">{previewText}</span>}
|
||||||
<span className="truncate text-sm text-gray-800 dark:text-white">
|
|
||||||
{previewText}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
|
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
|
||||||
<svg
|
<svg
|
||||||
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
|
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
<path
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z" />
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{notifications.some(n => !n.read) && (
|
{notifications.some(n => !n.read) && (
|
||||||
<span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">
|
<span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Player, Team } from '../../../types/team'
|
import { Player } from '../../../types/team'
|
||||||
|
|
||||||
export type CardWidth =
|
export type CardWidth =
|
||||||
| 'sm' // max-w-sm (24rem)
|
| 'sm' // max-w-sm (24rem)
|
||||||
@ -14,14 +14,12 @@ export type CardWidth =
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
player: Player
|
player: Player
|
||||||
team?: Team // aktuell nicht genutzt, aber falls du später z. B. Team‑Farbe brauchst
|
|
||||||
align?: 'left' | 'right'
|
align?: 'left' | 'right'
|
||||||
maxWidth?: CardWidth
|
maxWidth?: CardWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlayerCard({
|
export default function PlayerCard({
|
||||||
player,
|
player,
|
||||||
team,
|
|
||||||
align = 'left',
|
align = 'left',
|
||||||
maxWidth = 'sm',
|
maxWidth = 'sm',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
|
// /src/app/[locale]/components/ReadyOverlayHost.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useSSEStore } from '@/lib/useSSEStore'
|
import { useSSEStore } from '@/lib/useSSEStore'
|
||||||
import MatchReadyOverlay from './MatchReadyOverlay'
|
import MatchReadyOverlay from './MatchReadyOverlay'
|
||||||
@ -16,11 +17,12 @@ async function getConnectHref(matchId?: string): Promise<string | null> {
|
|||||||
try {
|
try {
|
||||||
const qs = matchId ? `?matchId=${encodeURIComponent(matchId)}` : ''
|
const qs = matchId ? `?matchId=${encodeURIComponent(matchId)}` : ''
|
||||||
const r = await fetch(`/api/cs2/server${qs}`, { cache: 'no-store' })
|
const r = await fetch(`/api/cs2/server${qs}`, { cache: 'no-store' })
|
||||||
if (!r.ok) {
|
if (!r.ok) return null
|
||||||
return null
|
const j = (await r.json()) as unknown
|
||||||
}
|
const href =
|
||||||
const j = await r.json()
|
(j && typeof j === 'object' && 'connectHref' in j
|
||||||
const href: string | undefined = j?.connectHref
|
? (j as Record<string, unknown>).connectHref
|
||||||
|
: undefined) as string | undefined
|
||||||
if (href) CONNECT_CACHE.set(matchId, href)
|
if (href) CONNECT_CACHE.set(matchId, href)
|
||||||
return href ?? null
|
return href ?? null
|
||||||
} catch {
|
} catch {
|
||||||
@ -28,16 +30,89 @@ async function getConnectHref(matchId?: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------- Typen & Guards ----------------- */
|
||||||
|
type PlayerLite = { steamId?: string }
|
||||||
|
type TeamSide = { players?: PlayerLite[] }
|
||||||
|
type TeamBlock = { teamA?: TeamSide; teamB?: TeamSide }
|
||||||
|
type Step = { action?: string; map?: string }
|
||||||
|
type MapVisual = { label?: string; bg?: string }
|
||||||
|
|
||||||
|
type MapVoteUpdatedPayload = {
|
||||||
|
matchId?: string
|
||||||
|
locked?: boolean
|
||||||
|
bestOf?: number
|
||||||
|
steps?: Step[]
|
||||||
|
mapVisuals?: Record<string, MapVisual>
|
||||||
|
teams?: TeamBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchReadyPayload = {
|
||||||
|
matchId?: string
|
||||||
|
firstMap?: { label?: string; bg?: string }
|
||||||
|
participants?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SseLike = {
|
||||||
|
type?: string
|
||||||
|
payload?: unknown
|
||||||
|
ts?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null
|
||||||
|
|
||||||
|
const getPayload = (evt: unknown): unknown =>
|
||||||
|
isObject(evt) && 'payload' in evt ? (evt as { payload?: unknown }).payload : evt
|
||||||
|
|
||||||
|
const isStringArray = (v: unknown): v is string[] =>
|
||||||
|
Array.isArray(v) && v.every(x => typeof x === 'string')
|
||||||
|
|
||||||
|
const asPlayerList = (v: unknown): PlayerLite[] =>
|
||||||
|
Array.isArray(v) ? (v.filter(p => isObject(p)) as PlayerLite[]) : []
|
||||||
|
|
||||||
|
/* ---------- Aus 'map-vote-updated' minimal Summary ableiten ---------- */
|
||||||
|
function deriveReadySummary(payload: unknown) {
|
||||||
|
if (!isObject(payload)) return null
|
||||||
|
|
||||||
|
const p = payload as MapVoteUpdatedPayload
|
||||||
|
const matchId = p.matchId
|
||||||
|
if (!matchId) return null
|
||||||
|
|
||||||
|
const locked = !!p.locked
|
||||||
|
const bestOf = typeof p.bestOf === 'number' ? p.bestOf : 3
|
||||||
|
const steps = Array.isArray(p.steps) ? p.steps : []
|
||||||
|
const mapVisuals = isObject(p.mapVisuals) ? (p.mapVisuals as Record<string, MapVisual>) : {}
|
||||||
|
|
||||||
|
// Teilnehmer extrahieren
|
||||||
|
const teamA = isObject(p.teams) && isObject(p.teams.teamA) ? (p.teams.teamA as TeamSide) : undefined
|
||||||
|
const teamB = isObject(p.teams) && isObject(p.teams.teamB) ? (p.teams.teamB as TeamSide) : undefined
|
||||||
|
const playersA = asPlayerList(teamA?.players)
|
||||||
|
const playersB = asPlayerList(teamB?.players)
|
||||||
|
const participants = [
|
||||||
|
...playersA.map(pl => pl.steamId).filter((s): s is string => typeof s === 'string'),
|
||||||
|
...playersB.map(pl => pl.steamId).filter((s): s is string => typeof s === 'string'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const chosen = steps.filter(s => (s?.action === 'pick' || s?.action === 'decider') && s?.map)
|
||||||
|
const allChosen = locked && chosen.length >= bestOf
|
||||||
|
if (!allChosen) return null
|
||||||
|
|
||||||
|
const first = chosen[0]
|
||||||
|
const key = first?.map
|
||||||
|
const label = key ? (mapVisuals[key]?.label ?? key) : '?'
|
||||||
|
const bg = key ? (mapVisuals[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp'
|
||||||
|
|
||||||
|
return { matchId, firstMap: { key, label, bg }, participants }
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReadyOverlayHost() {
|
export default function ReadyOverlayHost() {
|
||||||
const router = useRouter()
|
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const mySteamId = session?.user?.steamId ?? null
|
const mySteamId = (session?.user as { steamId?: string } | undefined)?.steamId ?? null
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
const { open, data, showWithDelay, hide } = useReadyOverlayStore()
|
const { open, data, showWithDelay, hide } = useReadyOverlayStore()
|
||||||
|
const setRoster = useMatchRosterStore(s => s.setRoster)
|
||||||
const setRoster = useMatchRosterStore(s => s.setRoster) // ⬅️ neu
|
const clearRoster = useMatchRosterStore(s => s.clearRoster)
|
||||||
const clearRoster = useMatchRosterStore(s => s.clearRoster) // ⬅️ neu
|
|
||||||
|
|
||||||
const isAccepted = (matchId: string) =>
|
const isAccepted = (matchId: string) =>
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
@ -49,59 +124,26 @@ export default function ReadyOverlayHost() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aus 'map-vote-updated' minimal Summary ableiten (Map 1 + Teilnehmer)
|
|
||||||
function deriveReadySummary(payload: any) {
|
|
||||||
const matchId: string | undefined = payload?.matchId
|
|
||||||
if (!matchId) return null
|
|
||||||
|
|
||||||
const locked = !!payload?.locked
|
|
||||||
const bestOf = payload?.bestOf ?? 3
|
|
||||||
const steps: any[] = Array.isArray(payload?.steps) ? payload.steps : []
|
|
||||||
const mapVisuals = payload?.mapVisuals ?? {}
|
|
||||||
|
|
||||||
const playersA = payload?.teams?.teamA?.players ?? []
|
|
||||||
const playersB = payload?.teams?.teamB?.players ?? []
|
|
||||||
const participants: string[] = [
|
|
||||||
...playersA.map((p: any) => p?.steamId).filter(Boolean),
|
|
||||||
...playersB.map((p: any) => p?.steamId).filter(Boolean),
|
|
||||||
]
|
|
||||||
|
|
||||||
const chosen = steps.filter(
|
|
||||||
(s) => (s.action === 'pick' || s.action === 'decider') && s.map
|
|
||||||
)
|
|
||||||
const allChosen = locked && chosen.length >= bestOf
|
|
||||||
if (!allChosen) return null
|
|
||||||
|
|
||||||
const first = chosen[0]
|
|
||||||
const key = first?.map
|
|
||||||
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
|
|
||||||
const bg = key
|
|
||||||
? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`)
|
|
||||||
: '/assets/img/maps/cs2.webp'
|
|
||||||
|
|
||||||
return { matchId, firstMap: { key, label, bg }, participants }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Events: 'match-ready' & 'map-vote-updated'
|
// Events: 'match-ready' & 'map-vote-updated'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent || !mySteamId) return
|
if (!lastEvent || !mySteamId) return
|
||||||
const evt = (lastEvent as any).payload ?? lastEvent // ⬅️ robust gegen beide Formen
|
const evt = lastEvent as SseLike
|
||||||
|
const payload = getPayload(evt)
|
||||||
|
|
||||||
if (lastEvent.type === 'match-ready') {
|
if (evt.type === 'match-ready') {
|
||||||
(async () => {
|
;(async () => {
|
||||||
const m: string | undefined = evt?.matchId
|
const p = isObject(payload) ? (payload as MatchReadyPayload) : {}
|
||||||
const participants: string[] = evt?.participants ?? []
|
const m = p.matchId
|
||||||
|
const participants = isStringArray(p.participants) ? p.participants : []
|
||||||
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
|
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
|
||||||
|
|
||||||
setRoster(participants) // ✅ wird jetzt sicher gesetzt
|
setRoster(participants)
|
||||||
|
|
||||||
const label = evt?.firstMap?.label ?? '?'
|
const label = p.firstMap?.label ?? '?'
|
||||||
const bg = evt?.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
|
const bg = p.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
|
||||||
|
|
||||||
const connectHref =
|
const connectHref =
|
||||||
(await getConnectHref(m)) ||
|
(await getConnectHref(m)) || process.env.NEXT_PUBLIC_CONNECT_HREF || null
|
||||||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
|
|
||||||
null
|
|
||||||
|
|
||||||
showWithDelay(
|
showWithDelay(
|
||||||
{
|
{
|
||||||
@ -117,9 +159,9 @@ export default function ReadyOverlayHost() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastEvent.type === 'map-vote-updated') {
|
if (evt.type === 'map-vote-updated') {
|
||||||
(async () => {
|
;(async () => {
|
||||||
const summary = deriveReadySummary(evt) // evt statt lastEvent.payload
|
const summary = deriveReadySummary(payload)
|
||||||
if (!summary) return
|
if (!summary) return
|
||||||
const { matchId: m, firstMap, participants } = summary
|
const { matchId: m, firstMap, participants } = summary
|
||||||
if (!participants.includes(mySteamId) || isAccepted(m)) return
|
if (!participants.includes(mySteamId) || isAccepted(m)) return
|
||||||
@ -127,9 +169,7 @@ export default function ReadyOverlayHost() {
|
|||||||
setRoster(participants)
|
setRoster(participants)
|
||||||
|
|
||||||
const connectHref =
|
const connectHref =
|
||||||
(await getConnectHref(m)) ||
|
(await getConnectHref(m)) || process.env.NEXT_PUBLIC_CONNECT_HREF || null
|
||||||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
|
|
||||||
null
|
|
||||||
|
|
||||||
showWithDelay(
|
showWithDelay(
|
||||||
{
|
{
|
||||||
@ -149,11 +189,11 @@ export default function ReadyOverlayHost() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
if (lastEvent.type === 'map-vote-reset') {
|
if (lastEvent.type === 'map-vote-reset') {
|
||||||
const m = lastEvent.payload?.matchId
|
const m = (lastEvent.payload as { matchId?: string } | undefined)?.matchId
|
||||||
if (m && typeof window !== 'undefined') {
|
if (m && typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(`match:${m}:readyAccepted`)
|
window.localStorage.removeItem(`match:${m}:readyAccepted`)
|
||||||
}
|
}
|
||||||
clearRoster() // ⬅️ neu
|
clearRoster()
|
||||||
if (open) hide()
|
if (open) hide()
|
||||||
}
|
}
|
||||||
}, [lastEvent, open, hide, clearRoster])
|
}, [lastEvent, open, hide, clearRoster])
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
|
// /src/app/[locale]/components/ScrollSpyTabs.tsx
|
||||||
'use client'
|
'use client'
|
||||||
import {useEffect, useRef, useState, type RefObject} from 'react'
|
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
|
||||||
|
|
||||||
export type SpyItem = { id: string; label: string }
|
export type SpyItem = { id: string; label: string }
|
||||||
|
|
||||||
@ -14,6 +15,14 @@ type Props = {
|
|||||||
smoothMs?: number
|
smoothMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== stabile Helper außerhalb der Komponente ===== */
|
||||||
|
const escapeId = (id: string) =>
|
||||||
|
id.replace(/([ !"#$%&'()*+,.\/:;<=>?@[\]^`{|}~\\])/g, '\\$1')
|
||||||
|
|
||||||
|
const qs = (root: Document | HTMLElement, id: string) =>
|
||||||
|
root.querySelector<HTMLElement>(`#${escapeId(id)}`)
|
||||||
|
/* =================================================== */
|
||||||
|
|
||||||
export default function ScrollSpyTabs({
|
export default function ScrollSpyTabs({
|
||||||
items,
|
items,
|
||||||
containerRef,
|
containerRef,
|
||||||
@ -29,20 +38,17 @@ export default function ScrollSpyTabs({
|
|||||||
const isProgrammaticRef = useRef(false)
|
const isProgrammaticRef = useRef(false)
|
||||||
const progTimerRef = useRef<number | null>(null)
|
const progTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const setActive = (id: string) => {
|
const setActive = useCallback(
|
||||||
|
(id: string) => {
|
||||||
if (id && id !== activeId) {
|
if (id && id !== activeId) {
|
||||||
setActiveId(id)
|
setActiveId(id)
|
||||||
if (updateHash) history.replaceState(null, '', `#${id}`)
|
if (updateHash) history.replaceState(null, '', `#${id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[activeId, updateHash]
|
||||||
|
)
|
||||||
|
|
||||||
// sichere Query
|
/* -------- IntersectionObserver -------- */
|
||||||
const qs = (root: Document | HTMLElement, id: string) => {
|
|
||||||
const esc = (window as any).CSS?.escape?.(id) ?? id.replace(/([ #.;?+*~\\':"!^$[\]()=>|\/@])/g, '\\$1')
|
|
||||||
return root.querySelector<HTMLElement>(`#${esc}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------- IntersectionObserver: „mittlere“ Logik -------- */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rootEl = containerRef?.current ?? null
|
const rootEl = containerRef?.current ?? null
|
||||||
const rootNode: Document | HTMLElement = rootEl ?? document
|
const rootNode: Document | HTMLElement = rootEl ?? document
|
||||||
@ -55,23 +61,24 @@ export default function ScrollSpyTabs({
|
|||||||
() => {
|
() => {
|
||||||
if (isProgrammaticRef.current) return
|
if (isProgrammaticRef.current) return
|
||||||
|
|
||||||
// ⬇️ Top/Bottom bevorzugen
|
|
||||||
const firstId = items[0]?.id
|
const firstId = items[0]?.id
|
||||||
const lastId = items[items.length - 1]?.id
|
const lastId = items[items.length - 1]?.id
|
||||||
const EPS = 1
|
const EPS = 1
|
||||||
if (rootEl) {
|
if (rootEl) {
|
||||||
const atTop = rootEl.scrollTop <= EPS
|
const atTop = rootEl.scrollTop <= EPS
|
||||||
const atBottom = Math.ceil(rootEl.scrollTop + rootEl.clientHeight) >= rootEl.scrollHeight - EPS
|
const atBottom =
|
||||||
if (atTop && firstId) { setActive(firstId); return }
|
Math.ceil(rootEl.scrollTop + rootEl.clientHeight) >= rootEl.scrollHeight - EPS
|
||||||
if (atBottom && lastId){ setActive(lastId); return }
|
if (atTop && firstId) return setActive(firstId)
|
||||||
|
if (atBottom && lastId) return setActive(lastId)
|
||||||
} else {
|
} else {
|
||||||
const atTop = window.scrollY <= EPS
|
const atTop = window.scrollY <= EPS
|
||||||
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - EPS
|
const atBottom =
|
||||||
if (atTop && firstId) { setActive(firstId); return }
|
Math.ceil(window.scrollY + window.innerHeight) >=
|
||||||
if (atBottom && lastId){ setActive(lastId); return }
|
document.documentElement.scrollHeight - EPS
|
||||||
|
if (atTop && firstId) return setActive(firstId)
|
||||||
|
if (atBottom && lastId) return setActive(lastId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// „mittlere Linie“-Logik
|
|
||||||
const rootTop = rootEl ? rootEl.getBoundingClientRect().top : 0
|
const rootTop = rootEl ? rootEl.getBoundingClientRect().top : 0
|
||||||
const targetLine = rootTop + offset + 1
|
const targetLine = rootTop + offset + 1
|
||||||
|
|
||||||
@ -93,13 +100,12 @@ export default function ScrollSpyTabs({
|
|||||||
|
|
||||||
sections.forEach(s => observerRef.current!.observe(s))
|
sections.forEach(s => observerRef.current!.observe(s))
|
||||||
return () => observerRef.current?.disconnect()
|
return () => observerRef.current?.disconnect()
|
||||||
// ⬇️ WICHTIG: auf das ELEMENt hören, nicht nur auf das Ref-Objekt
|
}, [containerRef, items, offset, setActive])
|
||||||
}, [containerRef?.current, items, offset, updateHash])
|
|
||||||
|
|
||||||
/* -------- NEU: Kanten-Logik (Top/Bottom bevorzugen) -------- */
|
/* -------- Kanten-Logik (Top/Bottom bevorzugen) -------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef?.current
|
const el = containerRef?.current
|
||||||
const target: any = el ?? window
|
const target: Window | HTMLElement = el ?? window
|
||||||
const firstId = items[0]?.id
|
const firstId = items[0]?.id
|
||||||
const lastId = items[items.length - 1]?.id
|
const lastId = items[items.length - 1]?.id
|
||||||
const EPS = 1
|
const EPS = 1
|
||||||
@ -110,27 +116,29 @@ export default function ScrollSpyTabs({
|
|||||||
|
|
||||||
if (el) {
|
if (el) {
|
||||||
const atTop = el.scrollTop <= EPS
|
const atTop = el.scrollTop <= EPS
|
||||||
const atBottom = Math.ceil(el.scrollTop + el.clientHeight) >= el.scrollHeight - EPS
|
const atBottom =
|
||||||
|
Math.ceil(el.scrollTop + el.clientHeight) >= el.scrollHeight - EPS
|
||||||
if (atTop) return setActive(firstId)
|
if (atTop) return setActive(firstId)
|
||||||
if (atBottom) return setActive(lastId)
|
if (atBottom) return setActive(lastId)
|
||||||
} else {
|
} else {
|
||||||
const atTop = window.scrollY <= EPS
|
const atTop = window.scrollY <= EPS
|
||||||
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - EPS
|
const atBottom =
|
||||||
|
Math.ceil(window.scrollY + window.innerHeight) >=
|
||||||
|
document.documentElement.scrollHeight - EPS
|
||||||
if (atTop) return setActive(firstId)
|
if (atTop) return setActive(firstId)
|
||||||
if (atBottom) return setActive(lastId)
|
if (atBottom) return setActive(lastId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
target.addEventListener('scroll', onScrollOrResize, { passive: true })
|
target.addEventListener('scroll', onScrollOrResize, { passive: true } as AddEventListenerOptions)
|
||||||
window.addEventListener('resize', onScrollOrResize, { passive: true })
|
window.addEventListener('resize', onScrollOrResize, { passive: true } as AddEventListenerOptions)
|
||||||
onScrollOrResize()
|
onScrollOrResize()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
target.removeEventListener('scroll', onScrollOrResize)
|
target.removeEventListener('scroll', onScrollOrResize as EventListener)
|
||||||
window.removeEventListener('resize', onScrollOrResize)
|
window.removeEventListener('resize', onScrollOrResize as EventListener)
|
||||||
}
|
}
|
||||||
// ⬇️ ebenfalls auf das Element selbst hören
|
}, [containerRef, items, setActive])
|
||||||
}, [containerRef?.current, items])
|
|
||||||
|
|
||||||
/* -------- programmatic scroll (Tabs-Klick) -------- */
|
/* -------- programmatic scroll (Tabs-Klick) -------- */
|
||||||
const onJump = (id: string) => {
|
const onJump = (id: string) => {
|
||||||
@ -161,15 +169,17 @@ export default function ScrollSpyTabs({
|
|||||||
return (
|
return (
|
||||||
<nav className={className} aria-label="Section navigation" role="tablist" aria-orientation="vertical">
|
<nav className={className} aria-label="Section navigation" role="tablist" aria-orientation="vertical">
|
||||||
{items.map(it => {
|
{items.map(it => {
|
||||||
const isActive = activeId === it.id
|
const isCurrent = activeId === it.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={it.id}
|
key={it.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isCurrent}
|
||||||
onClick={() => onJump(it.id)}
|
onClick={() => onJump(it.id)}
|
||||||
className={`text-left py-2 px-3 rounded-lg transition-colors ${isActive ? activeClassName : inactiveClassName}`}
|
className={`text-left py-2 px-3 rounded-lg transition-colors ${
|
||||||
|
isCurrent ? activeClassName : inactiveClassName
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{it.label}
|
{it.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
import { useRouter, usePathname } from '@/i18n/navigation'
|
import { useRouter, usePathname } from '@/i18n/navigation'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
@ -23,7 +23,7 @@ export default function Sidebar() {
|
|||||||
const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null)
|
const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null)
|
||||||
|
|
||||||
// Aktive Route prüfen (pathname kommt schon ohne Locale)
|
// Aktive Route prüfen (pathname kommt schon ohne Locale)
|
||||||
const isActive = (path: string) => pathname === path
|
const isActive = useCallback((path: string) => pathname === path, [pathname])
|
||||||
|
|
||||||
const navBtnBase =
|
const navBtnBase =
|
||||||
'w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors'
|
'w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors'
|
||||||
@ -38,12 +38,11 @@ export default function Sidebar() {
|
|||||||
setOpenSubmenu(prev => (prev === key ? null : key))
|
setOpenSubmenu(prev => (prev === key ? null : key))
|
||||||
|
|
||||||
// ✅ Locale-Wechsel: gleiche Route behalten, nur Locale ändern
|
// ✅ Locale-Wechsel: gleiche Route behalten, nur Locale ändern
|
||||||
const changeLocale = (nextLocale: 'en' | 'de') => {
|
const changeLocale = useCallback((nextLocale: 'en' | 'de') => {
|
||||||
if (nextLocale === locale) return
|
if (nextLocale === locale) return
|
||||||
// pathname ist z.B. '/dashboard' – next-intl setzt das Locale
|
|
||||||
router.replace(pathname, { locale: nextLocale })
|
router.replace(pathname, { locale: nextLocale })
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}, [router, pathname, locale])
|
||||||
|
|
||||||
// Gemeinsamer Inhalt (wird in Desktop-Aside und im Mobile-Drawer benutzt)
|
// Gemeinsamer Inhalt (wird in Desktop-Aside und im Mobile-Drawer benutzt)
|
||||||
const SidebarInner = useMemo(
|
const SidebarInner = useMemo(
|
||||||
@ -70,10 +69,10 @@ export default function Sidebar() {
|
|||||||
{/* Dashboard */}
|
{/* Dashboard */}
|
||||||
<li>
|
<li>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => { router.push('/dashboard'); setIsOpen(false) }}
|
onClick={() => { router.push('/'); setIsOpen(false) }}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="link"
|
variant="link"
|
||||||
className={`${navBtnBase} ${isActive('/dashboard') ? activeClasses : idleClasses}`}
|
className={`${navBtnBase} ${isActive('/') ? activeClasses : idleClasses}`}
|
||||||
aria-label={tNav('dashboard')}
|
aria-label={tNav('dashboard')}
|
||||||
>
|
>
|
||||||
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@ -248,7 +247,7 @@ export default function Sidebar() {
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
[pathname, openSubmenu, locale, tNav, tSidebar]
|
[openSubmenu, locale, tNav, tSidebar, isActive, changeLocale, router]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,40 +1,44 @@
|
|||||||
// /src/app/[locale]]/components/SidebarFooter.ts
|
// /src/app/[locale]/components/SidebarFooter.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSession, signIn, signOut } from 'next-auth/react'
|
import { useSession, signIn } from 'next-auth/react'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { signOutWithStatus } from '@/lib/signOutWithStatus'
|
import { signOutWithStatus } from '@/lib/signOutWithStatus'
|
||||||
import { useRouter, usePathname } from 'next/navigation'
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import Image from 'next/image'
|
|
||||||
import LoadingSpinner from '../components/LoadingSpinner'
|
import LoadingSpinner from '../components/LoadingSpinner'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import UserAvatarWithStatus from './UserAvatarWithStatus'
|
import UserAvatarWithStatus from './UserAvatarWithStatus'
|
||||||
import PremierRankBadge from './PremierRankBadge'
|
import PremierRankBadge from './PremierRankBadge'
|
||||||
|
|
||||||
|
type SessUser = {
|
||||||
|
steamId?: string | null
|
||||||
|
name?: string | null
|
||||||
|
image?: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export default function SidebarFooter() {
|
export default function SidebarFooter() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
// Übersetzungen
|
|
||||||
const tSidebar = useTranslations('sidebar')
|
const tSidebar = useTranslations('sidebar')
|
||||||
|
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [teamName, setTeamName] = useState<string | null>(null)
|
const [teamName, setTeamName] = useState<string | null>(null)
|
||||||
const [premierRank, setPremierRank] = useState<number>(0)
|
const [premierRank, setPremierRank] = useState<number>(0)
|
||||||
|
|
||||||
// ➜ Nach Login: User aus DB laden (inkl. premierRank & Teamname)
|
// Userdetails laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'authenticated') {
|
if (status !== 'authenticated') {
|
||||||
setTeamName(null)
|
setTeamName(null)
|
||||||
setPremierRank(0) // ← immer 0, nicht null
|
setPremierRank(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/user', { cache: 'no-store' })
|
const res = await fetch('/api/user', { cache: 'no-store' })
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
@ -54,7 +58,7 @@ export default function SidebarFooter() {
|
|||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => signIn('steam', { callbackUrl: `/dashboard` })}
|
onClick={() => signIn('steam', { callbackUrl: `/` })}
|
||||||
className="flex items-center justify-center gap-2 w-full py-4 px-6 bg-green-800 text-white text-md font-medium hover:bg-green-900 transition"
|
className="flex items-center justify-center gap-2 w-full py-4 px-6 bg-green-800 text-white text-md font-medium hover:bg-green-900 transition"
|
||||||
>
|
>
|
||||||
<i className="fab fa-steam" />
|
<i className="fab fa-steam" />
|
||||||
@ -63,9 +67,10 @@ export default function SidebarFooter() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const subline = teamName ?? session?.user?.steamId
|
const u = session?.user as SessUser | undefined
|
||||||
const userName = session?.user?.name || 'Profil'
|
const subline = teamName ?? u?.steamId ?? undefined
|
||||||
const avatarSrc = (session?.user as any)?.avatar || session?.user?.image || '/default-avatar.png'
|
const userName = u?.name || 'Profil'
|
||||||
|
const avatarSrc = u?.avatar ?? u?.image ?? '/default-avatar.png'
|
||||||
|
|
||||||
const linkClass = (active: boolean) =>
|
const linkClass = (active: boolean) =>
|
||||||
`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors ${
|
`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors ${
|
||||||
@ -89,7 +94,7 @@ export default function SidebarFooter() {
|
|||||||
src={avatarSrc}
|
src={avatarSrc}
|
||||||
alt={userName}
|
alt={userName}
|
||||||
size={30}
|
size={30}
|
||||||
steamId={session?.user?.steamId}
|
steamId={u?.steamId ?? undefined}
|
||||||
/>
|
/>
|
||||||
<div className="ms-3 flex-1 min-w-0">
|
<div className="ms-3 flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-gray-800 dark:text-white truncate">
|
<h3 className="font-semibold text-gray-800 dark:text-white truncate">
|
||||||
@ -100,13 +105,11 @@ export default function SidebarFooter() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge darf nicht schrumpfen */}
|
|
||||||
<div className="ml-2 flex-shrink-0">
|
<div className="ml-2 flex-shrink-0">
|
||||||
<PremierRankBadge rank={premierRank} />
|
<PremierRankBadge rank={premierRank} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pfeil – ebenfalls nicht schrumpfen */}
|
|
||||||
<svg
|
<svg
|
||||||
className={`ms-2 size-4 shrink-0 ${isOpen ? 'rotate-180' : ''} text-gray-600 dark:text-neutral-400`}
|
className={`ms-2 size-4 shrink-0 ${isOpen ? 'rotate-180' : ''} text-gray-600 dark:text-neutral-400`}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@ -130,10 +133,10 @@ export default function SidebarFooter() {
|
|||||||
>
|
>
|
||||||
<div className="p-2 flex flex-col gap-1">
|
<div className="p-2 flex flex-col gap-1">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push(`/profile/${session?.user?.steamId}`)}
|
onClick={() => router.push(`/profile/${u?.steamId ?? ''}`)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="link"
|
variant="link"
|
||||||
className={linkClass(pathname === `/profile/${session?.user?.steamId}`)}
|
className={linkClass(pathname === `/profile/${u?.steamId ?? ''}`)}
|
||||||
>
|
>
|
||||||
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||||
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@ -168,7 +171,7 @@ export default function SidebarFooter() {
|
|||||||
{tSidebar('footer.settings')}
|
{tSidebar('footer.settings')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{session?.user?.isAdmin && (
|
{u?.isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push('/admin')}
|
onClick={() => router.push('/admin')}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
type TabProps = {
|
type TabProps = {
|
||||||
name: string
|
name: string
|
||||||
href: string
|
href: string
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
// /src/app/[locale]/components/Tabs.tsx
|
// /src/app/[locale]/components/Tabs.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { ReactNode, ReactElement } from 'react'
|
import type { ReactNode, FC } from 'react'
|
||||||
|
import { Children, type ReactElement } from 'react';
|
||||||
|
|
||||||
export type TabProps = {
|
export type TabProps = {
|
||||||
name: string
|
name: string
|
||||||
@ -13,47 +13,47 @@ export type TabProps = {
|
|||||||
|
|
||||||
type TabsProps = {
|
type TabsProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
/** optional kontrollierter Modus */
|
|
||||||
value?: string
|
value?: string
|
||||||
onChange?: (name: string) => void
|
onChange?: (name: string) => void
|
||||||
/** Ausrichtung */
|
|
||||||
orientation?: 'horizontal' | 'vertical'
|
orientation?: 'horizontal' | 'vertical'
|
||||||
/** optional: Styling */
|
|
||||||
className?: string
|
className?: string
|
||||||
tabClassName?: string
|
tabClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── add a component type that has a static Tab
|
||||||
|
type TabsComponent = FC<TabsProps> & { Tab: FC<TabProps> }
|
||||||
|
|
||||||
function normalize(path: string) {
|
function normalize(path: string) {
|
||||||
if (!path) return '/'
|
if (!path) return '/'
|
||||||
const v = path.replace(/\/+$/, '')
|
const v = path.replace(/\/+$/, '')
|
||||||
return v === '' ? '/' : v
|
return v === '' ? '/' : v
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tabs({
|
function isTabElement(v: unknown): v is ReactElement<TabProps> {
|
||||||
|
if (typeof v !== 'object' || v === null) return false;
|
||||||
|
if (!('props' in v)) return false;
|
||||||
|
const propsUnknown = (v as { props: unknown }).props;
|
||||||
|
if (typeof propsUnknown !== 'object' || propsUnknown === null) return false;
|
||||||
|
const props = propsUnknown as Record<string, unknown>;
|
||||||
|
return typeof props.href === 'string' && typeof props.name === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement as base function
|
||||||
|
const TabsBase: FC<TabsProps> = ({
|
||||||
children,
|
children,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
orientation = 'horizontal',
|
orientation = 'horizontal',
|
||||||
className = '',
|
className = '',
|
||||||
tabClassName = ''
|
tabClassName = ''
|
||||||
}: TabsProps) {
|
}) => {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
// Kinder in gültige Tab-Elemente filtern
|
const rawTabs = Children.toArray(children);
|
||||||
const rawTabs = Array.isArray(children) ? children : [children]
|
const tabs = rawTabs.filter(isTabElement);
|
||||||
const tabs = rawTabs.filter(
|
|
||||||
(tab): tab is ReactElement<TabProps> =>
|
|
||||||
tab !== null &&
|
|
||||||
typeof tab === 'object' &&
|
|
||||||
'props' in tab &&
|
|
||||||
typeof tab.props.href === 'string' &&
|
|
||||||
typeof tab.props.name === 'string'
|
|
||||||
)
|
|
||||||
|
|
||||||
const isVertical = orientation === 'vertical'
|
const isVertical = orientation === 'vertical'
|
||||||
const current = normalize(pathname)
|
const current = normalize(pathname)
|
||||||
|
|
||||||
// Liste aller Tab-URLs (normalisiert) für die Heuristik
|
|
||||||
const hrefs = tabs.map(t => normalize(t.props.href))
|
const hrefs = tabs.map(t => normalize(t.props.href))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -68,10 +68,8 @@ export function Tabs({
|
|||||||
aria-orientation={isVertical ? 'vertical' : 'horizontal'}
|
aria-orientation={isVertical ? 'vertical' : 'horizontal'}
|
||||||
>
|
>
|
||||||
{tabs.map((tab, index) => {
|
{tabs.map((tab, index) => {
|
||||||
const baseClasses =
|
const baseClasses = 'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
|
||||||
'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
|
|
||||||
|
|
||||||
// Kontrollierter Modus: Auswahl über value/onChange
|
|
||||||
if (onChange && value !== undefined) {
|
if (onChange && value !== undefined) {
|
||||||
const isActive = value === tab.props.name
|
const isActive = value === tab.props.name
|
||||||
return (
|
return (
|
||||||
@ -82,9 +80,7 @@ export function Tabs({
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
className={
|
className={
|
||||||
baseClasses +
|
baseClasses + ' ' + (isActive
|
||||||
' ' +
|
|
||||||
(isActive
|
|
||||||
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
||||||
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
|
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
|
||||||
}
|
}
|
||||||
@ -94,16 +90,9 @@ export function Tabs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unkontrollierter Modus: Link-basiert
|
|
||||||
const base = normalize(tab.props.href)
|
const base = normalize(tab.props.href)
|
||||||
|
|
||||||
// Hat dieser Tab "tiefere" Geschwister? (z.B. /profile/... und /profile/.../matches)
|
|
||||||
const hasSiblingDeeper = hrefs.some(h => h !== base && h.startsWith(base + '/'))
|
const hasSiblingDeeper = hrefs.some(h => h !== base && h.startsWith(base + '/'))
|
||||||
|
|
||||||
// Nur wenn es KEINEN tieferen Sibling gibt, erlauben wir Prefix-Matching.
|
|
||||||
// Dadurch ist /profile/... NICHT aktiv, wenn /profile/.../matches aktiv ist.
|
|
||||||
const allowStartsWith = !hasSiblingDeeper
|
const allowStartsWith = !hasSiblingDeeper
|
||||||
|
|
||||||
const isActive = current === base || (allowStartsWith && current.startsWith(base + '/'))
|
const isActive = current === base || (allowStartsWith && current.startsWith(base + '/'))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -113,9 +102,7 @@ export function Tabs({
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
className={
|
className={
|
||||||
baseClasses +
|
baseClasses + ' ' + (isActive
|
||||||
' ' +
|
|
||||||
(isActive
|
|
||||||
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
||||||
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
|
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
|
||||||
}
|
}
|
||||||
@ -128,6 +115,8 @@ export function Tabs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Tabs.Tab = function Tab(_props: TabProps) {
|
// the dummy Tab element (for nicer JSX usage)
|
||||||
return null
|
const Tab: FC<TabProps> = () => null
|
||||||
}
|
|
||||||
|
// ── export Tabs with a typed static property
|
||||||
|
export const Tabs: TabsComponent = Object.assign(TabsBase, { Tab })
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
// /src/app/components/TeamCard.tsx
|
// /src/app/[locale]/components/TeamCard.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||||||
import type { Team, TeamJoinPolicy } from '../../../types/team'
|
import type { Team, TeamJoinPolicy, Player } from '../../../types/team'
|
||||||
|
|
||||||
// ⬇️ NEU: SSE-Hooks / Type-Guard
|
|
||||||
import { useSSEStore } from '@/lib/useSSEStore'
|
import { useSSEStore } from '@/lib/useSSEStore'
|
||||||
import { isSseEventType } from '@/lib/sseEvents'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
team: Team
|
team: Team
|
||||||
@ -17,9 +15,30 @@ type Props = {
|
|||||||
invitationId?: string
|
invitationId?: string
|
||||||
onUpdateInvitation: (teamId: string, newValue: string | null | 'pending') => void
|
onUpdateInvitation: (teamId: string, newValue: string | null | 'pending') => void
|
||||||
adminMode?: boolean
|
adminMode?: boolean
|
||||||
|
/** Falls false, ist Anfragen grundsätzlich deaktiviert (z. B. User ist bereits in einem Team) */
|
||||||
canRequestJoin?: boolean
|
canRequestJoin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ButtonColor = 'blue' | 'red' | 'gray'
|
||||||
|
type MemberLike = Pick<Player, 'steamId' | 'name' | 'avatar'>
|
||||||
|
|
||||||
|
/* === Helper nach oben (außerhalb der Komponente) ================= */
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null
|
||||||
|
|
||||||
|
const extractPayloadObject = (raw: unknown): Record<string, unknown> => {
|
||||||
|
if (!isRecord(raw)) return {}
|
||||||
|
if ('payload' in raw && isRecord((raw as Record<string, unknown>).payload)) {
|
||||||
|
return (raw as { payload: Record<string, unknown> }).payload
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
const asJoinPolicy = (v: unknown): TeamJoinPolicy | undefined =>
|
||||||
|
v === 'REQUEST' || v === 'INVITE_ONLY' ? v : undefined
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
export default function TeamCard({
|
export default function TeamCard({
|
||||||
team,
|
team,
|
||||||
currentUserSteamId,
|
currentUserSteamId,
|
||||||
@ -31,14 +50,12 @@ export default function TeamCard({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [joining, setJoining] = useState(false)
|
const [joining, setJoining] = useState(false)
|
||||||
|
|
||||||
// ⬇️ NEU: SSE
|
// SSE
|
||||||
const { connect, lastEvent, isConnected } = useSSEStore()
|
const { connect, lastEvent, isConnected } = useSSEStore()
|
||||||
|
|
||||||
// ⬇️ NEU: lokale, “wirksame” Policy – startet mit Prop
|
// lokale, wirksame Policy
|
||||||
const [effectivePolicy, setEffectivePolicy] = useState<TeamJoinPolicy>(team.joinPolicy)
|
const [effectivePolicy, setEffectivePolicy] = useState<TeamJoinPolicy>(team.joinPolicy)
|
||||||
const sseWinsUntil = useRef(0)
|
const sseWinsUntil = useRef(0)
|
||||||
const lastHandledKeyRef = useRef('')
|
|
||||||
|
|
||||||
const lastSeenTsRef = useRef<number | null>(null)
|
const lastSeenTsRef = useRef<number | null>(null)
|
||||||
|
|
||||||
// SSE-Verbindung herstellen
|
// SSE-Verbindung herstellen
|
||||||
@ -47,56 +64,57 @@ export default function TeamCard({
|
|||||||
if (!isConnected) connect(currentUserSteamId)
|
if (!isConnected) connect(currentUserSteamId)
|
||||||
}, [currentUserSteamId, isConnected, connect])
|
}, [currentUserSteamId, isConnected, connect])
|
||||||
|
|
||||||
// ⬇️ Jede 'team-updated'-Änderung verarbeiten, robust entpacken, per ts deduplizieren
|
// team-updated verarbeiten
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ev = lastEvent
|
const ev = lastEvent
|
||||||
if (!ev || ev.type !== 'team-updated') return
|
if (!ev || ev.type !== 'team-updated') return
|
||||||
|
|
||||||
// payload kann entweder direkt die Felder haben … oder unter payload liegen
|
const p = extractPayloadObject(ev.payload)
|
||||||
const p = (ev.payload && typeof ev.payload === 'object' && 'payload' in ev.payload)
|
const tid = typeof p.teamId === 'string' ? (p.teamId as string) : undefined
|
||||||
? ev.payload.payload
|
const jp = asJoinPolicy(p.joinPolicy)
|
||||||
: ev.payload
|
|
||||||
|
|
||||||
const tid = p?.teamId
|
|
||||||
const jp = p?.joinPolicy as TeamJoinPolicy | undefined
|
|
||||||
if (tid !== team.id) return
|
if (tid !== team.id) return
|
||||||
if (jp !== 'REQUEST' && jp !== 'INVITE_ONLY') return
|
if (!jp) return
|
||||||
|
|
||||||
// Dedupe an der Ereignis-Identität (ts stammt aus dem Store)
|
|
||||||
if (ev.ts && lastSeenTsRef.current === ev.ts) return
|
if (ev.ts && lastSeenTsRef.current === ev.ts) return
|
||||||
lastSeenTsRef.current = ev.ts ?? Date.now()
|
lastSeenTsRef.current = ev.ts ?? Date.now()
|
||||||
|
|
||||||
// kurzes Fenster, in dem Props-Refetch nicht wieder überschreibt
|
|
||||||
sseWinsUntil.current = Date.now() + 1500
|
sseWinsUntil.current = Date.now() + 1500
|
||||||
setEffectivePolicy(jp)
|
setEffectivePolicy(jp)
|
||||||
}, [lastEvent?.ts, lastEvent, team.id])
|
}, [lastEvent, team.id])
|
||||||
|
|
||||||
|
// Props übernehmen, wenn kein frisches SSE dazwischenfunkt
|
||||||
// ⬇️ Props nur übernehmen, wenn kein frisches SSE dazwischenfunkt
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const jp = team.joinPolicy as TeamJoinPolicy | undefined
|
const jp = team.joinPolicy as TeamJoinPolicy | undefined
|
||||||
if (Date.now() < sseWinsUntil.current) return
|
if (Date.now() < sseWinsUntil.current) return
|
||||||
if (jp === 'REQUEST' || jp === 'INVITE_ONLY') {
|
if (jp === 'REQUEST' || jp === 'INVITE_ONLY') {
|
||||||
setEffectivePolicy(prev => (prev === jp ? prev : jp))
|
setEffectivePolicy((prev) => (prev === jp ? prev : jp))
|
||||||
}
|
}
|
||||||
}, [team.id, team.joinPolicy])
|
}, [team.id, team.joinPolicy])
|
||||||
|
|
||||||
// ── Stati ableiten (jetzt von effectivePolicy!)
|
// Stati ableiten
|
||||||
const isInviteOnly = effectivePolicy === 'INVITE_ONLY'
|
const isInviteOnly = effectivePolicy === 'INVITE_ONLY'
|
||||||
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
|
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
|
||||||
const hasPendingRequest = invitationId === 'pending'
|
const hasPendingRequest = invitationId === 'pending'
|
||||||
const isRequested = hasRealInvitation || hasPendingRequest
|
const isRequested = hasRealInvitation || hasPendingRequest
|
||||||
|
|
||||||
const isMemberOfThisTeam = useMemo(() => {
|
const isMemberOfThisTeam = useMemo(() => {
|
||||||
const inActive = (team.activePlayers ?? []).some(p => String(p.steamId) === String(currentUserSteamId))
|
const inActive = (team.activePlayers ?? []).some(
|
||||||
const inInactive = (team.inactivePlayers ?? []).some(p => String(p.steamId) === String(currentUserSteamId))
|
(p) => String(p.steamId) === String(currentUserSteamId)
|
||||||
const isLeader = team.leader?.steamId && String(team.leader.steamId) === String(currentUserSteamId)
|
)
|
||||||
|
const inInactive = (team.inactivePlayers ?? []).some(
|
||||||
|
(p) => String(p.steamId) === String(currentUserSteamId)
|
||||||
|
)
|
||||||
|
const isLeader =
|
||||||
|
team.leader?.steamId &&
|
||||||
|
String(team.leader.steamId) === String(currentUserSteamId)
|
||||||
return Boolean(inActive || inInactive || isLeader)
|
return Boolean(inActive || inInactive || isLeader)
|
||||||
}, [team, currentUserSteamId])
|
}, [team, currentUserSteamId])
|
||||||
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
joining ||
|
joining ||
|
||||||
isMemberOfThisTeam ||
|
isMemberOfThisTeam ||
|
||||||
|
!canRequestJoin ||
|
||||||
(isInviteOnly && !hasRealInvitation && !hasPendingRequest)
|
(isInviteOnly && !hasRealInvitation && !hasPendingRequest)
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
@ -140,23 +158,47 @@ export default function TeamCard({
|
|||||||
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
|
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
|
||||||
Lädt
|
Lädt
|
||||||
</>
|
</>
|
||||||
) : isMemberOfThisTeam ? 'Schon Mitglied'
|
) : isMemberOfThisTeam ? (
|
||||||
: hasRealInvitation ? 'Einladung ablehnen'
|
'Schon Mitglied'
|
||||||
: hasPendingRequest ? 'Angefragt (zurückziehen)'
|
) : hasRealInvitation ? (
|
||||||
: isInviteOnly ? 'Nur Einladungen'
|
'Einladung ablehnen'
|
||||||
: 'Beitritt anfragen'
|
) : hasPendingRequest ? (
|
||||||
|
'Angefragt (zurückziehen)'
|
||||||
|
) : isInviteOnly ? (
|
||||||
|
'Nur Einladungen'
|
||||||
|
) : (
|
||||||
|
'Beitritt anfragen'
|
||||||
|
)
|
||||||
|
|
||||||
const buttonColor =
|
const buttonColor: ButtonColor =
|
||||||
hasRealInvitation ? 'red' :
|
hasRealInvitation ? 'red' : isDisabled ? 'gray' : isRequested ? 'gray' : 'blue'
|
||||||
isDisabled ? 'gray' :
|
|
||||||
(isRequested ? 'gray' : 'blue')
|
// Mitglieder-Avatare (Leader zuerst, dann aktiv, dann inaktiv; unique)
|
||||||
|
const members: MemberLike[] = useMemo(() => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: MemberLike[] = []
|
||||||
|
|
||||||
|
const pushUnique = (p?: MemberLike | null) => {
|
||||||
|
if (!p?.steamId) return
|
||||||
|
const sid = String(p.steamId)
|
||||||
|
if (seen.has(sid)) return
|
||||||
|
seen.add(sid)
|
||||||
|
out.push({ steamId: sid, name: p.name, avatar: p.avatar })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team.leader) pushUnique(team.leader)
|
||||||
|
;(team.activePlayers ?? []).forEach((p) => pushUnique(p))
|
||||||
|
;(team.inactivePlayers ?? []).forEach((p) => pushUnique(p))
|
||||||
|
|
||||||
|
return out
|
||||||
|
}, [team.leader, team.activePlayers, team.inactivePlayers])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => router.push(targetHref)}
|
onClick={() => router.push(targetHref)}
|
||||||
onKeyDown={e => (e.key === 'Enter') && router.push(targetHref)}
|
onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
|
||||||
className="p-4 border rounded-lg bg-white dark:bg-neutral-800
|
className="p-4 border rounded-lg bg-white dark:bg-neutral-800
|
||||||
dark:border-neutral-700 shadow-sm hover:shadow-md
|
dark:border-neutral-700 shadow-sm hover:shadow-md
|
||||||
transition cursor-pointer focus:outline-none
|
transition cursor-pointer focus:outline-none
|
||||||
@ -164,12 +206,18 @@ export default function TeamCard({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img
|
<div className="relative h-12 w-12 overflow-hidden rounded-full border border-gray-200 dark:border-neutral-600">
|
||||||
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
<Image
|
||||||
|
src={
|
||||||
|
team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`
|
||||||
|
}
|
||||||
alt={team.name ?? 'Teamlogo'}
|
alt={team.name ?? 'Teamlogo'}
|
||||||
className="w-12 h-12 rounded-full object-cover border
|
fill
|
||||||
border-gray-200 dark:border-neutral-600"
|
sizes="48px"
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
|
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
|
||||||
{team.name ?? 'Team'}
|
{team.name ?? 'Team'}
|
||||||
@ -184,7 +232,7 @@ export default function TeamCard({
|
|||||||
size="md"
|
size="md"
|
||||||
color="blue"
|
color="blue"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
router.push(`/admin/teams/${team.id}`)
|
router.push(`/admin/teams/${team.id}`)
|
||||||
}}
|
}}
|
||||||
@ -192,13 +240,15 @@ export default function TeamCard({
|
|||||||
Verwalten
|
Verwalten
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
// 👉 Button immer zeigen – falls nicht möglich: disabled + anderes Label
|
|
||||||
<Button
|
<Button
|
||||||
title={typeof buttonLabel === 'string' ? buttonLabel : undefined}
|
title={typeof buttonLabel === 'string' ? buttonLabel : undefined}
|
||||||
size="sm"
|
size="sm"
|
||||||
color={buttonColor as any}
|
color={buttonColor}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onClick={e => { e.stopPropagation(); handleClick() }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleClick()
|
||||||
|
}}
|
||||||
aria-disabled={isDisabled ? 'true' : undefined}
|
aria-disabled={isDisabled ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
@ -207,33 +257,16 @@ export default function TeamCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex -space-x-3">
|
<div className="flex -space-x-3">
|
||||||
{(() => {
|
{members.map((p) => (
|
||||||
const seen = new Set<string>();
|
<div
|
||||||
const members: any[] = [];
|
|
||||||
|
|
||||||
const pushUnique = (p?: any) => {
|
|
||||||
if (!p || !p.steamId || seen.has(p.steamId)) return;
|
|
||||||
seen.add(p.steamId);
|
|
||||||
members.push(p);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1) Leader (falls vorhanden) zuerst
|
|
||||||
if (team.leader) pushUnique(team.leader);
|
|
||||||
|
|
||||||
// 2) aktive & inaktive
|
|
||||||
(team.activePlayers ?? []).forEach(pushUnique);
|
|
||||||
(team.inactivePlayers ?? []).forEach(pushUnique);
|
|
||||||
|
|
||||||
return members.map(p => (
|
|
||||||
<img
|
|
||||||
key={p.steamId}
|
key={p.steamId}
|
||||||
src={p.avatar}
|
|
||||||
alt={p.name}
|
|
||||||
title={p.name}
|
title={p.name}
|
||||||
className="w-8 h-8 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
|
aria-label={p.name}
|
||||||
/>
|
className="relative h-8 w-8 overflow-hidden rounded-full border-2 border-white dark:border-neutral-800"
|
||||||
));
|
>
|
||||||
})()}
|
<Image src={p.avatar} alt={p.name} fill sizes="32px" className="object-cover" unoptimized />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// /src/app/components/TeamCardComponent.tsx
|
// /src/app/[locale]/components/TeamCardComponent.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, forwardRef } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
import TeamInvitationBanner from './TeamInvitationBanner'
|
import TeamInvitationBanner from './TeamInvitationBanner'
|
||||||
@ -13,16 +13,18 @@ import type { Player, Team } from '../../../types/team'
|
|||||||
import type { Invitation } from '../../../types/invitation'
|
import type { Invitation } from '../../../types/invitation'
|
||||||
import { useSSEStore } from '@/lib/useSSEStore'
|
import { useSSEStore } from '@/lib/useSSEStore'
|
||||||
import { INVITE_EVENTS, TEAM_EVENTS, SELF_EVENTS, isSseEventType } from '@/lib/sseEvents'
|
import { INVITE_EVENTS, TEAM_EVENTS, SELF_EVENTS, isSseEventType } from '@/lib/sseEvents'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
refetchKey?: string
|
refetchKey?: string
|
||||||
initialTeams: Team[]
|
initialTeams: Team[]
|
||||||
initialInvitationMap: Record<string, string> // ⬅️ wieder nur string
|
initialInvitationMap: Record<string, string>
|
||||||
initialInvites?: Invitation[]
|
initialInvites?: Invitation[]
|
||||||
onUpdateInvitation?: (teamId: string, inviteId: string | null) => void
|
onUpdateInvitation?: (teamId: string, inviteId: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- kleine Helper ---------- */
|
/* ---------- kleine Helper ---------- */
|
||||||
|
|
||||||
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
|
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
|
||||||
|
|
||||||
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
||||||
@ -31,6 +33,12 @@ const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TeamLegacyFields = Team & {
|
||||||
|
leaderId?: string
|
||||||
|
logoUpdatedAt?: string | Date
|
||||||
|
updatedAt?: string | Date
|
||||||
|
}
|
||||||
|
|
||||||
const eqTeam = (a?: Team | null, b?: Team | null) => {
|
const eqTeam = (a?: Team | null, b?: Team | null) => {
|
||||||
if (!a && !b) return true
|
if (!a && !b) return true
|
||||||
if (!a || !b) return false
|
if (!a || !b) return false
|
||||||
@ -38,13 +46,12 @@ const eqTeam = (a?: Team | null, b?: Team | null) => {
|
|||||||
if ((a.name ?? '') !== (b.name ?? '')) return false
|
if ((a.name ?? '') !== (b.name ?? '')) return false
|
||||||
if ((a.logo ?? '') !== (b.logo ?? '')) return false
|
if ((a.logo ?? '') !== (b.logo ?? '')) return false
|
||||||
|
|
||||||
const la = a.leader?.steamId ?? (a as any).leaderId ?? null
|
const la = (a.leader?.steamId ?? (a as TeamLegacyFields).leaderId ?? null)
|
||||||
const lb = b.leader?.steamId ?? (b as any).leaderId ?? null
|
const lb = (b.leader?.steamId ?? (b as TeamLegacyFields).leaderId ?? null)
|
||||||
if (la !== lb) return false
|
if (la !== lb) return false
|
||||||
|
|
||||||
// >>> hier neu:
|
const va = (a as TeamLegacyFields).logoUpdatedAt ?? (a as TeamLegacyFields).updatedAt ?? null
|
||||||
const va = (a as any).logoUpdatedAt ?? (a as any).updatedAt ?? null
|
const vb = (b as TeamLegacyFields).logoUpdatedAt ?? (b as TeamLegacyFields).updatedAt ?? null
|
||||||
const vb = (b as any).logoUpdatedAt ?? (b as any).updatedAt ?? null
|
|
||||||
if ((va ? String(va) : '') !== (vb ? String(vb) : '')) return false
|
if ((va ? String(va) : '') !== (vb ? String(vb) : '')) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -59,30 +66,34 @@ const eqInviteList = (a: Invitation[] = [], b: Invitation[] = []) => {
|
|||||||
const B = b.map((x) => x.id).sort().join(',')
|
const B = b.map((x) => x.id).sort().join(',')
|
||||||
return A === B
|
return A === B
|
||||||
}
|
}
|
||||||
const logoVer = (t: Team, vmap: Record<string, number>) =>
|
|
||||||
vmap[t.id] ?? (t as any).logoUpdatedAt ? new Date((t as any).logoUpdatedAt).getTime()
|
|
||||||
: (t as any).updatedAt ? new Date((t as any).updatedAt).getTime()
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
async function loadTeamFull(teamId: string) {
|
const logoVer = (t: Team, vmap: Record<string, number>) => {
|
||||||
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
|
const legacy = t as TeamLegacyFields
|
||||||
if (!res.ok) return null
|
if (vmap[t.id] != null) return vmap[t.id]
|
||||||
return await res.json()
|
if (legacy.logoUpdatedAt) return new Date(legacy.logoUpdatedAt).getTime()
|
||||||
|
if (legacy.updatedAt) return new Date(legacy.updatedAt).getTime()
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTeamFull(teamId: string): Promise<Team | null> {
|
||||||
|
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
|
||||||
|
if (!res.ok) return null
|
||||||
|
return (await res.json()) as Team
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Komponente ---------- */
|
/* ---------- Komponente ---------- */
|
||||||
function TeamCardComponent(
|
export default function TeamCardComponent({
|
||||||
{ initialTeams, initialInvitationMap, initialInvites = [] }: Props,
|
initialTeams,
|
||||||
_ref: any
|
initialInvitationMap,
|
||||||
) {
|
initialInvites = [],
|
||||||
|
}: Props) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const steamId = session?.user?.steamId ?? ''
|
const steamId = ((session?.user as { steamId?: string } | undefined)?.steamId) ?? ''
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
const [initialLoading, setInitialLoading] = useState(true)
|
const [initialLoading, setInitialLoading] = useState(true)
|
||||||
|
|
||||||
const [logoVersionByTeam, setLogoVersionByTeam] = useState<Record<string, number>>({});
|
const [logoVersionByTeam, setLogoVersionByTeam] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
// Alle Teams, in denen ich Mitglied bin (Leader/aktiv/inaktiv)
|
// Alle Teams, in denen ich Mitglied bin (Leader/aktiv/inaktiv)
|
||||||
const [myTeams, setMyTeams] = useState<Team[]>([])
|
const [myTeams, setMyTeams] = useState<Team[]>([])
|
||||||
@ -91,7 +102,7 @@ function TeamCardComponent(
|
|||||||
|
|
||||||
// Einladungen (nur relevant, wenn ich in KEINEM Team bin)
|
// Einladungen (nur relevant, wenn ich in KEINEM Team bin)
|
||||||
const [pendingInvitations, setPendingInvitations] = useState<Invitation[]>(
|
const [pendingInvitations, setPendingInvitations] = useState<Invitation[]>(
|
||||||
initialInvites.filter(i => i.type === 'team-invite' && i.team) as any
|
initialInvites.filter((i): i is Invitation => i.type === 'team-invite' && !!i.team)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Drag/Modals für TeamMemberView
|
// Drag/Modals für TeamMemberView
|
||||||
@ -107,15 +118,18 @@ function TeamCardComponent(
|
|||||||
const softReloadInFlight = useRef(false) // keine Parallel-Reloads
|
const softReloadInFlight = useRef(false) // keine Parallel-Reloads
|
||||||
const lastSoftReloadAt = useRef(0) // einfacher Throttle (ms)
|
const lastSoftReloadAt = useRef(0) // einfacher Throttle (ms)
|
||||||
|
|
||||||
/* ------- User+Teams laden (einmalig) ------- */
|
/* ------- User+Teams laden (einmalig, aber stabil typisiert) ------- */
|
||||||
const loadUserTeams = async () => {
|
const loadUserTeams = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setInitialLoading(true)
|
setInitialLoading(true)
|
||||||
const res = await fetch('/api/user', { cache: 'no-store' })
|
const res = await fetch('/api/user', { cache: 'no-store' })
|
||||||
if (!res.ok) throw new Error('failed /api/user')
|
if (!res.ok) throw new Error('failed /api/user')
|
||||||
const data = await res.json()
|
const data: unknown = await res.json()
|
||||||
|
|
||||||
|
const teams: Team[] = Array.isArray((data as { teams?: unknown }).teams)
|
||||||
|
? ((data as { teams: Team[] }).teams)
|
||||||
|
: []
|
||||||
|
|
||||||
const teams: Team[] = Array.isArray(data?.teams) ? data.teams : []
|
|
||||||
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
|
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
|
||||||
|
|
||||||
// Auto-Auswahl
|
// Auto-Auswahl
|
||||||
@ -132,12 +146,15 @@ function TeamCardComponent(
|
|||||||
} finally {
|
} finally {
|
||||||
setInitialLoading(false)
|
setInitialLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [pendingInvitations.length, selectedTeam])
|
||||||
|
|
||||||
useEffect(() => { loadUserTeams() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => {
|
||||||
|
// einmalig ausführen; loadUserTeams ist memoized (siehe deps)
|
||||||
|
void loadUserTeams()
|
||||||
|
}, [loadUserTeams])
|
||||||
|
|
||||||
/* ------- Gedrosseltes Soft-Reload ------- */
|
/* ------- Gedrosseltes Soft-Reload ------- */
|
||||||
const softReload = async () => {
|
const softReload = useCallback(async () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (softReloadInFlight.current) return
|
if (softReloadInFlight.current) return
|
||||||
if (now - lastSoftReloadAt.current < 500) return // 500ms Cooldown
|
if (now - lastSoftReloadAt.current < 500) return // 500ms Cooldown
|
||||||
@ -147,8 +164,10 @@ function TeamCardComponent(
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/user', { cache: 'no-store' })
|
const res = await fetch('/api/user', { cache: 'no-store' })
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data: unknown = await res.json()
|
||||||
const teams: Team[] = Array.isArray(data?.teams) ? data.teams : []
|
const teams: Team[] = Array.isArray((data as { teams?: unknown }).teams)
|
||||||
|
? ((data as { teams: Team[] }).teams)
|
||||||
|
: []
|
||||||
|
|
||||||
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
|
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
|
||||||
|
|
||||||
@ -157,18 +176,26 @@ function TeamCardComponent(
|
|||||||
|
|
||||||
if (teams.length > 0 && pendingInvitations.length) setPendingInvitations([])
|
if (teams.length > 0 && pendingInvitations.length) setPendingInvitations([])
|
||||||
|
|
||||||
|
// Einladungen nachladen falls kein Team
|
||||||
if (teams.length === 0 && Date.now() - lastInviteCheck.current > 1500) {
|
if (teams.length === 0 && Date.now() - lastInviteCheck.current > 1500) {
|
||||||
lastInviteCheck.current = Date.now()
|
lastInviteCheck.current = Date.now()
|
||||||
const inv = await fetch('/api/user/invitations', { cache: 'no-store' })
|
const inv = await fetch('/api/user/invitations', { cache: 'no-store' })
|
||||||
if (inv.ok) {
|
if (inv.ok) {
|
||||||
const json = await inv.json()
|
const json: unknown = await inv.json()
|
||||||
const all: Invitation[] = (json.invitations ?? [])
|
type RawInv = { id?: string; type?: string; team?: Team }
|
||||||
.filter((i: any) => i.type === 'team-invite' && i.team)
|
const rawList: RawInv[] = Array.isArray((json as { invitations?: unknown }).invitations)
|
||||||
.map((i: any) => ({ id: i.id, team: i.team }))
|
? ((json as { invitations: RawInv[] }).invitations)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const all: Invitation[] = rawList
|
||||||
|
.filter((i): i is Required<Pick<RawInv, 'id' | 'team'>> & RawInv => !!i.id && i.type === 'team-invite' && !!i.team)
|
||||||
|
.map((i) => ({ id: i.id!, type: 'team-invite', team: i.team! }))
|
||||||
|
|
||||||
setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all))
|
setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selektiertes Team vollständig aktualisieren
|
||||||
if (selectedTeam) {
|
if (selectedTeam) {
|
||||||
const full = await loadTeamFull(selectedTeam.id)
|
const full = await loadTeamFull(selectedTeam.id)
|
||||||
if (full) {
|
if (full) {
|
||||||
@ -179,44 +206,48 @@ function TeamCardComponent(
|
|||||||
} finally {
|
} finally {
|
||||||
softReloadInFlight.current = false
|
softReloadInFlight.current = false
|
||||||
}
|
}
|
||||||
}
|
}, [pendingInvitations.length, selectedTeam])
|
||||||
|
|
||||||
/* ------- SSE-gestützte Updates (dedupliziert) ------- */
|
/* ------- SSE-gestützte Updates (dedupliziert) ------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
if (!isSseEventType(lastEvent.type)) return
|
if (!isSseEventType(lastEvent.type)) return
|
||||||
|
|
||||||
const { type, payload } = lastEvent as any
|
const type = lastEvent.type
|
||||||
|
const payload = (lastEvent.payload ?? {}) as Record<string, unknown>
|
||||||
|
|
||||||
// Dedupe-Key (Type + teamId + version + invitationId)
|
// Dedupe-Key (Type + teamId + version + invitationId)
|
||||||
const key = [
|
const key = [
|
||||||
type,
|
type,
|
||||||
payload?.teamId ?? '',
|
(payload.teamId as string | undefined) ?? '',
|
||||||
payload?.version ?? '',
|
String(payload.version ?? ''),
|
||||||
payload?.invitationId ?? ''
|
(payload.invitationId as string | undefined) ?? '',
|
||||||
].join('|')
|
].join('|')
|
||||||
if (key === lastHandledRef.current) return
|
if (key === lastHandledRef.current) return
|
||||||
lastHandledRef.current = key
|
lastHandledRef.current = key
|
||||||
|
|
||||||
// Logo-Event: nur lokal updaten, KEIN /api/user-Reload
|
// Logo-Event: nur lokal updaten, KEIN /api/user-Reload
|
||||||
if (type === 'team-logo-updated' && payload?.teamId) {
|
if (type === 'team-logo-updated' && typeof payload.teamId === 'string') {
|
||||||
// Filename bleibt oft gleich -> trotzdem Teams updaten (ok)
|
const teamId = payload.teamId
|
||||||
if (payload?.filename) {
|
const filename = payload.filename as string | undefined
|
||||||
setMyTeams(prev => prev.map(t => t.id === payload.teamId ? { ...t, logo: payload.filename } : t));
|
const version = payload.version as number | undefined
|
||||||
if (selectedTeam?.id === payload.teamId) {
|
|
||||||
setSelectedTeam(prev => (prev ? { ...prev, logo: payload.filename } : prev));
|
if (filename) {
|
||||||
|
setMyTeams(prev => prev.map(t => t.id === teamId ? { ...t, logo: filename } : t))
|
||||||
|
if (selectedTeam?.id === teamId) {
|
||||||
|
setSelectedTeam(prev => (prev ? { ...prev, logo: filename } : prev))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (payload?.version) {
|
if (typeof version === 'number') {
|
||||||
setLogoVersionByTeam(prev => ({ ...prev, [payload.teamId]: payload.version }));
|
setLogoVersionByTeam(prev => ({ ...prev, [teamId]: version }))
|
||||||
}
|
}
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invite revoked: Liste anpassen, dann gedrosseltes Reload
|
// Invite revoked: Liste anpassen, dann gedrosseltes Reload
|
||||||
if (type === 'team-invite-revoked') {
|
if (type === 'team-invite-revoked') {
|
||||||
const revokedId = payload?.invitationId as string | undefined
|
const revokedId = payload.invitationId as string | undefined
|
||||||
const revokedTeamId = payload?.teamId as string | undefined
|
const revokedTeamId = payload.teamId as string | undefined
|
||||||
if (revokedId || revokedTeamId) {
|
if (revokedId || revokedTeamId) {
|
||||||
setPendingInvitations(prev =>
|
setPendingInvitations(prev =>
|
||||||
prev.filter(i =>
|
prev.filter(i =>
|
||||||
@ -225,31 +256,31 @@ function TeamCardComponent(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
softReload()
|
void softReload()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relevante Gruppen → gedrosseltes Reload
|
// Relevante Gruppen → gedrosseltes Reload
|
||||||
if (SELF_EVENTS.has(type)) { softReload(); return }
|
if (SELF_EVENTS.has(type)) { void softReload(); return }
|
||||||
if (TEAM_EVENTS.has(type)) { softReload(); return }
|
if (TEAM_EVENTS.has(type)) { void softReload(); return }
|
||||||
if (INVITE_EVENTS.has(type) && myTeams.length === 0) { softReload(); return }
|
if (INVITE_EVENTS.has(type) && myTeams.length === 0) { void softReload(); return }
|
||||||
|
}, [lastEvent, myTeams.length, selectedTeam, softReload])
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [lastEvent, myTeams.length]) // bewusst schlanke Dependencies
|
|
||||||
|
|
||||||
|
// wenn selectedTeam nur aus der /api/user-Quelle kommt (ohne invitedPlayers), einmalig vollständig laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedTeam) return
|
if (!selectedTeam) return
|
||||||
if (Array.isArray(selectedTeam.invitedPlayers)) return // schon voll
|
if (Array.isArray(selectedTeam.invitedPlayers)) return
|
||||||
|
|
||||||
(async () => {
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
const full = await loadTeamFull(selectedTeam.id)
|
const full = await loadTeamFull(selectedTeam.id)
|
||||||
if (!full) return
|
if (!full || cancelled) return
|
||||||
// in myTeams ersetzen …
|
|
||||||
setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t))
|
setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t))
|
||||||
// … und als selectedTeam setzen
|
|
||||||
setSelectedTeam(full)
|
setSelectedTeam(full)
|
||||||
})()
|
})()
|
||||||
}, [selectedTeam?.id])
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [selectedTeam])
|
||||||
|
|
||||||
/* ------- Render-Zweige ------- */
|
/* ------- Render-Zweige ------- */
|
||||||
|
|
||||||
@ -343,7 +374,12 @@ function TeamCardComponent(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{myTeams.map(team => (
|
{myTeams.map(team => {
|
||||||
|
const v = logoVer(team, logoVersionByTeam)
|
||||||
|
const src = team.logo
|
||||||
|
? `/assets/img/logos/${team.logo}${v ? `?v=${v}` : ''}`
|
||||||
|
: `/assets/img/logos/cs2.webp`
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={team.id}
|
key={team.id}
|
||||||
type="button"
|
type="button"
|
||||||
@ -357,16 +393,17 @@ function TeamCardComponent(
|
|||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img
|
<div className="relative h-12 w-12 overflow-hidden rounded-full border border-gray-200 dark:border-neutral-600">
|
||||||
key={`${team.logo ?? 'fallback'}-${logoVer(team, logoVersionByTeam)}`}
|
<Image
|
||||||
src={
|
key={`${team.logo ?? 'fallback'}-${v}`}
|
||||||
team.logo
|
src={src}
|
||||||
? `/assets/img/logos/${team.logo}${logoVer(team, logoVersionByTeam) ? `?v=${logoVer(team, logoVersionByTeam)}` : ''}`
|
|
||||||
: `/assets/img/logos/cs2.webp`
|
|
||||||
}
|
|
||||||
alt={team.name ?? 'Teamlogo'}
|
alt={team.name ?? 'Teamlogo'}
|
||||||
className="h-12 w-12 rounded-full border object-cover border-gray-200 dark:border-neutral-600"
|
fill
|
||||||
|
sizes="48px"
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="truncate font-medium text-gray-800 dark:text-neutral-200">
|
<span className="truncate font-medium text-gray-800 dark:text-neutral-200">
|
||||||
{team.name ?? 'Team'}
|
{team.name ?? 'Team'}
|
||||||
@ -376,25 +413,35 @@ function TeamCardComponent(
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24">
|
<svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24" aria-hidden>
|
||||||
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
|
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex -space-x-3">
|
<div className="flex -space-x-3">
|
||||||
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].slice(0, 6).map(p => (
|
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]
|
||||||
<img
|
.slice(0, 6)
|
||||||
|
.map(p => (
|
||||||
|
<div
|
||||||
key={p.steamId}
|
key={p.steamId}
|
||||||
|
className="relative h-8 w-8 overflow-hidden rounded-full border-2 border-white dark:border-neutral-800"
|
||||||
|
title={p.name}
|
||||||
|
aria-label={p.name}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
src={p.avatar}
|
src={p.avatar}
|
||||||
alt={p.name}
|
alt={p.name}
|
||||||
title={p.name}
|
fill
|
||||||
className="h-8 w-8 rounded-full border-2 border-white
|
sizes="32px"
|
||||||
dark:border-neutral-800 object-cover"
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -447,5 +494,3 @@ function TeamCardComponent(
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default forwardRef(TeamCardComponent)
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
// TeamInvitationBanner.tsx
|
// /src/app/[locale]/components/TeamInvitationBanner.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||||||
import type { Invitation } from '../../../types/invitation'
|
import type { Invitation } from '../../../types/invitation'
|
||||||
@ -45,7 +46,6 @@ export default function TeamInvitationBanner({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Klassen als Ausdruck (keine mehrzeiligen String-Literals im JSX)
|
|
||||||
const cardClasses =
|
const cardClasses =
|
||||||
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
|
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
|
||||||
'dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer ' +
|
'dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer ' +
|
||||||
@ -59,18 +59,21 @@ export default function TeamInvitationBanner({
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
|
onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
|
||||||
className={cardClasses}
|
className={cardClasses}
|
||||||
>
|
>
|
||||||
{/* animierter, dezenter grüner Gradient */}
|
|
||||||
<div aria-hidden className="absolute inset-0 z-0 pointer-events-none invitationGradient" />
|
<div aria-hidden className="absolute inset-0 z-0 pointer-events-none invitationGradient" />
|
||||||
|
|
||||||
{/* Inhalt */}
|
|
||||||
<div className="relative z-[1] p-4">
|
<div className="relative z-[1] p-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img
|
{/* Teamlogo */}
|
||||||
|
<Image
|
||||||
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
alt={team.name ?? 'Teamlogo'}
|
alt={team.name ?? 'Teamlogo'}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
|
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
|
||||||
|
priority={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
|
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
|
||||||
@ -86,11 +89,16 @@ export default function TeamInvitationBanner({
|
|||||||
{/* Teammitglieder */}
|
{/* Teammitglieder */}
|
||||||
<div className="flex -space-x-3">
|
<div className="flex -space-x-3">
|
||||||
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].map((p) => (
|
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].map((p) => (
|
||||||
<img
|
<Image
|
||||||
key={p.steamId}
|
key={p.steamId}
|
||||||
src={p.avatar}
|
src={p.avatar}
|
||||||
alt={p.name}
|
alt={p.name}
|
||||||
title={p.name}
|
title={p.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
// Falls Avatare externe URLs sind, ist unoptimized sicher;
|
||||||
|
// alternativ remotePatterns in next.config.js setzen.
|
||||||
|
unoptimized
|
||||||
className="w-12 h-12 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
|
className="w-12 h-12 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -144,7 +152,6 @@ export default function TeamInvitationBanner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
/* Hintergrund-Schimmer (läuft permanent) */
|
|
||||||
@keyframes slide-x {
|
@keyframes slide-x {
|
||||||
from { background-position-x: 0%; }
|
from { background-position-x: 0%; }
|
||||||
to { background-position-x: 200%; }
|
to { background-position-x: 200%; }
|
||||||
@ -158,7 +165,7 @@ export default function TeamInvitationBanner({
|
|||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
background-repeat: repeat-x;
|
background-repeat: repeat-x;
|
||||||
animation: slide-x 6s linear infinite; /* etwas ruhiger */
|
animation: slide-x 6s linear infinite;
|
||||||
}
|
}
|
||||||
:global(.dark) .invitationGradient {
|
:global(.dark) .invitationGradient {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
@ -168,18 +175,13 @@ export default function TeamInvitationBanner({
|
|||||||
rgba(16,168,54,0.28) 100%
|
rgba(16,168,54,0.28) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shine-Sweep nur auf Hover */
|
|
||||||
@keyframes shine {
|
@keyframes shine {
|
||||||
0% { transform: translateX(-120%) skewX(-20deg); opacity: 0; }
|
0% { transform: translateX(-120%) skewX(-20deg); opacity: 0; }
|
||||||
10% { opacity: .7; }
|
10% { opacity: .7; }
|
||||||
27% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
|
27% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
|
||||||
100% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
|
100% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
|
||||||
}
|
}
|
||||||
.shine {
|
.shine { position: absolute; inset: 0; }
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
.shine::before {
|
.shine::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -194,12 +196,9 @@ export default function TeamInvitationBanner({
|
|||||||
transform: translateX(-120%) skewX(-20deg);
|
transform: translateX(-120%) skewX(-20deg);
|
||||||
transition: opacity .2s;
|
transition: opacity .2s;
|
||||||
}
|
}
|
||||||
/* nur wenn die Karte offen ist und gehovert wird */
|
|
||||||
:global(.group:hover) .shine::before {
|
:global(.group:hover) .shine::before {
|
||||||
animation: shine 3.8s ease-out infinite;
|
animation: shine 3.8s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Respektiere Bewegungs-Präferenzen */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.invitationGradient { animation: none; }
|
.invitationGradient { animation: none; }
|
||||||
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }
|
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
|
|
||||||
// /src/app/[locale]/components/TeamMemberView.tsx
|
// /src/app/[locale]/components/TeamMemberView.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
DragOverlay,
|
||||||
|
type DragStartEvent,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
import { DroppableZone } from './DroppableZone'
|
import { DroppableZone } from './DroppableZone'
|
||||||
import MiniCard from './MiniCard'
|
import MiniCard from './MiniCard'
|
||||||
@ -43,16 +47,10 @@ type Props = {
|
|||||||
adminMode?: boolean
|
adminMode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper-Komponente:
|
|
||||||
* - spiegelt optionales team-Prop in den Store
|
|
||||||
* - rendert Body erst, wenn team + Berechtigungen vorhanden sind
|
|
||||||
* Dadurch bleiben Hooks-Reihenfolgen stabil.
|
|
||||||
*/
|
|
||||||
export default function TeamMemberView(props: Props) {
|
export default function TeamMemberView(props: Props) {
|
||||||
const { team: storeTeam, setTeam } = useTeamStore()
|
const { team: storeTeam, setTeam } = useTeamStore()
|
||||||
|
|
||||||
// Prop -> Store spiegeln: auch bei gleicher ID relevante Felder patchen
|
// Prop -> Store spiegeln
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.team) return
|
if (!props.team) return
|
||||||
const curr = useTeamStore.getState().team
|
const curr = useTeamStore.getState().team
|
||||||
@ -67,26 +65,19 @@ export default function TeamMemberView(props: Props) {
|
|||||||
if (curr.logo !== next.logo) diff.logo = next.logo
|
if (curr.logo !== next.logo) diff.logo = next.logo
|
||||||
if ((curr.leader?.steamId ?? null) !== (next.leader?.steamId ?? null)) diff.leader = next.leader
|
if ((curr.leader?.steamId ?? null) !== (next.leader?.steamId ?? null)) diff.leader = next.leader
|
||||||
if (typeof next.joinPolicy === 'string' && curr.joinPolicy !== next.joinPolicy) {
|
if (typeof next.joinPolicy === 'string' && curr.joinPolicy !== next.joinPolicy) {
|
||||||
diff.joinPolicy = next.joinPolicy as any
|
diff.joinPolicy = next.joinPolicy as TeamJoinPolicy
|
||||||
}
|
}
|
||||||
if (Object.keys(diff).length) setTeam({ ...curr, ...diff } as Team)
|
if (Object.keys(diff).length) setTeam({ ...curr, ...diff } as Team)
|
||||||
}, [props.team, setTeam])
|
}, [props.team, setTeam])
|
||||||
|
|
||||||
// Guards dürfen im Wrapper stehen (kein Hook darunter bricht ab)
|
|
||||||
if (!props.adminMode && !props.currentUserSteamId) return null
|
if (!props.adminMode && !props.currentUserSteamId) return null
|
||||||
|
|
||||||
const team = props.team ?? storeTeam ?? null
|
const team = props.team ?? storeTeam ?? null
|
||||||
if (!team) return null
|
if (!team) return null
|
||||||
|
|
||||||
// Ab hier nur noch Body rendern – dort gibt es keine frühen Returns mehr vor Hooks
|
|
||||||
return <TeamMemberViewBody {...props} team={team} />
|
return <TeamMemberViewBody {...props} team={team} />
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Body-Komponente:
|
|
||||||
* - enthält ALLE übrigen Hooks in fester Reihenfolge
|
|
||||||
* - hier ist team garantiert vorhanden (nicht null)
|
|
||||||
*/
|
|
||||||
function TeamMemberViewBody({
|
function TeamMemberViewBody({
|
||||||
team,
|
team,
|
||||||
activeDragItem,
|
activeDragItem,
|
||||||
@ -105,7 +96,11 @@ function TeamMemberViewBody({
|
|||||||
const teamId = team.id
|
const teamId = team.id
|
||||||
const teamLeaderSteamId = team.leader?.steamId ?? ''
|
const teamLeaderSteamId = team.leader?.steamId ?? ''
|
||||||
|
|
||||||
const RELEVANT: ReadonlySet<SSEEventType> = new Set([...TEAM_EVENTS, ...SELF_EVENTS])
|
// stabile Menge für useEffect-Deps
|
||||||
|
const RELEVANT = useMemo<ReadonlySet<SSEEventType>>(
|
||||||
|
() => new Set([...TEAM_EVENTS, ...SELF_EVENTS]),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const isLeader = currentUserSteamId === team.leader?.steamId
|
const isLeader = currentUserSteamId === team.leader?.steamId
|
||||||
const canManage = adminMode || isLeader
|
const canManage = adminMode || isLeader
|
||||||
@ -139,18 +134,20 @@ function TeamMemberViewBody({
|
|||||||
|
|
||||||
const [inviteKey, setInviteKey] = useState(0)
|
const [inviteKey, setInviteKey] = useState(0)
|
||||||
const openInvite = () => {
|
const openInvite = () => {
|
||||||
setInviteKey(k => k + 1) // erzwingt frischen Mount
|
setInviteKey(k => k + 1)
|
||||||
setShowInviteModal(true)
|
setShowInviteModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache-Busting fürs Logo
|
// Cache-Busting fürs Logo (ohne any)
|
||||||
|
type TeamWithStamp = Team & { logoUpdatedAt?: string | Date; updatedAt?: string | Date }
|
||||||
|
const tStamped = team as TeamWithStamp
|
||||||
const initialLogoVersion =
|
const initialLogoVersion =
|
||||||
(team as any).logoUpdatedAt
|
tStamped.logoUpdatedAt
|
||||||
? new Date((team as any).logoUpdatedAt).getTime()
|
? new Date(tStamped.logoUpdatedAt).getTime()
|
||||||
: (team as any).updatedAt
|
: tStamped.updatedAt
|
||||||
? new Date((team as any).updatedAt).getTime()
|
? new Date(tStamped.updatedAt).getTime()
|
||||||
: 0;
|
: 0
|
||||||
const [logoVersion, setLogoVersion] = useState<number | null>(initialLogoVersion);
|
const [logoVersion, setLogoVersion] = useState<number | null>(initialLogoVersion)
|
||||||
|
|
||||||
// Upload-Progress
|
// Upload-Progress
|
||||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
|
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
|
||||||
@ -184,7 +181,6 @@ function TeamMemberViewBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Nur setzen, wenn der Server wirklich einen Wert liefert.
|
|
||||||
if (typeof team.joinPolicy === 'string') {
|
if (typeof team.joinPolicy === 'string') {
|
||||||
setJoinPolicy(team.joinPolicy as TeamJoinPolicy)
|
setJoinPolicy(team.joinPolicy as TeamJoinPolicy)
|
||||||
}
|
}
|
||||||
@ -218,17 +214,10 @@ function TeamMemberViewBody({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent || !team.id) return
|
if (!lastEvent || !team.id) return
|
||||||
if (!isSseEventType(lastEvent.type)) return
|
if (!isSseEventType(lastEvent.type)) return
|
||||||
const payload = lastEvent.payload ?? {}
|
const payload = (lastEvent.payload ?? {}) as Record<string, unknown>
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
// nur joinPolicy geändert → minimal patchen
|
// Nach lokalem Speichern: kurzes Ignore-Fenster
|
||||||
if (lastEvent.type === 'team-updated' && payload.teamId === team.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Nach lokalem Speichern kommt oft ein generisches team-updated ohne joinPolicy.
|
|
||||||
// Das würde ein Reload triggern → 1x kurz ignorieren.
|
|
||||||
if (lastEvent.type === 'team-updated' && payload.teamId === team.id) {
|
if (lastEvent.type === 'team-updated' && payload.teamId === team.id) {
|
||||||
if (policyChangedAtRef.current && (now - policyChangedAtRef.current) < 2000) {
|
if (policyChangedAtRef.current && (now - policyChangedAtRef.current) < 2000) {
|
||||||
policyChangedAtRef.current = null
|
policyChangedAtRef.current = null
|
||||||
@ -238,16 +227,20 @@ function TeamMemberViewBody({
|
|||||||
|
|
||||||
// nur Logo geändert → minimal patchen
|
// nur Logo geändert → minimal patchen
|
||||||
if (lastEvent.type === 'team-logo-updated') {
|
if (lastEvent.type === 'team-logo-updated') {
|
||||||
if (payload.teamId && payload.teamId !== team.id) return
|
|
||||||
const curr = useTeamStore.getState().team
|
const curr = useTeamStore.getState().team
|
||||||
if (payload?.filename && curr) setTeam({ ...curr, logo: payload.filename })
|
const filename = (payload as { filename?: string }).filename
|
||||||
if (payload?.version) setLogoVersion(payload.version)
|
const version = (payload as { version?: number }).version
|
||||||
|
const evTeamId = (payload as { teamId?: string }).teamId
|
||||||
|
if (evTeamId && evTeamId !== team.id) return
|
||||||
|
if (filename && curr) setTeam({ ...curr, logo: filename })
|
||||||
|
if (typeof version === 'number') setLogoVersion(version)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rest: reload + remount NUR wenn Listen wirklich anders sind
|
// Rest: reload wenn relevant
|
||||||
if (!RELEVANT.has(lastEvent.type)) return
|
if (!RELEVANT.has(lastEvent.type)) return
|
||||||
if (payload.teamId && payload.teamId !== team.id) return
|
const evTeamId = (payload as { teamId?: string }).teamId
|
||||||
|
if (evTeamId && evTeamId !== team.id) return
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const updated = await reloadTeam(team.id)
|
const updated = await reloadTeam(team.id)
|
||||||
@ -256,8 +249,8 @@ function TeamMemberViewBody({
|
|||||||
setTeam(updated)
|
setTeam(updated)
|
||||||
setEditedName(updated.name || '')
|
setEditedName(updated.name || '')
|
||||||
|
|
||||||
if (typeof (updated as any).joinPolicy === 'string') {
|
if (typeof updated.joinPolicy === 'string') {
|
||||||
setJoinPolicy((updated as any).joinPolicy as TeamJoinPolicy)
|
setJoinPolicy(updated.joinPolicy as TeamJoinPolicy)
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||||
@ -270,35 +263,31 @@ function TeamMemberViewBody({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Set-Vergleich (Inhalt)
|
|
||||||
const contentChanged =
|
const contentChanged =
|
||||||
!eqSetByIds(activePlayers, nextActive) ||
|
!eqSetByIds(activePlayers, nextActive) ||
|
||||||
!eqSetByIds(inactivePlayers, nextInactive) ||
|
!eqSetByIds(inactivePlayers, nextInactive) ||
|
||||||
!eqSetByIds(invitedPlayers, nextInvited)
|
!eqSetByIds(invitedPlayers, nextInvited)
|
||||||
|
|
||||||
// 2) Reihenfolge-Vergleich (nur Order)
|
|
||||||
const orderChanged =
|
const orderChanged =
|
||||||
!eqByIds(activePlayers, nextActive) ||
|
!eqByIds(activePlayers, nextActive) ||
|
||||||
!eqByIds(inactivePlayers, nextInactive) ||
|
!eqByIds(inactivePlayers, nextInactive) ||
|
||||||
!eqByIds(invitedPlayers, nextInvited)
|
!eqByIds(invitedPlayers, nextInvited)
|
||||||
|
|
||||||
if (contentChanged) {
|
if (contentChanged) {
|
||||||
// IDs haben sich geändert → Listen setzen + DnD remounten (Keys bleiben!)
|
|
||||||
setActivePlayers(nextActive)
|
setActivePlayers(nextActive)
|
||||||
setInactivePlayers(nextInactive)
|
setInactivePlayers(nextInactive)
|
||||||
setInvitedPlayers(nextInvited)
|
setInvitedPlayers(nextInvited)
|
||||||
setRemountKey(k => k + 1)
|
setRemountKey(k => k + 1)
|
||||||
} else if (orderChanged) {
|
} else if (orderChanged) {
|
||||||
// Nur Reihenfolge/Sichtung anders → Listen setzen, aber KEIN remount
|
|
||||||
setActivePlayers(nextActive)
|
setActivePlayers(nextActive)
|
||||||
setInactivePlayers(nextInactive)
|
setInactivePlayers(nextInactive)
|
||||||
setInvitedPlayers(nextInvited)
|
setInvitedPlayers(nextInvited)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [lastEvent, team.id, setTeam, activePlayers, inactivePlayers, invitedPlayers])
|
}, [RELEVANT, lastEvent, team.id, setTeam, activePlayers, inactivePlayers, invitedPlayers])
|
||||||
|
|
||||||
const handleDragStart = (event: any) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
const id = event.active.id as string
|
const id = String(event.active.id)
|
||||||
const item =
|
const item =
|
||||||
activePlayers.find(p => p.steamId === id) ||
|
activePlayers.find(p => p.steamId === id) ||
|
||||||
inactivePlayers.find(p => p.steamId === id)
|
inactivePlayers.find(p => p.steamId === id)
|
||||||
@ -314,8 +303,8 @@ function TeamMemberViewBody({
|
|||||||
|
|
||||||
const applyPolicy = async (p: TeamJoinPolicy) => {
|
const applyPolicy = async (p: TeamJoinPolicy) => {
|
||||||
if (p === joinPolicy) { setShowPolicyMenu(false); return }
|
if (p === joinPolicy) { setShowPolicyMenu(false); return }
|
||||||
setJoinPolicy(p) // optimistisch
|
setJoinPolicy(p)
|
||||||
await saveJoinPolicy(p) // serverseitig speichern
|
await saveJoinPolicy(p)
|
||||||
setShowPolicyMenu(false)
|
setShowPolicyMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,7 +314,6 @@ function TeamMemberViewBody({
|
|||||||
const onOutside = (e: PointerEvent) => {
|
const onOutside = (e: PointerEvent) => {
|
||||||
if (!policyMenuRef.current) return
|
if (!policyMenuRef.current) return
|
||||||
if (!policyMenuRef.current.contains(e.target as Node)) {
|
if (!policyMenuRef.current.contains(e.target as Node)) {
|
||||||
// Klick außerhalb: Menü schließen + Navigation/Drag verhindern
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setShowPolicyMenu(false)
|
setShowPolicyMenu(false)
|
||||||
@ -335,7 +323,6 @@ function TeamMemberViewBody({
|
|||||||
if (e.key === 'Escape') setShowPolicyMenu(false)
|
if (e.key === 'Escape') setShowPolicyMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture-Phase, damit wir VOR Links/Drag reagieren
|
|
||||||
document.addEventListener('pointerdown', onOutside, { capture: true })
|
document.addEventListener('pointerdown', onOutside, { capture: true })
|
||||||
document.addEventListener('keydown', onEsc)
|
document.addEventListener('keydown', onEsc)
|
||||||
|
|
||||||
@ -345,28 +332,27 @@ function TeamMemberViewBody({
|
|||||||
}
|
}
|
||||||
}, [showPolicyMenu])
|
}, [showPolicyMenu])
|
||||||
|
|
||||||
|
const updateTeamMembers = async (tId: string, active: Player[], inactive: Player[]) => {
|
||||||
const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/team/update-players', {
|
const res = await fetch('/api/team/update-players', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
teamId,
|
teamId: tId,
|
||||||
activePlayers: active.map(p => p.steamId),
|
activePlayers: active.map(p => p.steamId),
|
||||||
inactivePlayers: inactive.map(p => p.steamId),
|
inactivePlayers: inactive.map(p => p.steamId),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Update fehlgeschlagen')
|
if (!res.ok) throw new Error('Update fehlgeschlagen')
|
||||||
|
|
||||||
const updated = await reloadTeam(teamId)
|
const updated = await reloadTeam(tId)
|
||||||
if (updated) setTeam(updated)
|
if (updated) setTeam(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Aktualisieren:', err)
|
console.error('Fehler beim Aktualisieren:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnd = async (event: any) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
setActiveDragItem(null)
|
setActiveDragItem(null)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
isDraggingRef.current = false
|
isDraggingRef.current = false
|
||||||
@ -482,8 +468,8 @@ function TeamMemberViewBody({
|
|||||||
body: JSON.stringify({ teamId, newLeaderSteamId: newLeaderId }),
|
body: JSON.stringify({ teamId, newLeaderSteamId: newLeaderId }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data: unknown = await res.json().catch(() => ({}))
|
||||||
console.error('Fehler bei Leader-Übertragung:', data.message)
|
console.error('Fehler bei Leader-Übertragung:', (data as { message?: string }).message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await handleReload()
|
await handleReload()
|
||||||
@ -493,11 +479,11 @@ function TeamMemberViewBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DownscaleOpts = {
|
type DownscaleOpts = {
|
||||||
size?: number; // Zielkante (px)
|
size?: number
|
||||||
quality?: number; // 0..1
|
quality?: number
|
||||||
mime?: string; // Wunschformat, default 'image/webp'
|
mime?: string
|
||||||
square?: boolean; // center-crop auf Quadrat
|
square?: boolean
|
||||||
};
|
}
|
||||||
|
|
||||||
async function saveJoinPolicy(next: TeamJoinPolicy = joinPolicy) {
|
async function saveJoinPolicy(next: TeamJoinPolicy = joinPolicy) {
|
||||||
const prev = joinPolicy
|
const prev = joinPolicy
|
||||||
@ -509,20 +495,21 @@ function TeamMemberViewBody({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
body: JSON.stringify({ teamId, joinPolicy: next }), // teamId aus dem Body-Scope
|
body: JSON.stringify({ teamId, joinPolicy: next }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data: unknown = await res.json().catch(() => ({}))
|
||||||
throw new Error(data?.message ?? `Speichern fehlgeschlagen (${res.status})`)
|
const msg = (data as { message?: string }).message
|
||||||
|
throw new Error(msg ?? `Speichern fehlgeschlagen (${res.status})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { joinPolicy: serverPolicy } = await res.json().catch(() => ({}))
|
const parsed: unknown = await res.json().catch(() => ({}))
|
||||||
|
const serverPolicy = (parsed as { joinPolicy?: TeamJoinPolicy }).joinPolicy
|
||||||
const patched = (serverPolicy ?? next) as TeamJoinPolicy
|
const patched = (serverPolicy ?? next) as TeamJoinPolicy
|
||||||
|
|
||||||
setJoinPolicy(patched)
|
setJoinPolicy(patched)
|
||||||
|
|
||||||
// Store patchen
|
|
||||||
const curr = useTeamStore.getState().team
|
const curr = useTeamStore.getState().team
|
||||||
if (curr && curr.id === teamId && curr.joinPolicy !== patched) {
|
if (curr && curr.id === teamId && curr.joinPolicy !== patched) {
|
||||||
setTeam({ ...curr, joinPolicy: patched })
|
setTeam({ ...curr, joinPolicy: patched })
|
||||||
@ -543,18 +530,17 @@ function TeamMemberViewBody({
|
|||||||
|
|
||||||
async function canEncode(mime: string): Promise<boolean> {
|
async function canEncode(mime: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// OffscreenCanvas hat die zuverlässigste Blob-API
|
|
||||||
if ('OffscreenCanvas' in window) {
|
if ('OffscreenCanvas' in window) {
|
||||||
const c = new OffscreenCanvas(2, 2);
|
const c = new OffscreenCanvas(2, 2)
|
||||||
const b = await (c as any).convertToBlob?.({ type: mime, quality: 0.8 });
|
const b = await c.convertToBlob({ type: mime, quality: 0.8 })
|
||||||
return !!b;
|
return !!b
|
||||||
}
|
}
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas')
|
||||||
c.width = 2; c.height = 2;
|
c.width = 2; c.height = 2
|
||||||
const url = c.toDataURL(mime);
|
const url = c.toDataURL(mime)
|
||||||
return typeof url === 'string' && url.startsWith(`data:${mime}`);
|
return typeof url === 'string' && url.startsWith(`data:${mime}`)
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -564,104 +550,93 @@ function TeamMemberViewBody({
|
|||||||
quality = 0.85,
|
quality = 0.85,
|
||||||
mime: wantedMime = 'image/webp',
|
mime: wantedMime = 'image/webp',
|
||||||
square = true,
|
square = true,
|
||||||
} = opts;
|
} = opts
|
||||||
|
|
||||||
// 1) Bild laden (ImageBitmap bevorzugt)
|
// 1) Bild laden (ImageBitmap bevorzugt, ohne any)
|
||||||
let url: string | null = null;
|
let url: string | null = null
|
||||||
let img: ImageBitmap | HTMLImageElement;
|
let img: ImageBitmap | HTMLImageElement
|
||||||
|
|
||||||
const useBitmap = 'createImageBitmap' in window;
|
|
||||||
if (useBitmap) {
|
|
||||||
try {
|
try {
|
||||||
img = await (createImageBitmap as any)(file, { imageOrientation: 'from-image' });
|
img = await createImageBitmap(file)
|
||||||
} catch {
|
} catch {
|
||||||
url = URL.createObjectURL(file);
|
url = URL.createObjectURL(file)
|
||||||
img = await new Promise<HTMLImageElement>((res, rej) => {
|
img = await new Promise<HTMLImageElement>((res, rej) => {
|
||||||
const im = new window.Image();
|
const im = new window.Image()
|
||||||
im.onload = () => res(im);
|
im.onload = () => res(im)
|
||||||
im.onerror = rej;
|
im.onerror = rej
|
||||||
im.src = url!;
|
im.src = url!
|
||||||
});
|
})
|
||||||
}
|
|
||||||
} else {
|
|
||||||
url = URL.createObjectURL(file);
|
|
||||||
img = await new Promise<HTMLImageElement>((res, rej) => {
|
|
||||||
const im = new window.Image();
|
|
||||||
im.onload = () => res(im);
|
|
||||||
im.onerror = rej;
|
|
||||||
im.src = url!;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const srcW = (img as any).width as number;
|
const dims = img as unknown as { width: number; height: number }
|
||||||
const srcH = (img as any).height as number;
|
const srcW = dims.width
|
||||||
|
const srcH = dims.height
|
||||||
if (!srcW || !srcH) {
|
if (!srcW || !srcH) {
|
||||||
if (url) URL.revokeObjectURL(url);
|
if (url) URL.revokeObjectURL(url)
|
||||||
if ('close' in (img as any)) try { (img as ImageBitmap).close(); } catch {}
|
if ('close' in (img as ImageBitmap)) try { (img as ImageBitmap).close() } catch {}
|
||||||
throw new Error('Invalid image dimensions');
|
throw new Error('Invalid image dimensions')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Zielgröße + optionaler Center-Crop
|
// 2) Zielgröße + optionaler Center-Crop
|
||||||
let sx = 0, sy = 0, sw = srcW, sh = srcH;
|
let sx = 0, sy = 0, sw = srcW, sh = srcH
|
||||||
if (square) {
|
if (square) {
|
||||||
const side = Math.min(srcW, srcH);
|
const side = Math.min(srcW, srcH)
|
||||||
sx = Math.max(0, Math.floor((srcW - side) / 2));
|
sx = Math.max(0, Math.floor((srcW - side) / 2))
|
||||||
sy = Math.max(0, Math.floor((srcH - side) / 2));
|
sy = Math.max(0, Math.floor((srcH - side) / 2))
|
||||||
sw = side; sh = side;
|
sw = side; sh = side
|
||||||
}
|
}
|
||||||
const scale = Math.min(size / sw, size / sh, 1);
|
const scale = Math.min(size / sw, size / sh, 1)
|
||||||
const dw = Math.max(1, Math.round(sw * scale));
|
const dw = Math.max(1, Math.round(sw * scale))
|
||||||
const dh = Math.max(1, Math.round(sh * scale));
|
const dh = Math.max(1, Math.round(sh * scale))
|
||||||
|
|
||||||
// 3) Canvas wählen (Offscreen bevorzugt)
|
// 3) Canvas (Offscreen bevorzugt)
|
||||||
const offscreen = 'OffscreenCanvas' in window;
|
const source = img as unknown as CanvasImageSource
|
||||||
let blob: Blob | null = null;
|
const offscreen = 'OffscreenCanvas' in window
|
||||||
|
let blob: Blob | null = null
|
||||||
|
|
||||||
if (offscreen) {
|
if (offscreen) {
|
||||||
const c = new OffscreenCanvas(dw, dh);
|
const c = new OffscreenCanvas(dw, dh)
|
||||||
const ctx = c.getContext('2d', { alpha: true })!;
|
const ctx = c.getContext('2d', { alpha: true })!
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high'
|
||||||
ctx.drawImage(img as any, sx, sy, sw, sh, 0, 0, dw, dh);
|
ctx.drawImage(source, sx, sy, sw, sh, 0, 0, dw, dh)
|
||||||
|
|
||||||
// 4) Format mit Fallbacks
|
// 4) Format mit Fallbacks
|
||||||
const canWebp = await canEncode('image/webp');
|
const canWebp = await canEncode('image/webp')
|
||||||
const canJpeg = await canEncode('image/jpeg');
|
const canJpeg = await canEncode('image/jpeg')
|
||||||
const targetMime =
|
const targetMime =
|
||||||
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
|
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
|
||||||
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
|
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
|
||||||
canWebp ? 'image/webp' :
|
canWebp ? 'image/webp' :
|
||||||
canJpeg ? 'image/jpeg' : 'image/png';
|
canJpeg ? 'image/jpeg' : 'image/png'
|
||||||
|
|
||||||
blob = await (c as any).convertToBlob({ type: targetMime, quality: targetMime === 'image/png' ? undefined : quality });
|
blob = await c.convertToBlob({ type: targetMime, quality: targetMime === 'image/png' ? undefined : quality })
|
||||||
} else {
|
} else {
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas')
|
||||||
c.width = dw; c.height = dh;
|
c.width = dw; c.height = dh
|
||||||
const ctx = c.getContext('2d')!;
|
const ctx = c.getContext('2d')!
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high'
|
||||||
ctx.drawImage(img as any, sx, sy, sw, sh, 0, 0, dw, dh);
|
ctx.drawImage(source, sx, sy, sw, sh, 0, 0, dw, dh)
|
||||||
|
|
||||||
const canWebp = await canEncode('image/webp');
|
const canWebp = await canEncode('image/webp')
|
||||||
const canJpeg = await canEncode('image/jpeg');
|
const canJpeg = await canEncode('image/jpeg')
|
||||||
const targetMime =
|
const targetMime =
|
||||||
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
|
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
|
||||||
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
|
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
|
||||||
canWebp ? 'image/webp' :
|
canWebp ? 'image/webp' :
|
||||||
canJpeg ? 'image/jpeg' : 'image/png';
|
canJpeg ? 'image/jpeg' : 'image/png'
|
||||||
|
|
||||||
blob = await new Promise<Blob | null>((res) =>
|
blob = await new Promise<Blob | null>((res) =>
|
||||||
c.toBlob(b => res(b), targetMime, targetMime === 'image/png' ? undefined : quality)
|
c.toBlob(b => res(b), targetMime, targetMime === 'image/png' ? undefined : quality)
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
if (url) URL.revokeObjectURL(url)
|
||||||
if (url) URL.revokeObjectURL(url);
|
if ('close' in (img as ImageBitmap)) { try { (img as ImageBitmap).close() } catch {} }
|
||||||
if ('close' in (img as any)) { try { (img as ImageBitmap).close(); } catch {} }
|
|
||||||
|
|
||||||
if (!blob) throw new Error('Canvas encoding failed (toBlob returned null)');
|
if (!blob) throw new Error('Canvas encoding failed (toBlob returned null)')
|
||||||
return blob;
|
return blob
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload mit Progress via XHR – setzt filename/version direkt, kein Reload nötig
|
|
||||||
async function uploadTeamLogo(file: File) {
|
async function uploadTeamLogo(file: File) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
@ -677,10 +652,12 @@ function TeamMemberViewBody({
|
|||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(xhr.responseText)
|
const json: unknown = JSON.parse(xhr.responseText)
|
||||||
const current = useTeamStore.getState().team
|
const current = useTeamStore.getState().team
|
||||||
if (json?.filename && current) setTeam({ ...current, logo: json.filename })
|
const filename = (json as { filename?: string }).filename
|
||||||
if (json?.version) setLogoVersion(json.version)
|
const version = (json as { version?: number }).version
|
||||||
|
if (filename && current) setTeam({ ...current, logo: filename })
|
||||||
|
if (typeof version === 'number') setLogoVersion(version)
|
||||||
} catch {}
|
} catch {}
|
||||||
resolve()
|
resolve()
|
||||||
} else {
|
} else {
|
||||||
@ -709,7 +686,6 @@ function TeamMemberViewBody({
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/profile/${player.steamId}`}
|
href={`/profile/${player.steamId}`}
|
||||||
passHref
|
|
||||||
onClick={e => { if (isDragging) e.preventDefault() }}
|
onClick={e => { if (isDragging) e.preventDefault() }}
|
||||||
>
|
>
|
||||||
<SortableMiniCard
|
<SortableMiniCard
|
||||||
@ -769,7 +745,6 @@ function TeamMemberViewBody({
|
|||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hover-Overlay nur, wenn klickbar */}
|
|
||||||
{canManage && isClickable && (
|
{canManage && isClickable && (
|
||||||
<div className="absolute inset-0 bg-black/50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute inset-0 bg-black/50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 mb-1" viewBox="0 0 576 512" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 mb-1" viewBox="0 0 576 512" fill="currentColor">
|
||||||
@ -778,7 +753,6 @@ function TeamMemberViewBody({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress-Kreis (Start bei 12 Uhr via rotate(-90 …)) */}
|
|
||||||
{isUploadingLogo && (
|
{isUploadingLogo && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} className="absolute">
|
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} className="absolute">
|
||||||
@ -810,24 +784,22 @@ function TeamMemberViewBody({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={!isClickable}
|
disabled={!isClickable}
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
if (isUploadingLogo) return;
|
if (isUploadingLogo) return
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0]
|
||||||
if (!file) return;
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await downscaleImage(file, { size: 512, quality: 0.85, mime: 'image/webp', square: true });
|
const blob = await downscaleImage(file, { size: 512, quality: 0.85, mime: 'image/webp', square: true })
|
||||||
// Dateiendung passend zum MIME bestimmen (nur kosmetisch)
|
const mime = blob.type || 'image/webp'
|
||||||
const mime = blob.type || 'image/webp';
|
const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp'
|
||||||
const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp';
|
const processed = new File([blob], `${team!.id}.${ext}`, { type: mime })
|
||||||
const processed = new File([blob], `${team!.id}.${ext}`, { type: mime });
|
await uploadTeamLogo(processed)
|
||||||
|
|
||||||
await uploadTeamLogo(processed);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Hochladen des Logos:', err);
|
console.error('Fehler beim Hochladen des Logos:', err)
|
||||||
alert('Fehler beim Hochladen des Logos.');
|
alert('Fehler beim Hochladen des Logos.')
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => { setIsUploadingLogo(false); setUploadPct(0); }, 300);
|
setTimeout(() => { setIsUploadingLogo(false); setUploadPct(0) }, 300)
|
||||||
e.currentTarget.value = '';
|
e.currentTarget.value = ''
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -880,7 +852,6 @@ function TeamMemberViewBody({
|
|||||||
<TeamPremierRankBadge players={activePlayers} />
|
<TeamPremierRankBadge players={activePlayers} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Beitritts-Einstellungen (nur Leader/Admin) */}
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@ -897,12 +868,12 @@ function TeamMemberViewBody({
|
|||||||
Bearbeiten
|
Bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 🔽 Dezente Policy-Pill */}
|
{/* Policy-Pill */}
|
||||||
<div className="relative" ref={policyMenuRef}>
|
<div className="relative" ref={policyMenuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onPointerDownCapture={(e) => { e.stopPropagation(); }} // verhindert Drag/Link schon sehr früh
|
onPointerDownCapture={(e) => { e.stopPropagation() }}
|
||||||
onMouseDown={(e) => e.stopPropagation()} // fallback
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => { e.stopPropagation(); setShowPolicyMenu(v => !v) }}
|
onClick={(e) => { e.stopPropagation(); setShowPolicyMenu(v => !v) }}
|
||||||
className="h-[32px] px-2.5 rounded-xl text-xs border border-gray-300 dark:border-neutral-600
|
className="h-[32px] px-2.5 rounded-xl text-xs border border-gray-300 dark:border-neutral-600
|
||||||
bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-200
|
bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-200
|
||||||
@ -926,11 +897,10 @@ function TeamMemberViewBody({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showPolicyMenu && (
|
{showPolicyMenu && (
|
||||||
<>
|
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 z-[60] mt-1 w-56 rounded-md border border-gray-200
|
className="absolute right-0 z-[60] mt-1 w-56 rounded-md border border-gray-200
|
||||||
dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-lg p-1"
|
dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-lg p-1"
|
||||||
onPointerDownCapture={(e) => e.stopPropagation()} // Klicks bleiben im Menü
|
onPointerDownCapture={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@ -963,10 +933,8 @@ function TeamMemberViewBody({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 🔼 Ende Policy-Pill */}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -1038,7 +1006,7 @@ function TeamMemberViewBody({
|
|||||||
<div className="w-full rounded-lg p-4 transition-colors min-h-[200px] border border-gray-300 dark:border-neutral-700">
|
<div className="w-full rounded-lg p-4 transition-colors min-h-[200px] border border-gray-300 dark:border-neutral-700">
|
||||||
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{invitedPlayers.map((player: InvitedPlayer) => (
|
{invitedPlayers.map((player) => (
|
||||||
<motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
|
<motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
|
||||||
<MiniCard
|
<MiniCard
|
||||||
steamId={player.steamId}
|
steamId={player.steamId}
|
||||||
@ -1053,7 +1021,8 @@ function TeamMemberViewBody({
|
|||||||
isSelectable={false}
|
isSelectable={false}
|
||||||
isInvite={true}
|
isInvite={true}
|
||||||
rank={player.premierRank}
|
rank={player.premierRank}
|
||||||
invitationId={(player as any).invitationId}
|
// optional lokales Extra-Feld sicher lesen
|
||||||
|
invitationId={(player as InvitedPlayer & { invitationId?: string }).invitationId}
|
||||||
onKick={async (sid) => {
|
onKick={async (sid) => {
|
||||||
setInvitedPlayers(list => list.filter(p => p.steamId !== sid))
|
setInvitedPlayers(list => list.filter(p => p.steamId !== sid))
|
||||||
try {
|
try {
|
||||||
@ -1061,7 +1030,7 @@ function TeamMemberViewBody({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
invitationId: (player as any).invitationId ?? undefined,
|
invitationId: (player as InvitedPlayer & { invitationId?: string }).invitationId ?? undefined,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
steamId: sid,
|
steamId: sid,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -6,20 +6,26 @@ import ComboBox from '../components/ComboBox'
|
|||||||
|
|
||||||
type ComboItem = { id: string; label: string }
|
type ComboItem = { id: string; label: string }
|
||||||
|
|
||||||
function toComboItems(raw: any): ComboItem[] {
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
!!v && typeof v === 'object' && !Array.isArray(v)
|
||||||
|
|
||||||
|
function toComboItems(raw: unknown): ComboItem[] {
|
||||||
if (!Array.isArray(raw)) return []
|
if (!Array.isArray(raw)) return []
|
||||||
return raw
|
return raw
|
||||||
.map((t) => {
|
.map((t: unknown) => {
|
||||||
if (typeof t === 'string') return { id: t, label: t }
|
if (typeof t === 'string') return { id: t, label: t }
|
||||||
if (t && typeof t === 'object') {
|
if (isRecord(t)) {
|
||||||
const id =
|
const id = String(
|
||||||
String(t.id ?? t.teamId ?? t.value ?? t.slug ?? t.name ?? '')
|
t.id ?? t.teamId ?? t.value ?? t.slug ?? t.name ?? ''
|
||||||
const label = String(t.name ?? t.label ?? t.teamname ?? id)
|
)
|
||||||
|
const label = String(
|
||||||
|
(t.name ?? t.label ?? (t as Record<string, unknown>).teamname ?? id) as string
|
||||||
|
)
|
||||||
return id ? { id, label } : null
|
return id ? { id, label } : null
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
.filter(Boolean) as ComboItem[]
|
.filter((x): x is ComboItem => !!x)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamSelector() {
|
export default function TeamSelector() {
|
||||||
@ -30,10 +36,10 @@ export default function TeamSelector() {
|
|||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/teams', { cache: 'no-store' })
|
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||||||
const data = await res.json()
|
const data: unknown = await res.json()
|
||||||
const items = toComboItems(data?.teams)
|
const items = toComboItems((isRecord(data) ? data.teams : undefined) as unknown)
|
||||||
setTeams(items)
|
setTeams(items)
|
||||||
if (!selectedTeam && items[0]) setSelectedTeam(items[0].id) // optional: default auswählen
|
if (!selectedTeam && items[0]) setSelectedTeam(items[0].id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Laden der Teams:', err)
|
console.error('Fehler beim Laden der Teams:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
// /src/app/[locale]/components/TelemetrySocket.tsx
|
// /src/app/[locale]/components/TelemetrySocket.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
|
import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
|
||||||
import { usePresenceStore } from '@/lib/usePresenceStore'
|
import { usePresenceStore } from '@/lib/usePresenceStore'
|
||||||
import { useTelemetryStore } from '@/lib/useTelemetryStore'
|
import { useTelemetryStore } from '@/lib/useTelemetryStore'
|
||||||
import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
|
import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
|
||||||
import { useSSEStore } from '@/lib/useSSEStore'
|
import { useSSEStore } from '@/lib/useSSEStore'
|
||||||
|
import type { SSEEventType } from '@/lib/sseEvents'
|
||||||
|
|
||||||
|
/* ===================== helpers & types ===================== */
|
||||||
|
|
||||||
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
||||||
const h = (host ?? '').trim() || '127.0.0.1'
|
const h = (host ?? '').trim() || '127.0.0.1'
|
||||||
@ -22,8 +24,66 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string)
|
|||||||
return `${proto}://${h}${portPart}${pa}`
|
return `${proto}://${h}${portPart}${pa}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
|
function toSnapshotList(arr: PlayerLike[]): SnapshotPlayer[] {
|
||||||
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
|
const out: SnapshotPlayer[] = [];
|
||||||
|
for (const p of arr) {
|
||||||
|
const sid = p.steamId ?? p.steam_id ?? p.id;
|
||||||
|
if (sid == null) continue; // ohne ID überspringen
|
||||||
|
out.push({ steamId: sid, name: p.name, team: p.team });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerLike = {
|
||||||
|
steamId?: string | number
|
||||||
|
steam_id?: string | number
|
||||||
|
id?: string | number
|
||||||
|
name?: string
|
||||||
|
team?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshotPlayer = { steamId: string | number; name?: string; team?: unknown };
|
||||||
|
|
||||||
|
const sidOf = (p: unknown): string => {
|
||||||
|
if (p && typeof p === 'object') {
|
||||||
|
const o = p as PlayerLike
|
||||||
|
const raw = o.steamId ?? o.steam_id ?? o.id
|
||||||
|
return raw != null ? String(raw) : ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- incoming telemetry message shapes ---- */
|
||||||
|
|
||||||
|
type PlayersMsg = { type: 'players'; players: PlayerLike[] }
|
||||||
|
type PlayerJoinMsg = { type: 'player_join'; player: PlayerLike }
|
||||||
|
type PlayerLeaveMsg = { type: 'player_leave'; steamId?: string | number; steam_id?: string | number; id?: string | number }
|
||||||
|
type MapMsg = { type: 'map'; name: string }
|
||||||
|
type PhaseMsg = { type: 'phase'; phase: string }
|
||||||
|
|
||||||
|
function isObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === 'object' && v !== null
|
||||||
|
}
|
||||||
|
function isPlayersMsg(v: unknown): v is PlayersMsg {
|
||||||
|
return isObject(v) && v.type === 'players' && Array.isArray((v as PlayersMsg).players)
|
||||||
|
}
|
||||||
|
function isPlayerJoinMsg(v: unknown): v is PlayerJoinMsg {
|
||||||
|
return isObject(v) && v.type === 'player_join' && isObject((v as PlayerJoinMsg).player)
|
||||||
|
}
|
||||||
|
function isPlayerLeaveMsg(v: unknown): v is PlayerLeaveMsg {
|
||||||
|
return isObject(v) && v.type === 'player_leave'
|
||||||
|
}
|
||||||
|
function isMapMsg(v: unknown): v is MapMsg {
|
||||||
|
return isObject(v) && v.type === 'map' && typeof (v as MapMsg).name === 'string'
|
||||||
|
}
|
||||||
|
function isPhaseMsg(v: unknown): v is PhaseMsg {
|
||||||
|
return isObject(v) && v.type === 'phase' && typeof (v as PhaseMsg).phase === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRefetchRoster = (t?: SSEEventType | string | null | undefined) =>
|
||||||
|
!!t && ['match-updated', 'match-ready', 'map-vote-updated', 'match-exported'].includes(String(t))
|
||||||
|
|
||||||
|
/* ===================== component ===================== */
|
||||||
|
|
||||||
export default function TelemetrySocket() {
|
export default function TelemetrySocket() {
|
||||||
// WS-URL aus ENV ableiten
|
// WS-URL aus ENV ableiten
|
||||||
@ -40,7 +100,7 @@ export default function TelemetrySocket() {
|
|||||||
|
|
||||||
// aktiver User
|
// aktiver User
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const mySteamId = (session?.user as any)?.steamId ?? null
|
const mySteamId = (session?.user as { steamId?: string } | undefined)?.steamId ?? null
|
||||||
|
|
||||||
// Overlay-Steuerung
|
// Overlay-Steuerung
|
||||||
const hideOverlay = useReadyOverlayStore((s) => s.hide)
|
const hideOverlay = useReadyOverlayStore((s) => s.hide)
|
||||||
@ -52,40 +112,42 @@ export default function TelemetrySocket() {
|
|||||||
|
|
||||||
const setMapKey = useTelemetryStore((s) => s.setMapKey)
|
const setMapKey = useTelemetryStore((s) => s.setMapKey)
|
||||||
const setPhase = useTelemetryStore((s) => s.setPhase)
|
const setPhase = useTelemetryStore((s) => s.setPhase)
|
||||||
|
|
||||||
// 👇 NEU: online-Status für GameBanner
|
|
||||||
const setOnline = useTelemetryStore((s) => s.setOnline)
|
const setOnline = useTelemetryStore((s) => s.setOnline)
|
||||||
|
|
||||||
// Roster-Store
|
// Roster-Store
|
||||||
const setRoster = useMatchRosterStore((s) => s.setRoster)
|
const setRoster = useMatchRosterStore((s) => s.setRoster)
|
||||||
const clearRoster = useMatchRosterStore((s) => s.clearRoster)
|
const clearRoster = useMatchRosterStore((s) => s.clearRoster)
|
||||||
|
|
||||||
// lokaler Telemetry-Set (wer ist per WS online)
|
|
||||||
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// SSE -> bei relevanten Events Roster nachladen
|
// SSE -> bei relevanten Events Roster nachladen
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
async function fetchCurrentRoster() {
|
const fetchCurrentRoster = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/matches/current', { cache: 'no-store' })
|
const r = await fetch('/api/matches/current', { cache: 'no-store' })
|
||||||
if (!r.ok) return
|
if (!r.ok) return
|
||||||
const j = await r.json()
|
const j: unknown = await r.json()
|
||||||
const ids: string[] = Array.isArray(j?.steamIds) ? j.steamIds : []
|
const ids: string[] = Array.isArray((j as Record<string, unknown>)?.steamIds)
|
||||||
|
? ((j as Record<string, unknown>).steamIds as unknown[]).map(String)
|
||||||
|
: []
|
||||||
if (ids.length) setRoster(ids)
|
if (ids.length) setRoster(ids)
|
||||||
else clearRoster()
|
else clearRoster()
|
||||||
} catch {}
|
} catch {
|
||||||
|
// still
|
||||||
}
|
}
|
||||||
|
}, [setRoster, clearRoster])
|
||||||
|
|
||||||
// initial + bei Events
|
// initial + bei Events
|
||||||
useEffect(() => { fetchCurrentRoster() }, [])
|
useEffect(() => {
|
||||||
|
fetchCurrentRoster()
|
||||||
|
}, [fetchCurrentRoster])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
const t = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
const t = lastEvent.type ?? (isObject(lastEvent.payload) ? (lastEvent.payload as Record<string, unknown>).type : undefined)
|
||||||
if (['match-updated', 'match-ready', 'map-vote-updated', 'match-exported'].includes(String(t))) {
|
if (shouldRefetchRoster(t as SSEEventType | string)) {
|
||||||
fetchCurrentRoster()
|
fetchCurrentRoster()
|
||||||
}
|
}
|
||||||
}, [lastEvent])
|
}, [lastEvent, fetchCurrentRoster])
|
||||||
|
|
||||||
// wenn User ab-/anmeldet → Online-Flag sinnvoll zurücksetzen
|
// wenn User ab-/anmeldet → Online-Flag sinnvoll zurücksetzen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -104,17 +166,21 @@ export default function TelemetrySocket() {
|
|||||||
if (!aliveRef.current || !url) return
|
if (!aliveRef.current || !url) return
|
||||||
|
|
||||||
// nicht doppelt verbinden
|
// nicht doppelt verbinden
|
||||||
if (wsRef.current && (
|
if (
|
||||||
wsRef.current.readyState === WebSocket.OPEN ||
|
wsRef.current &&
|
||||||
wsRef.current.readyState === WebSocket.CONNECTING
|
(wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)
|
||||||
)) return
|
)
|
||||||
|
return
|
||||||
|
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open')
|
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open')
|
||||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
if (retryRef.current) {
|
||||||
|
window.clearTimeout(retryRef.current)
|
||||||
|
retryRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
@ -135,59 +201,56 @@ export default function TelemetrySocket() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
let msg: any = null
|
let msg: unknown
|
||||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
try {
|
||||||
if (!msg) return
|
msg = JSON.parse(String(ev.data ?? ''))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// komplette Playerliste
|
// komplette Playerliste
|
||||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
if (isPlayersMsg(msg)) {
|
||||||
setSnapshot(msg.players)
|
setSnapshot(toSnapshotList(msg.players));
|
||||||
const ids = msg.players.map(sidOf).filter(Boolean)
|
const ids = msg.players.map(sidOf).filter(Boolean)
|
||||||
setTelemetrySet(toSet(ids))
|
|
||||||
|
|
||||||
const mePresent = !!mySteamId && ids.includes(String(mySteamId))
|
const mePresent = !!mySteamId && ids.includes(String(mySteamId))
|
||||||
setOnline(!!mePresent)
|
setOnline(!!mePresent)
|
||||||
if (mePresent) hideOverlay()
|
if (mePresent) hideOverlay()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// join/leave deltas
|
// join/leave deltas
|
||||||
if (msg.type === 'player_join' && msg.player) {
|
if (isPlayerJoinMsg(msg)) {
|
||||||
setJoin(msg.player)
|
const sid = msg.player.steamId ?? msg.player.steam_id ?? msg.player.id;
|
||||||
setTelemetrySet(prev => {
|
if (sid != null) {
|
||||||
const next = new Set(prev)
|
setJoin({ steamId: sid, name: msg.player.name, team: msg.player.team }); // ✅ required steamId
|
||||||
const sid = sidOf(msg.player)
|
if (mySteamId && String(sid) === String(mySteamId)) {
|
||||||
if (sid) next.add(sid)
|
setOnline(true);
|
||||||
return next
|
hideOverlay();
|
||||||
})
|
|
||||||
|
|
||||||
const sid = sidOf(msg.player)
|
|
||||||
if (mySteamId && sid === String(mySteamId)) {
|
|
||||||
setOnline(true)
|
|
||||||
hideOverlay()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'player_leave') {
|
if (isPlayerLeaveMsg(msg)) {
|
||||||
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '')
|
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '')
|
||||||
if (sid) setLeave(sid)
|
if (sid) setLeave(sid)
|
||||||
setTelemetrySet(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (sid) next.delete(sid)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
if (mySteamId && sid === String(mySteamId)) {
|
if (mySteamId && sid === String(mySteamId)) {
|
||||||
setOnline(false)
|
setOnline(false)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map-Key und Phase ins Telemetry-Store schreiben
|
// Map-Key und Phase ins Telemetry-Store schreiben
|
||||||
if (msg.type === 'map' && typeof msg.name === 'string') {
|
if (isMapMsg(msg)) {
|
||||||
const key = msg.name.toLowerCase()
|
const key = msg.name.toLowerCase()
|
||||||
setMapKey(key)
|
setMapKey(key)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'phase' && typeof msg.phase === 'string') {
|
|
||||||
setPhase(String(msg.phase).toLowerCase() as any)
|
if (isPhaseMsg(msg)) {
|
||||||
|
// phase-Typ auf den Store-Parameter abbilden, ohne any
|
||||||
|
const nextPhase = String(msg.phase).toLowerCase() as Parameters<typeof setPhase>[0]
|
||||||
|
setPhase(nextPhase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,12 +258,16 @@ export default function TelemetrySocket() {
|
|||||||
connectOnce()
|
connectOnce()
|
||||||
return () => {
|
return () => {
|
||||||
aliveRef.current = false
|
aliveRef.current = false
|
||||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
if (retryRef.current) {
|
||||||
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
|
window.clearTimeout(retryRef.current)
|
||||||
|
retryRef.current = null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
wsRef.current?.close(1000, 'telemetry socket unmounted')
|
||||||
|
} catch {}
|
||||||
setOnline(false)
|
setOnline(false)
|
||||||
}
|
}
|
||||||
}, [url, hideOverlay, mySteamId, setJoin, setLeave, setMapKey, setPhase, setSnapshot, setOnline])
|
}, [url, hideOverlay, mySteamId, setJoin, setLeave, setMapKey, setPhase, setSnapshot, setOnline])
|
||||||
|
|
||||||
// ⬇️ WICHTIG: Kein Banner-Rendering mehr hier. UI kommt aus GameBannerHost.
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// /src/app/components/UserAvatarWithStatus.tsx
|
// /src/app/[locale]/components/UserAvatarWithStatus.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
@ -22,6 +22,34 @@ type Props = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
avatarClassName?: string
|
avatarClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPresence(v: unknown): v is Presence {
|
||||||
|
return v === 'online' || v === 'away' || v === 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaybeStatusPayload =
|
||||||
|
| { steamId?: unknown; status?: unknown }
|
||||||
|
| { payload?: { steamId?: unknown; status?: unknown } }
|
||||||
|
| Record<string, unknown>
|
||||||
|
| unknown
|
||||||
|
|
||||||
|
function extractStatusPayload(v: MaybeStatusPayload): { steamId?: string; status?: Presence } {
|
||||||
|
if (typeof v !== 'object' || v == null) return {}
|
||||||
|
const top = v as Record<string, unknown>
|
||||||
|
|
||||||
|
const directSteam = typeof top.steamId === 'string' ? top.steamId : undefined
|
||||||
|
const directStatus = isPresence(top.status) ? top.status : undefined
|
||||||
|
if (directSteam && directStatus) return { steamId: directSteam, status: directStatus }
|
||||||
|
|
||||||
|
const nested = (typeof top.payload === 'object' && top.payload != null)
|
||||||
|
? (top.payload as Record<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nestedSteam = typeof nested?.steamId === 'string' ? nested.steamId : undefined
|
||||||
|
const nestedStatus = isPresence(nested?.status) ? nested.status : undefined
|
||||||
|
|
||||||
|
return { steamId: nestedSteam, status: nestedStatus }
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserAvatarWithStatus({
|
export default function UserAvatarWithStatus({
|
||||||
steamId,
|
steamId,
|
||||||
src,
|
src,
|
||||||
@ -31,20 +59,21 @@ export default function UserAvatarWithStatus({
|
|||||||
isLeader = false,
|
isLeader = false,
|
||||||
alignRight = false,
|
alignRight = false,
|
||||||
showStatus = true,
|
showStatus = true,
|
||||||
className, // <— NEU: Klassen für den äußeren Wrapper
|
className,
|
||||||
avatarClassName, // <— NEU: Klassen für den inneren Avatar-Container
|
avatarClassName,
|
||||||
...rest // <— NEU: alle weiteren div-Props (onClick, title, …)
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
const [status, setStatus] = useState<Presence>(initialStatus)
|
const [status, setStatus] = useState<Presence>(initialStatus)
|
||||||
|
|
||||||
const { crownSize, crownOffset, crownIconSize } = useMemo(() => {
|
const { crownSize, crownOffset, crownIconSize } = useMemo(() => {
|
||||||
const cs = Math.min(20, Math.round(Math.max(size * 0.5, 14)))
|
const cs = Math.min(20, Math.round(Math.max(size * 0.5, 14)))
|
||||||
const off = Math.round(size * 0.10)
|
const off = Math.round(size * 0.1)
|
||||||
const icon = Math.round(cs * 0.70)
|
const icon = Math.round(cs * 0.7)
|
||||||
return { crownSize: cs, crownOffset: off, crownIconSize: icon }
|
return { crownSize: cs, crownOffset: off, crownIconSize: icon }
|
||||||
}, [size])
|
}, [size])
|
||||||
|
|
||||||
|
// Initialstatus vom Server holen (optional)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showStatus || !steamId) return
|
if (!showStatus || !steamId) return
|
||||||
let alive = true
|
let alive = true
|
||||||
@ -52,19 +81,29 @@ export default function UserAvatarWithStatus({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/user/${steamId}`, { cache: 'no-store' })
|
const res = await fetch(`/api/user/${steamId}`, { cache: 'no-store' })
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data: unknown = await res.json()
|
||||||
if (alive) setStatus((data?.user?.status ?? 'offline') as Presence)
|
const next = typeof data === 'object' && data != null
|
||||||
} catch {}
|
? (data as Record<string, unknown>).user
|
||||||
|
: undefined
|
||||||
|
const nextStatus =
|
||||||
|
typeof next === 'object' && next != null && isPresence((next as Record<string, unknown>).status)
|
||||||
|
? ((next as Record<string, unknown>).status as Presence)
|
||||||
|
: undefined
|
||||||
|
if (alive && nextStatus) setStatus(nextStatus)
|
||||||
|
} catch {
|
||||||
|
// still
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
return () => { alive = false }
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
}, [steamId, showStatus])
|
}, [steamId, showStatus])
|
||||||
|
|
||||||
|
// Live-Updates via SSE
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showStatus || !steamId) return
|
if (!showStatus || !steamId) return
|
||||||
if (!lastEvent || lastEvent.type !== 'user-status-updated') return
|
if (!lastEvent || lastEvent.type !== 'user-status-updated') return
|
||||||
const raw = lastEvent.payload as any
|
const { steamId: sid, status: st } = extractStatusPayload(lastEvent.payload as MaybeStatusPayload)
|
||||||
const sid = raw?.steamId ?? raw?.payload?.steamId
|
|
||||||
const st = (raw?.status ?? raw?.payload?.status) as Presence | undefined
|
|
||||||
if (sid === steamId && st) setStatus(st)
|
if (sid === steamId && st) setStatus(st)
|
||||||
}, [lastEvent, steamId, showStatus])
|
}, [lastEvent, steamId, showStatus])
|
||||||
|
|
||||||
@ -113,7 +152,13 @@ export default function UserAvatarWithStatus({
|
|||||||
...(alignRight ? { left: -crownOffset } : { right: -crownOffset }),
|
...(alignRight ? { left: -crownOffset } : { right: -crownOffset }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height={crownIconSize} width={crownIconSize} fill="currentColor" viewBox="0 0 640 640">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={crownIconSize}
|
||||||
|
width={crownIconSize}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 640 640"
|
||||||
|
>
|
||||||
<path d="M345 151.2C354.2 143.9 360 132.6 360 120C360 97.9 342.1 80 320 80C297.9 80 280 97.9 280 120C280 132.6 285.9 143.9 295 151.2L226.6 258.8C216.6 274.5 195.3 278.4 180.4 267.2L120.9 222.7C125.4 216.3 128 208.4 128 200C128 177.9 110.1 160 88 160C65.9 160 48 177.9 48 200C48 221.8 65.5 239.6 87.2 240L119.8 457.5C124.5 488.8 151.4 512 183.1 512L456.9 512C488.6 512 515.5 488.8 520.2 457.5L552.8 240C574.5 239.6 592 221.8 592 200C592 177.9 574.1 160 552 160C529.9 160 512 177.9 512 200C512 208.4 514.6 216.3 519.1 222.7L459.7 267.3C444.8 278.5 423.5 274.6 413.5 258.9L345 151.2z" />
|
<path d="M345 151.2C354.2 143.9 360 132.6 360 120C360 97.9 342.1 80 320 80C297.9 80 280 97.9 280 120C280 132.6 285.9 143.9 295 151.2L226.6 258.8C216.6 274.5 195.3 278.4 180.4 267.2L120.9 222.7C125.4 216.3 128 208.4 128 200C128 177.9 110.1 160 88 160C65.9 160 48 177.9 48 200C48 221.8 65.5 239.6 87.2 240L119.8 457.5C124.5 488.8 151.4 512 183.1 512L456.9 512C488.6 512 515.5 488.8 520.2 457.5L552.8 240C574.5 239.6 592 221.8 592 200C592 177.9 574.1 160 552 160C529.9 160 512 177.9 512 200C512 208.4 514.6 216.3 519.1 222.7L459.7 267.3C444.8 278.5 423.5 274.6 413.5 258.9L345 151.2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,37 +1,10 @@
|
|||||||
|
// /src/app/[locale]/components/admin/MatchesAdminManager.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import CommunityMatchList from '../CommunityMatchList'
|
import CommunityMatchList from '../CommunityMatchList'
|
||||||
|
|
||||||
function getRoundedDate() {
|
|
||||||
const now = new Date()
|
|
||||||
const minutes = now.getMinutes()
|
|
||||||
const roundedMinutes = Math.ceil(minutes / 15) * 15
|
|
||||||
now.setMinutes(roundedMinutes === 60 ? 0 : roundedMinutes)
|
|
||||||
if (roundedMinutes === 60) now.setHours(now.getHours() + 1)
|
|
||||||
now.setSeconds(0)
|
|
||||||
now.setMilliseconds(0)
|
|
||||||
return now
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MatchesAdminManager() {
|
export default function MatchesAdminManager() {
|
||||||
const [teams, setTeams] = useState<any[]>([])
|
|
||||||
const [matches, setMatches] = useState<any[]>([])
|
|
||||||
const [teamAId, setTeamAId] = useState('')
|
|
||||||
const [teamBId, setTeamBId] = useState('')
|
|
||||||
const [title, setTitle] = useState('')
|
|
||||||
const [titleManuallySet, setTitleManuallySet] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!titleManuallySet && teamAId && teamBId && teamAId !== teamBId) {
|
|
||||||
const teamA = teams.find(t => t.id === teamAId)
|
|
||||||
const teamB = teams.find(t => t.id === teamBId)
|
|
||||||
if (teamA && teamB) {
|
|
||||||
setTitle(`${teamA.name} vs ${teamB.name}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [teamAId, teamBId, teams, titleManuallySet])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommunityMatchList matchType="community" />
|
<CommunityMatchList matchType="community" />
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
|
||||||
import Button from '../../Button'
|
import Button from '../../Button'
|
||||||
import Modal from '../../Modal'
|
import Modal from '../../Modal'
|
||||||
import Input from '../../Input'
|
import Input from '../../Input'
|
||||||
@ -12,8 +11,6 @@ import type { Team } from '@/types/team'
|
|||||||
import LoadingSpinner from '../../LoadingSpinner'
|
import LoadingSpinner from '../../LoadingSpinner'
|
||||||
|
|
||||||
export default function AdminTeamsView() {
|
export default function AdminTeamsView() {
|
||||||
/* ────────────────────────── Session ─────────────────────────── */
|
|
||||||
const { data: session } = useSession()
|
|
||||||
|
|
||||||
/* ─────────────────────────── State ───────────────────────────── */
|
/* ─────────────────────────── State ───────────────────────────── */
|
||||||
const [teams, setTeams] = useState<Team[]>([])
|
const [teams, setTeams] = useState<Team[]>([])
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Card from '../../Card'
|
|||||||
import PremierRankBadge from '../../PremierRankBadge'
|
import PremierRankBadge from '../../PremierRankBadge'
|
||||||
import CompRankBadge from '../../CompRankBadge'
|
import CompRankBadge from '../../CompRankBadge'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
type Props = { steamId: string }
|
type Props = { steamId: string }
|
||||||
|
|
||||||
@ -191,13 +192,15 @@ const iconForMap = (raw: string) => {
|
|||||||
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
|
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
|
||||||
return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg`
|
return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg`
|
||||||
}
|
}
|
||||||
|
|
||||||
const bgForMap = (raw: string) => {
|
const bgForMap = (raw: string) => {
|
||||||
const k = normKey(raw)
|
const k = normKey(raw)
|
||||||
const opt: any = MAP_OPTIONS.find(o => o.key === k)
|
const opt = MAP_OPTIONS.find(o => o.key === k)
|
||||||
if (opt?.images?.length) return String(opt.images[0])
|
if (opt?.images?.length) return String(opt.images[0])
|
||||||
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
|
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
|
||||||
return `/assets/img/maps/${withPrefix}.webp`
|
return `/assets/img/maps/${withPrefix}.webp`
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
|
const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
|
||||||
|
|
||||||
const parseScoreString = (raw?: string | null): [number | null, number | null] => {
|
const parseScoreString = (raw?: string | null): [number | null, number | null] => {
|
||||||
@ -436,7 +439,13 @@ export default async function Profile({ steamId }: Props) {
|
|||||||
{/* Map + Meta */}
|
{/* Map + Meta */}
|
||||||
<div className="relative z-[1] flex items-center gap-3 shrink-0">
|
<div className="relative z-[1] flex items-center gap-3 shrink-0">
|
||||||
<div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden">
|
<div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden">
|
||||||
<img src={iconSrc} alt={mapLabel} className="h-10 w-10 object-contain" loading="lazy" />
|
<Image
|
||||||
|
src={iconSrc}
|
||||||
|
alt={mapLabel}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="h-10 w-10 object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs text-neutral-300/90">{fmtDateTime(m.date)}</div>
|
<div className="text-xs text-neutral-300/90">{fmtDateTime(m.date)}</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Card from '../../../Card'
|
|||||||
import PremierRankBadge from '../../../PremierRankBadge'
|
import PremierRankBadge from '../../../PremierRankBadge'
|
||||||
import CompRankBadge from '../../../CompRankBadge'
|
import CompRankBadge from '../../../CompRankBadge'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
type Props = { steamId: string }
|
type Props = { steamId: string }
|
||||||
|
|
||||||
@ -61,16 +62,9 @@ const iconForMap = (raw: string) => {
|
|||||||
return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg`
|
return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg`
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeIconFor = (t?: string | null) =>
|
|
||||||
t === 'premier'
|
|
||||||
? '/assets/img/icons/ui/competitive_teams.svg'
|
|
||||||
: t === 'competitive'
|
|
||||||
? '/assets/img/icons/ui/competitive.svg'
|
|
||||||
: null
|
|
||||||
|
|
||||||
const bgForMap = (raw: string) => {
|
const bgForMap = (raw: string) => {
|
||||||
const k = normKey(raw)
|
const k = normKey(raw)
|
||||||
const opt: any = MAP_OPTIONS.find(o => o.key === k)
|
const opt = MAP_OPTIONS.find(o => o.key === k)
|
||||||
if (opt?.images?.length) return String(opt.images[0])
|
if (opt?.images?.length) return String(opt.images[0])
|
||||||
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
|
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
|
||||||
return `/assets/img/maps/${withPrefix}.webp`
|
return `/assets/img/maps/${withPrefix}.webp`
|
||||||
@ -231,7 +225,12 @@ export default async function MatchesList({ steamId }: Props) {
|
|||||||
{/* LINKS: Map + Meta */}
|
{/* LINKS: Map + Meta */}
|
||||||
<div className="relative z-[1] flex items-center gap-3 shrink-0">
|
<div className="relative z-[1] flex items-center gap-3 shrink-0">
|
||||||
<div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden">
|
<div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden">
|
||||||
<img src={iconSrc} alt={mapLabel} className="h-10 w-10 object-contain" loading="lazy" />
|
<Image
|
||||||
|
src={iconSrc}
|
||||||
|
alt={mapLabel}
|
||||||
|
width={40}
|
||||||
|
height={40}className="h-10 w-10 object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
|
// /src/app/[locale]/components/profile/[steamId]/stats/StatsView.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import type { TooltipItem } from 'chart.js'
|
||||||
import Chart from '../../../Chart'
|
import Chart from '../../../Chart'
|
||||||
import Card from '../../../Card'
|
import Card from '../../../Card'
|
||||||
import { MatchStats } from '@/types/match'
|
import { MatchStats } from '@/types/match'
|
||||||
@ -45,15 +47,20 @@ const tone = {
|
|||||||
const fmtADR = (v: number) =>
|
const fmtADR = (v: number) =>
|
||||||
new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v)
|
new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v)
|
||||||
|
|
||||||
/** Runden robust ziehen */
|
/** Runden robust ziehen (ohne any) */
|
||||||
function getRounds(m: Partial<MatchStats>): number {
|
function getRounds(m: Partial<MatchStats>): number {
|
||||||
|
const r = (k: string): number | null => {
|
||||||
|
const v = (m as Record<string, unknown>)[k]
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
(m as any).rounds ??
|
r('rounds') ??
|
||||||
(m as any).roundCount ??
|
r('roundCount') ??
|
||||||
(m as any).roundsPlayed ??
|
r('roundsPlayed') ??
|
||||||
(m as any).roundsTotal ??
|
r('roundsTotal') ??
|
||||||
0
|
0
|
||||||
) || 0
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* – kleine Sparkline – */
|
/* – kleine Sparkline – */
|
||||||
@ -126,11 +133,11 @@ function perfOfMatch(m: Partial<MatchStats>) {
|
|||||||
const d = m.deaths ?? 0
|
const d = m.deaths ?? 0
|
||||||
const a = m.assists ?? 0
|
const a = m.assists ?? 0
|
||||||
const r = Math.max(1, getRounds(m))
|
const r = Math.max(1, getRounds(m))
|
||||||
const kd = d > 0 ? k / d : KD_CAP
|
const kdV = d > 0 ? k / d : KD_CAP
|
||||||
const adr = (m.totalDamage ?? 0) / r
|
const adr = (m.totalDamage ?? 0) / r
|
||||||
const kpr = k / r
|
const kpr = k / r
|
||||||
const apr = a / r
|
const apr = a / r
|
||||||
const kdS = clamp01(kd / KD_CAP)
|
const kdS = clamp01(kdV / KD_CAP)
|
||||||
const adrS = clamp01(adr / ADR_CAP)
|
const adrS = clamp01(adr / ADR_CAP)
|
||||||
const kprS = clamp01(kpr / KPR_CAP)
|
const kprS = clamp01(kpr / KPR_CAP)
|
||||||
const aprS = clamp01(apr / APR_CAP)
|
const aprS = clamp01(apr / APR_CAP)
|
||||||
@ -139,8 +146,11 @@ function perfOfMatch(m: Partial<MatchStats>) {
|
|||||||
|
|
||||||
/* – Hauptkomponente – */
|
/* – Hauptkomponente – */
|
||||||
export default function StatsView({ steamId, stats }: Props) {
|
export default function StatsView({ steamId, stats }: Props) {
|
||||||
const { data: session } = useSession()
|
// Matches-Array stabilisieren, damit useMemo-Dep sauber bleibt
|
||||||
const allMatches = stats.matches ?? []
|
const allMatches = useMemo<MatchStats[]>(
|
||||||
|
() => stats.matches ?? [],
|
||||||
|
[stats.matches]
|
||||||
|
)
|
||||||
|
|
||||||
/* ─ Filter: 30 | 90 | Alle ─ */
|
/* ─ Filter: 30 | 90 | Alle ─ */
|
||||||
const [range, setRange] = useState<'30' | '90' | 'all'>('30')
|
const [range, setRange] = useState<'30' | '90' | 'all'>('30')
|
||||||
@ -159,7 +169,8 @@ export default function StatsView({ steamId, stats }: Props) {
|
|||||||
|
|
||||||
// ► ADR-Berechnung
|
// ► ADR-Berechnung
|
||||||
const totalRounds = matches.reduce((s, m) => s + getRounds(m), 0)
|
const totalRounds = matches.reduce((s, m) => s + getRounds(m), 0)
|
||||||
const adrOverall = totalRounds > 0 ? totalDamage / totalRounds : (matches.length ? totalDamage / matches.length : 0)
|
const adrOverall =
|
||||||
|
totalRounds > 0 ? totalDamage / totalRounds : (matches.length ? totalDamage / matches.length : 0)
|
||||||
|
|
||||||
const overallKD = kd(totalKills, totalDeaths)
|
const overallKD = kd(totalKills, totalDeaths)
|
||||||
const dateLabels = matches.map((m) => fmtShortDate(m.date))
|
const dateLabels = matches.map((m) => fmtShortDate(m.date))
|
||||||
@ -187,7 +198,7 @@ export default function StatsView({ steamId, stats }: Props) {
|
|||||||
}, [matches])
|
}, [matches])
|
||||||
const mapKeys = Object.keys(killsPerMap)
|
const mapKeys = Object.keys(killsPerMap)
|
||||||
const mapLabels = mapKeys.map((k) =>
|
const mapLabels = mapKeys.map((k) =>
|
||||||
k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
/* – Winrate je Map (vom API) – */
|
/* – Winrate je Map (vom API) – */
|
||||||
@ -211,13 +222,11 @@ export default function StatsView({ steamId, stats }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => { stop = true }
|
||||||
stop = true
|
|
||||||
}
|
|
||||||
}, [steamId])
|
}, [steamId])
|
||||||
|
|
||||||
const winPct = wrValues.map(v => Math.max(0, Math.min(100, v ?? 0)));
|
const winPct = wrValues.map(v => Math.max(0, Math.min(100, v ?? 0)))
|
||||||
const lossPct = winPct.map(v => 100 - v);
|
const lossPct = winPct.map(v => 100 - v)
|
||||||
|
|
||||||
// ►► Per-Match-ADR Serie für Chart
|
// ►► Per-Match-ADR Serie für Chart
|
||||||
const adrPerMatch = matches.map((m) => {
|
const adrPerMatch = matches.map((m) => {
|
||||||
@ -348,16 +357,8 @@ export default function StatsView({ steamId, stats }: Props) {
|
|||||||
type="bar"
|
type="bar"
|
||||||
labels={wrLabels}
|
labels={wrLabels}
|
||||||
datasets={[
|
datasets={[
|
||||||
{
|
{ label: 'Win %', data: winPct, backgroundColor: 'rgba(16,185,129,.85)' },
|
||||||
label: 'Win %',
|
{ label: 'Loss %', data: lossPct, backgroundColor: 'rgba(239,68,68,.85)' },
|
||||||
data: winPct,
|
|
||||||
backgroundColor: 'rgba(16,185,129,.85)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Loss %',
|
|
||||||
data: lossPct,
|
|
||||||
backgroundColor: 'rgba(239,68,68,.85)',
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
options={{
|
options={{
|
||||||
indexAxis: 'y',
|
indexAxis: 'y',
|
||||||
@ -366,7 +367,8 @@ export default function StatsView({ steamId, stats }: Props) {
|
|||||||
legend: { position: 'bottom' },
|
legend: { position: 'bottom' },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx: any) => `${ctx.dataset.label}: ${Number(ctx.parsed.x).toFixed(0)}%`,
|
label: (ctx: TooltipItem<'bar'>) =>
|
||||||
|
`${ctx.dataset.label ?? ''}: ${Number(ctx.parsed.x).toFixed(0)}%`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -375,15 +377,10 @@ export default function StatsView({ steamId, stats }: Props) {
|
|||||||
stacked: true,
|
stacked: true,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
ticks: {
|
ticks: { callback: (v) => `${v}%` },
|
||||||
callback: (v) => `${v}%`,
|
|
||||||
},
|
|
||||||
grid: { color: 'rgba(255,255,255,.08)' },
|
grid: { color: 'rgba(255,255,255,.08)' },
|
||||||
},
|
},
|
||||||
y: {
|
y: { stacked: true, grid: { display: false } },
|
||||||
stacked: true,
|
|
||||||
grid: { display: false },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -409,9 +406,7 @@ export default function StatsView({ steamId, stats }: Props) {
|
|||||||
datasets={[
|
datasets={[
|
||||||
{
|
{
|
||||||
label: 'K/D',
|
label: 'K/D',
|
||||||
data: matches.map((m) =>
|
data: matches.map((m) => (m.deaths ?? 0) > 0 ? (m.kills ?? 0) / (m.deaths ?? 1) : (m.kills ?? 0)),
|
||||||
(m.deaths ?? 0) > 0 ? (m.kills ?? 0) / (m.deaths ?? 1) : (m.kills ?? 0),
|
|
||||||
),
|
|
||||||
borderColor: tone.red,
|
borderColor: tone.red,
|
||||||
backgroundColor: tone.redBg,
|
backgroundColor: tone.redBg,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
|
|||||||
@ -1,65 +1,78 @@
|
|||||||
// /src/app/[locale]/components/radar/GameSocket.tsx
|
// /src/app/[locale]/components/radar/GameSocket.tsx
|
||||||
'use client'
|
'use client'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
|
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
|
||||||
|
type UnknownRecord = Record<string, unknown>
|
||||||
|
|
||||||
type GameSocketProps = {
|
type GameSocketProps = {
|
||||||
url?: string
|
url?: string
|
||||||
onStatus?: (s: Status) => void
|
onStatus?: (s: Status) => void
|
||||||
onMap?: (mapKey: string) => void
|
onMap?: (mapKey: string) => void
|
||||||
onPlayerUpdate?: (p: any) => void
|
onPlayerUpdate?: (p: UnknownRecord) => void
|
||||||
onPlayersAll?: (payload: any) => void
|
onPlayersAll?: (payload: UnknownRecord) => void
|
||||||
onGrenades?: (g: any) => void
|
onGrenades?: (g: unknown) => void
|
||||||
onRoundStart?: () => void
|
onRoundStart?: () => void
|
||||||
onRoundEnd?: () => void
|
onRoundEnd?: () => void
|
||||||
onBomb?: (b:any) => void
|
onBomb?: (b: UnknownRecord) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// HINZUFÜGEN: oben im Modul – kleine Helfer
|
function isObj(v: unknown): v is UnknownRecord {
|
||||||
function pickVec3Loose(src: any) {
|
return !!v && typeof v === 'object'
|
||||||
// akzeptiert {x,y,z}, [x,y,z], "x, y, z"
|
}
|
||||||
|
|
||||||
|
/* akzeptiert {x,y,z}, [x,y,z], "x, y, z" */
|
||||||
|
function pickVec3Loose(src: unknown): { x: number; y: number; z: number } | null {
|
||||||
if (!src) return null
|
if (!src) return null
|
||||||
if (Array.isArray(src)) {
|
if (Array.isArray(src)) {
|
||||||
const [x, y, z] = src
|
const [x, y, z] = src as unknown[]
|
||||||
const nx = Number(x), ny = Number(y), nz = Number(z)
|
const nx = Number(x), ny = Number(y), nz = Number(z)
|
||||||
if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
|
if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (typeof src === 'string') {
|
if (typeof src === 'string') {
|
||||||
const parts = src.split(',').map(s => Number(s.trim()))
|
const parts = src.split(',').map((s) => Number(s.trim()))
|
||||||
if (parts.length >= 2 && parts.slice(0, 2).every(Number.isFinite)) {
|
if (parts.length >= 2 && parts.slice(0, 2).every(Number.isFinite)) {
|
||||||
return { x: parts[0], y: parts[1], z: Number.isFinite(parts[2]) ? parts[2] : 0 }
|
return { x: parts[0], y: parts[1], z: Number.isFinite(parts[2]) ? parts[2] : 0 }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const nx = Number(src?.x), ny = Number(src?.y), nz = Number(src?.z)
|
if (isObj(src)) {
|
||||||
|
const nx = Number((src as UnknownRecord).x)
|
||||||
|
const ny = Number((src as UnknownRecord).y)
|
||||||
|
const nz = Number((src as UnknownRecord).z)
|
||||||
if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
|
if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractBombPayload(msg: any): any | null {
|
function extractBombPayload(msg: UnknownRecord): UnknownRecord | null {
|
||||||
// 1) Wenn msg.bomb / msg.c4 schon da ist → ggf. Position aus bekannten Feldern ergänzen
|
const base = (msg.bomb as UnknownRecord | undefined) ?? (msg.c4 as UnknownRecord | undefined) ?? null
|
||||||
const base = msg?.bomb ?? msg?.c4 ?? null
|
|
||||||
|
|
||||||
// mögliche Felder, wo Positionsinfos oft landen
|
const posCandidates: unknown[] = [
|
||||||
const posCandidates = [
|
|
||||||
base?.pos, base?.position, base?.location, base?.coordinates, base?.origin,
|
base?.pos, base?.position, base?.location, base?.coordinates, base?.origin,
|
||||||
msg?.bomb_pos, msg?.bomb_position, msg?.bombLocation, msg?.bomblocation,
|
(msg as UnknownRecord)['bomb_pos'],
|
||||||
msg?.pos, msg?.position, msg?.location, msg?.coordinates, msg?.origin,
|
(msg as UnknownRecord)['bomb_position'],
|
||||||
msg?.world?.bomb, msg?.objectives?.bomb
|
(msg as UnknownRecord)['bombLocation'],
|
||||||
|
(msg as UnknownRecord)['bomblocation'],
|
||||||
|
msg.pos, msg.position, msg.location, msg.coordinates, msg.origin,
|
||||||
|
(msg.world as UnknownRecord | undefined)?.bomb,
|
||||||
|
(msg.objectives as UnknownRecord | undefined)?.bomb,
|
||||||
]
|
]
|
||||||
|
|
||||||
let P = null
|
let P: { x: number; y: number; z: number } | null = null
|
||||||
for (const p of posCandidates) { P = pickVec3Loose(p); if (P) break }
|
for (const p of posCandidates) {
|
||||||
|
P = pickVec3Loose(p)
|
||||||
|
if (P) break
|
||||||
|
}
|
||||||
|
|
||||||
// Status aus explizitem Feld oder vom Event-Type ableiten
|
const t = String((msg.type ?? '') as string).toLowerCase()
|
||||||
const t = String(msg?.type ?? '').toLowerCase()
|
|
||||||
let status: 'carried' | 'dropped' | 'planted' | 'defusing' | 'defused' | 'unknown' = 'unknown'
|
let status: 'carried' | 'dropped' | 'planted' | 'defusing' | 'defused' | 'unknown' = 'unknown'
|
||||||
const s = String(base?.status ?? base?.state ?? '').toLowerCase()
|
const s = String(((base as UnknownRecord | undefined)?.status ?? (base as UnknownRecord | undefined)?.state ?? '') as string).toLowerCase()
|
||||||
if (s.includes('plant')) status = 'planted'
|
if (s.includes('plant')) status = 'planted'
|
||||||
else if (s.includes('drop')) status = 'dropped'
|
else if (s.includes('drop')) status = 'dropped'
|
||||||
else if (s.includes('carry')) status = 'carried'
|
else if (s.includes('carry')) status = 'carried'
|
||||||
else if (s.includes('defus')) status = 'defusing'
|
else if (s.includes('defus') && !s.endsWith('ed')) status = 'defusing'
|
||||||
else if (s.includes('defus') && s.includes('ed')) status = 'defused'
|
else if (s.includes('defus') && s.includes('ed')) status = 'defused'
|
||||||
|
|
||||||
if (t === 'bomb_planted') status = 'planted'
|
if (t === 'bomb_planted') status = 'planted'
|
||||||
@ -69,94 +82,117 @@ function extractBombPayload(msg: any): any | null {
|
|||||||
else if (t === 'bomb_abortdefuse') status = 'planted'
|
else if (t === 'bomb_abortdefuse') status = 'planted'
|
||||||
else if (t === 'bomb_defused') status = 'defused'
|
else if (t === 'bomb_defused') status = 'defused'
|
||||||
|
|
||||||
// Wir wollen nur liefern, wenn NICHT getragen
|
|
||||||
const notCarried = status !== 'carried'
|
const notCarried = status !== 'carried'
|
||||||
|
|
||||||
if (!base && !P && !t.startsWith('bomb_')) return null
|
if (!base && !P && !t.startsWith('bomb_')) return null
|
||||||
if (!notCarried && !t.startsWith('bomb_')) return null
|
if (!notCarried && !t.startsWith('bomb_')) return null
|
||||||
|
|
||||||
const payload = {
|
const bombPayload: UnknownRecord = {
|
||||||
// Lass LiveRadar.normalizeBomb entscheiden – wir geben „bomb“ aus
|
...(base ?? {}),
|
||||||
bomb: {
|
|
||||||
...(base || {}),
|
|
||||||
...(P ? { x: P.x, y: P.y, z: P.z } : {}),
|
...(P ? { x: P.x, y: P.y, z: P.z } : {}),
|
||||||
status
|
status,
|
||||||
},
|
|
||||||
// original message für evtl. weitere Felder
|
|
||||||
type: msg?.type
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { bomb: bombPayload, type: msg.type }
|
||||||
|
}
|
||||||
|
|
||||||
export default function GameSocket(props: GameSocketProps) {
|
export default function GameSocket(props: GameSocketProps) {
|
||||||
const { url, onStatus, onMap, onPlayerUpdate, onPlayersAll, onGrenades, onRoundStart, onRoundEnd, onBomb } = props
|
const { url, onStatus, onMap, onPlayerUpdate, onPlayersAll, onGrenades, onRoundStart, onRoundEnd, onBomb } = props
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const retryRef = useRef<number | null>(null)
|
const retryRef = useRef<number | null>(null)
|
||||||
const connectTimerRef = useRef<number | null>(null) // <- NEU
|
const connectTimerRef = useRef<number | null>(null)
|
||||||
const shouldReconnectRef = useRef(true)
|
const shouldReconnectRef = useRef(true)
|
||||||
|
|
||||||
const dispatch = (msg: any) => {
|
const dispatch = useCallback(
|
||||||
if (!msg) return;
|
(msg: UnknownRecord) => {
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
if (msg.type === 'round_start') { onRoundStart?.(); return; }
|
const type = String((msg.type ?? '') as string)
|
||||||
if (msg.type === 'round_end') { onRoundEnd?.(); return; }
|
|
||||||
|
|
||||||
if (msg.type === 'tick') {
|
if (type === 'round_start') {
|
||||||
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase());
|
onRoundStart?.()
|
||||||
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}));
|
return
|
||||||
|
}
|
||||||
const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles;
|
if (type === 'round_end') {
|
||||||
if (g) onGrenades?.(g);
|
onRoundEnd?.()
|
||||||
|
return
|
||||||
// 1) Bisher: direkt durchreichen
|
|
||||||
if (msg.bomb) onBomb?.(msg.bomb);
|
|
||||||
|
|
||||||
// 2) NEU: Falls keine msg.bomb vorhanden, aber Position/Status auffindbar → synthetische Bomb-Payload senden
|
|
||||||
if (!msg.bomb) {
|
|
||||||
const synth = extractBombPayload(msg);
|
|
||||||
if (synth) onBomb?.(synth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onPlayersAll?.(msg);
|
if (type === 'tick') {
|
||||||
return;
|
const mapVal = msg.map
|
||||||
|
if (typeof mapVal === 'string') onMap?.(mapVal.toLowerCase())
|
||||||
|
const players = msg.players
|
||||||
|
if (Array.isArray(players)) {
|
||||||
|
const cb = onPlayerUpdate ?? (() => undefined)
|
||||||
|
for (const p of players) {
|
||||||
|
if (isObj(p)) cb(p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- non-tick messages (hello, map, bomb_* events, etc.) ---
|
const g =
|
||||||
|
(msg.grenades as unknown) ??
|
||||||
|
(msg.projectiles as unknown) ??
|
||||||
|
(msg.nades as unknown) ??
|
||||||
|
(msg.grenadeProjectiles as unknown)
|
||||||
|
if (g !== undefined) onGrenades?.(g)
|
||||||
|
|
||||||
|
if (msg.bomb) {
|
||||||
|
const b = msg.bomb
|
||||||
|
if (isObj(b)) onBomb?.(b)
|
||||||
|
} else {
|
||||||
|
const synth = extractBombPayload(msg)
|
||||||
|
if (synth) onBomb?.(synth)
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlayersAll?.(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-tick
|
||||||
if (typeof msg.map === 'string') {
|
if (typeof msg.map === 'string') {
|
||||||
onMap?.(msg.map.toLowerCase());
|
onMap?.(msg.map.toLowerCase())
|
||||||
} else if (msg.map && typeof msg.map.name === 'string') {
|
} else if (isObj(msg.map) && typeof (msg.map as UnknownRecord).name === 'string') {
|
||||||
onMap?.(msg.map.name.toLowerCase());
|
onMap?.(String((msg.map as UnknownRecord).name).toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.allplayers) onPlayersAll?.(msg);
|
if (msg.allplayers) onPlayersAll?.(msg)
|
||||||
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg);
|
|
||||||
|
|
||||||
const g2 = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles;
|
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg)
|
||||||
if (g2) onGrenades?.(g2);
|
|
||||||
|
|
||||||
// Bombe: generische Events + direkte bomb/c4-Payload
|
const g2 =
|
||||||
const t = String(msg.type || '').toLowerCase();
|
(msg.grenades as unknown) ??
|
||||||
|
(msg.projectiles as unknown) ??
|
||||||
|
(msg.nades as unknown) ??
|
||||||
|
(msg.grenadeProjectiles as unknown)
|
||||||
|
if (g2 !== undefined) onGrenades?.(g2)
|
||||||
|
|
||||||
|
const t = String((msg.type ?? '') as string).toLowerCase()
|
||||||
if (msg.bomb || msg.c4) {
|
if (msg.bomb || msg.c4) {
|
||||||
onBomb?.(msg); // unverändert weiterreichen
|
onBomb?.(msg)
|
||||||
} else if (t.startsWith('bomb_')) {
|
} else if (t.startsWith('bomb_')) {
|
||||||
// NEU: Event ohne bomb-Objekt → mit Position/Status anreichern
|
const enriched = extractBombPayload(msg)
|
||||||
const enriched = extractBombPayload(msg);
|
if (enriched) onBomb?.(enriched)
|
||||||
if (enriched) onBomb?.(enriched);
|
else onBomb?.(msg)
|
||||||
else onBomb?.(msg); // Fallback: Event trotzdem melden
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[onBomb, onGrenades, onMap, onPlayerUpdate, onPlayersAll, onRoundEnd, onRoundStart]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) return
|
if (!url) return
|
||||||
shouldReconnectRef.current = true
|
shouldReconnectRef.current = true
|
||||||
|
|
||||||
// evtl. alte Ressourcen räumen
|
try {
|
||||||
try { wsRef.current?.close(1000, 'replaced by new /radar visit') } catch {}
|
wsRef.current?.close(1000, 'replaced by new /radar visit')
|
||||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
} catch {}
|
||||||
if (connectTimerRef.current) { window.clearTimeout(connectTimerRef.current); connectTimerRef.current = null }
|
if (retryRef.current) {
|
||||||
|
window.clearTimeout(retryRef.current)
|
||||||
|
retryRef.current = null
|
||||||
|
}
|
||||||
|
if (connectTimerRef.current) {
|
||||||
|
window.clearTimeout(connectTimerRef.current)
|
||||||
|
connectTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (!shouldReconnectRef.current) return
|
if (!shouldReconnectRef.current) return
|
||||||
@ -175,31 +211,46 @@ export default function GameSocket(props: GameSocketProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
let msg: any = null
|
let parsed: unknown = null
|
||||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
try {
|
||||||
if (Array.isArray(msg)) msg.forEach(dispatch)
|
parsed = JSON.parse(String(ev.data ?? ''))
|
||||||
else dispatch(msg)
|
} catch {
|
||||||
|
parsed = null
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
for (const item of parsed as unknown[]) {
|
||||||
|
if (isObj(item)) dispatch(item)
|
||||||
|
}
|
||||||
|
} else if (isObj(parsed)) {
|
||||||
|
dispatch(parsed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// *** WICHTIG: leicht verzögert verbinden ***
|
|
||||||
// Verhindert das Fehl-Log im React-Strict-Mode (Mount->Unmount->Mount).
|
|
||||||
connectTimerRef.current = window.setTimeout(connect, 0)
|
connectTimerRef.current = window.setTimeout(connect, 0)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
shouldReconnectRef.current = false
|
shouldReconnectRef.current = false
|
||||||
|
|
||||||
if (connectTimerRef.current) { window.clearTimeout(connectTimerRef.current); connectTimerRef.current = null }
|
if (connectTimerRef.current) {
|
||||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
window.clearTimeout(connectTimerRef.current)
|
||||||
|
connectTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (retryRef.current) {
|
||||||
|
window.clearTimeout(retryRef.current)
|
||||||
|
retryRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
const ws = wsRef.current
|
const ws = wsRef.current
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.onclose = null // Reconnect nicht anstoßen
|
ws.onclose = null
|
||||||
try { ws.close(1000, 'left /radar') } catch {}
|
try {
|
||||||
|
ws.close(1000, 'left /radar')
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
onStatus?.('closed')
|
onStatus?.('closed')
|
||||||
}
|
}
|
||||||
}, [url])
|
}, [url, dispatch, onStatus])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,21 @@
|
|||||||
// /src/app/[locale]/components/radar/RadarCanvas.tsx
|
// /src/app/[locale]/components/radar/RadarCanvas.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
import Image from 'next/image'
|
||||||
import StaticEffects from './StaticEffects';
|
import StaticEffects from './StaticEffects';
|
||||||
import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';
|
import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';
|
||||||
import { contrastStroke } from './lib/helpers';
|
import { contrastStroke } from './lib/helpers';
|
||||||
import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types';
|
import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types';
|
||||||
|
|
||||||
|
type AvatarEntry = { avatar?: string; notFound?: boolean } | undefined
|
||||||
|
|
||||||
export default function RadarCanvas({
|
export default function RadarCanvas({
|
||||||
activeMapKey,
|
activeMapKey,
|
||||||
currentSrc, onImgLoad, onImgError,
|
currentSrc, onImgLoad, onImgError,
|
||||||
imgSize,
|
imgSize,
|
||||||
worldToPx, unitsToPx,
|
worldToPx, unitsToPx,
|
||||||
players, grenades, trails, deathMarkers,
|
players, grenades, trails, deathMarkers,
|
||||||
useAvatars, avatarById, hoveredPlayerId, setHoveredPlayerId,
|
useAvatars, avatarById, hoveredPlayerId,
|
||||||
myTeam,
|
myTeam,
|
||||||
beepState, bombFinal10,
|
beepState, bombFinal10,
|
||||||
bomb,
|
bomb,
|
||||||
@ -30,9 +33,8 @@ export default function RadarCanvas({
|
|||||||
trails: Trail[];
|
trails: Trail[];
|
||||||
deathMarkers: DeathMarker[];
|
deathMarkers: DeathMarker[];
|
||||||
useAvatars: boolean;
|
useAvatars: boolean;
|
||||||
avatarById: Record<string, any>;
|
avatarById: Record<string, AvatarEntry>;
|
||||||
hoveredPlayerId: string | null;
|
hoveredPlayerId: string | null;
|
||||||
setHoveredPlayerId: (id: string|null)=>void;
|
|
||||||
myTeam: 'T'|'CT'|string|null;
|
myTeam: 'T'|'CT'|string|null;
|
||||||
beepState: {key:number;dur:number}|null;
|
beepState: {key:number;dur:number}|null;
|
||||||
bombFinal10: boolean;
|
bombFinal10: boolean;
|
||||||
@ -50,9 +52,7 @@ export default function RadarCanvas({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const raw = activeMapKey.replace(/^de_/, '').replace(/[_-]+/g, ' ').trim()
|
const raw = activeMapKey.replace(/^de_/, '').replace(/[_-]+/g, ' ').trim()
|
||||||
// Leerzeichen zwischen Buchstabe↔Zahl einfügen (z.B. "dust2" -> "dust 2")
|
|
||||||
const spaced = raw.replace(/(\D)(\d)/g, '$1 $2')
|
const spaced = raw.replace(/(\D)(\d)/g, '$1 $2')
|
||||||
// Jedes Wort kapitalisieren
|
|
||||||
const pretty = spaced.replace(/\b\w/g, c => c.toUpperCase())
|
const pretty = spaced.replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -66,13 +66,16 @@ export default function RadarCanvas({
|
|||||||
|
|
||||||
{currentSrc ? (
|
{currentSrc ? (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<img
|
<Image
|
||||||
key={currentSrc}
|
key={currentSrc}
|
||||||
src={currentSrc}
|
src={currentSrc}
|
||||||
alt={activeMapKey ?? 'map'}
|
alt={activeMapKey ?? 'map'}
|
||||||
className="absolute inset-0 h-full w-full object-contain object-center"
|
fill
|
||||||
onLoad={(e) => onImgLoad(e.currentTarget)}
|
sizes="100vw"
|
||||||
onError={onImgError}
|
style={{ objectFit: 'contain', objectPosition: 'center' }}
|
||||||
|
onLoadingComplete={(img) => onImgLoad(img)}
|
||||||
|
onError={() => onImgError()}
|
||||||
|
priority={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{imgSize && (
|
{imgSize && (
|
||||||
@ -117,10 +120,6 @@ export default function RadarCanvas({
|
|||||||
{grenades.filter(shouldShowGrenade).map((g) => {
|
{grenades.filter(shouldShowGrenade).map((g) => {
|
||||||
const P = worldToPx(g.x, g.y);
|
const P = worldToPx(g.x, g.y);
|
||||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null;
|
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null;
|
||||||
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? 60));
|
|
||||||
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
|
|
||||||
: g.team === 'T' ? UI.nade.teamStrokeT
|
|
||||||
: UI.nade.stroke;
|
|
||||||
|
|
||||||
// projectile icon
|
// projectile icon
|
||||||
if (g.phase === 'projectile') {
|
if (g.phase === 'projectile') {
|
||||||
@ -181,7 +180,7 @@ export default function RadarCanvas({
|
|||||||
dxp *= dirLenPx / cur; dyp *= dirLenPx / cur;
|
dxp *= dirLenPx / cur; dyp *= dirLenPx / cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = avatarById[p.id] as any;
|
const entry = avatarById[p.id];
|
||||||
const avatarFromStore = entry && !entry?.notFound && entry?.avatar ? entry.avatar : null;
|
const avatarFromStore = entry && !entry?.notFound && entry?.avatar ? entry.avatar : null;
|
||||||
const avatarUrl = useAvatars ? (p.id.toUpperCase().startsWith('BOT:') ? BOT_ICON : (avatarFromStore || DEFAULT_AVATAR)) : null;
|
const avatarUrl = useAvatars ? (p.id.toUpperCase().startsWith('BOT:') ? BOT_ICON : (avatarFromStore || DEFAULT_AVATAR)) : null;
|
||||||
const isAvatar = !!avatarUrl;
|
const isAvatar = !!avatarUrl;
|
||||||
|
|||||||
@ -28,6 +28,11 @@ type Grenade = {
|
|||||||
effectTimeSec?: number
|
effectTimeSec?: number
|
||||||
lifeElapsedMs?: number
|
lifeElapsedMs?: number
|
||||||
lifeLeftMs?: number
|
lifeLeftMs?: number
|
||||||
|
/** lokal gemerkter Sichtungszeitpunkt (für Timer-Anzeige) */
|
||||||
|
firstSeenAt?: number | null
|
||||||
|
/** Server-/Parser-Felder für Feuer */
|
||||||
|
spreaded?: boolean
|
||||||
|
flamesCount?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
||||||
@ -62,7 +67,6 @@ function seedRng(seed: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function StaticEffects({
|
export default function StaticEffects({
|
||||||
grenades,
|
grenades,
|
||||||
bomb,
|
bomb,
|
||||||
@ -111,7 +115,7 @@ export default function StaticEffects({
|
|||||||
|
|
||||||
// ── TIMER: Immer lokal bei 20s starten, unabhängig von Serverzeiten
|
// ── TIMER: Immer lokal bei 20s starten, unabhängig von Serverzeiten
|
||||||
const DISPLAY_TIMER_MS = 20_000
|
const DISPLAY_TIMER_MS = 20_000
|
||||||
const firstSeenAt = (g as any).firstSeenAt ?? g.spawnedAt ?? Date.now()
|
const firstSeenAt = (g.firstSeenAt ?? g.spawnedAt) ?? Date.now()
|
||||||
const timerLeftMs = Math.max(0, DISPLAY_TIMER_MS - (Date.now() - firstSeenAt))
|
const timerLeftMs = Math.max(0, DISPLAY_TIMER_MS - (Date.now() - firstSeenAt))
|
||||||
const timerSecs = Math.ceil(timerLeftMs / 1000)
|
const timerSecs = Math.ceil(timerLeftMs / 1000)
|
||||||
const timerAlpha = Math.min(1, timerLeftMs / 1000) // in letzter Sekunde ausblenden
|
const timerAlpha = Math.min(1, timerLeftMs / 1000) // in letzter Sekunde ausblenden
|
||||||
@ -230,16 +234,12 @@ export default function StaticEffects({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const molotovNode = (g: Grenade) => {
|
const molotovNode = (g: Grenade) => {
|
||||||
const P = worldToPx(g.x, g.y)
|
const P = worldToPx(g.x, g.y)
|
||||||
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||||
// 1.0 = wie jetzt. 1.3..1.8 = größerer Radius
|
// 1.0 = wie jetzt. 1.3..1.8 = größerer Radius
|
||||||
const FIRE_RADIUS_MULT = 2
|
const FIRE_RADIUS_MULT = 2
|
||||||
|
|
||||||
const stroke = g.team === 'CT' ? ui.nade.teamStrokeCT : g.team === 'T' ? ui.nade.teamStrokeT : ui.nade.stroke
|
|
||||||
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
|
||||||
|
|
||||||
// Größen analog zum CodePen (Container ~10em breit, Rise ~10em)
|
// Größen analog zum CodePen (Container ~10em breit, Rise ~10em)
|
||||||
const coverPx = rPx * FIRE_RADIUS_MULT // „Radius“ der Fläche
|
const coverPx = rPx * FIRE_RADIUS_MULT // „Radius“ der Fläche
|
||||||
const W = Math.max(26, coverPx * 1.45) // Breite des Feuers
|
const W = Math.max(26, coverPx * 1.45) // Breite des Feuers
|
||||||
@ -256,6 +256,11 @@ export default function StaticEffects({
|
|||||||
const gradPartId = `molo-fire-grad-${g.id}`
|
const gradPartId = `molo-fire-grad-${g.id}`
|
||||||
const blurPartId = `molo-fire-blur-${g.id}`
|
const blurPartId = `molo-fire-blur-${g.id}`
|
||||||
|
|
||||||
|
// CSS-Var für --rise typsicher setzen
|
||||||
|
const riseStyle: React.CSSProperties & { ['--rise']?: string } = {
|
||||||
|
['--rise']: `${Math.round(RISE)}px`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={g.id}>
|
<g key={g.id}>
|
||||||
<defs>
|
<defs>
|
||||||
@ -270,7 +275,7 @@ export default function StaticEffects({
|
|||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Partikel exakt bei P, über die Breite verteilt */}
|
{/* Partikel exakt bei P, über die Breite verteilt */}
|
||||||
<g transform={`translate(${P.x}, ${P.y})`} style={{ ['--rise' as any]: `${Math.round(RISE)}px` }}>
|
<g transform={`translate(${P.x}, ${P.y})`} style={riseStyle}>
|
||||||
{Array.from({ length: PARTS }).map((_, i) => {
|
{Array.from({ length: PARTS }).map((_, i) => {
|
||||||
// „left: calc((100% - partSize) * (i/parts))“
|
// „left: calc((100% - partSize) * (i/parts))“
|
||||||
const leftX = (i / PARTS) * (W - PART_SIZE) - (W/2 - PART_SIZE/2)
|
const leftX = (i / PARTS) * (W - PART_SIZE) - (W/2 - PART_SIZE/2)
|
||||||
@ -283,7 +288,7 @@ export default function StaticEffects({
|
|||||||
fill={`url(#${gradPartId})`}
|
fill={`url(#${gradPartId})`}
|
||||||
filter={`url(#${blurPartId})`}
|
filter={`url(#${blurPartId})`}
|
||||||
style={{
|
style={{
|
||||||
mixBlendMode: 'screen' as any,
|
mixBlendMode: 'screen',
|
||||||
animation: `molotovFireRise ${DUR_MS}ms ease-in infinite`,
|
animation: `molotovFireRise ${DUR_MS}ms ease-in infinite`,
|
||||||
animationDelay: `${Math.round(DUR_MS * rnd())}ms`,
|
animationDelay: `${Math.round(DUR_MS * rnd())}ms`,
|
||||||
transformBox: 'fill-box',
|
transformBox: 'fill-box',
|
||||||
@ -299,10 +304,6 @@ export default function StaticEffects({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const decoyNode = (g: Grenade) => {
|
const decoyNode = (g: Grenade) => {
|
||||||
const P = worldToPx(g.x, g.y)
|
const P = worldToPx(g.x, g.y)
|
||||||
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||||
@ -359,7 +360,7 @@ export default function StaticEffects({
|
|||||||
cx={P.x} cy={P.y} r={rBase}
|
cx={P.x} cy={P.y} r={rBase}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={bombFinal10 ? '#ef4444' : '#f59e0b'}
|
stroke={bombFinal10 ? '#ef4444' : '#f59e0b'}
|
||||||
strokeWidth={2} // vorher 3
|
strokeWidth={2}
|
||||||
style={{
|
style={{
|
||||||
transformBox: 'fill-box',
|
transformBox: 'fill-box',
|
||||||
transformOrigin: 'center',
|
transformOrigin: 'center',
|
||||||
@ -410,8 +411,8 @@ export default function StaticEffects({
|
|||||||
if (g.kind === 'smoke') return smokeNode(g)
|
if (g.kind === 'smoke') return smokeNode(g)
|
||||||
|
|
||||||
if (g.kind === 'molotov' || g.kind === 'incendiary') {
|
if (g.kind === 'molotov' || g.kind === 'incendiary') {
|
||||||
const spreaded = (g as any).spreaded === true || ((g as any).flamesCount ?? 0) > 0
|
const spreaded = g.spreaded === true || ((g.flamesCount ?? 0) > 0)
|
||||||
if (!spreaded) return null // << Nur zeigen, wenn wirklich Flames vorhanden/spreaded
|
if (!spreaded) return null // Nur zeigen, wenn wirklich Flames vorhanden/spreaded
|
||||||
return molotovNode(g)
|
return molotovNode(g)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// /src/app/[locale]/components/radar/TeamSidebar.tsx
|
// /src/app/[locale]/components/radar/TeamSidebar.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'
|
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'
|
||||||
|
|
||||||
export type Team = 'T' | 'CT'
|
export type Team = 'T' | 'CT'
|
||||||
@ -39,7 +39,7 @@ const ShieldIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── Rotes Bomben-Icon via CSS-Maske, damit es sicher rot ist ── */
|
/* ── Rotes Bomben-Icon via CSS-Maske ── */
|
||||||
const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string; title?: string; className?: string }) => (
|
const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string; title?: string; className?: string }) => (
|
||||||
<span
|
<span
|
||||||
title={title}
|
title={title}
|
||||||
@ -59,7 +59,18 @@ const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string;
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── Gear Blöcke (links/rechts trennen) ── */
|
/* ── kleine Image-Hilfen ── */
|
||||||
|
function IconImg({
|
||||||
|
src, alt, title, w, h, className,
|
||||||
|
}: { src: string; alt: string; title?: string; w: number; h: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={className} title={title} aria-label={title}>
|
||||||
|
<Image src={src} alt={alt} width={w} height={h} unoptimized />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gear Blöcke ── */
|
||||||
function leftGear(opts: { armor?: number|null; helmet?: boolean|null }) {
|
function leftGear(opts: { armor?: number|null; helmet?: boolean|null }) {
|
||||||
const out: { src: string; title: string; key: string }[] = []
|
const out: { src: string; title: string; key: string }[] = []
|
||||||
if ((opts.armor ?? 0) > 0) out.push({ src: equipIcon('armor.svg'), title: 'Kevlar', key: 'armor' })
|
if ((opts.armor ?? 0) > 0) out.push({ src: equipIcon('armor.svg'), title: 'Kevlar', key: 'armor' })
|
||||||
@ -143,13 +154,6 @@ function grenadeIconFromKey(k: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeWeaponNameOf(w?: string | { name?: string | null } | null): string | null {
|
|
||||||
if (!w) return null
|
|
||||||
if (typeof w === 'string') return w
|
|
||||||
if (typeof w === 'object' && w?.name) return w.name
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TeamSidebar({
|
export default function TeamSidebar({
|
||||||
team, teamId, players, align = 'left', onHoverPlayer, score, oppScore
|
team, teamId, players, align = 'left', onHoverPlayer, score, oppScore
|
||||||
}: {
|
}: {
|
||||||
@ -203,35 +207,39 @@ export default function TeamSidebar({
|
|||||||
return (a.name ?? '').localeCompare(b.name ?? '')
|
return (a.name ?? '').localeCompare(b.name ?? '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// lokaler Typ für Avatar-Lookup (eliminiert 'any')
|
||||||
|
type AvatarEntry = { avatar?: string; notFound?: boolean }
|
||||||
|
const byId: Record<string, AvatarEntry | undefined> = avatarById as Record<string, AvatarEntry | undefined>
|
||||||
|
void avatarVer // nur, um Re-Render bei Versionswechsel zu triggern
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="h-full min-h-0 flex flex-col rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2 overflow-hidden">
|
<aside className="h-full min-h-0 flex flex-col rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between text-xs uppercase tracking-wide opacity-80">
|
<div className="flex items-center justify-between text-xs uppercase tracking-wide opacity-80">
|
||||||
<span className={`font-semibold flex items-center gap-2 ${teamColor}`}>
|
<span className={`font-semibold flex items-center gap-2 ${teamColor}`}>
|
||||||
{/* Logo größer */}
|
{/* Logo größer */}
|
||||||
<img src={logoSrc} alt={teamName} className="w-7 h-7 md:w-8 md:h-8 object-contain" />
|
<span className="relative block h-8 w-8 md:h-9 md:w-9">
|
||||||
|
<Image src={logoSrc} alt={teamName} fill sizes="36px" className="object-contain" unoptimized />
|
||||||
|
</span>
|
||||||
<span className="hidden sm:inline">{teamName}</span>
|
<span className="hidden sm:inline">{teamName}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{/* Score-Pill in der Sidebar */}
|
|
||||||
{(typeof score === 'number' && typeof oppScore === 'number') && (
|
{(typeof score === 'number' && typeof oppScore === 'number') && (
|
||||||
<span className="px-2 py-0.5 rounded bg-black/45 text-white text-[11px] font-semibold tabular-nums">
|
<span className="px-2 py-0.5 rounded bg-black/45 text-white text-[11px] font-semibold tabular-nums">
|
||||||
{score}<span className="opacity-60 mx-1">:</span>{oppScore}
|
{score}<span className="opacity-60 mx-1">:</span>{oppScore}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Alive-Count bleibt */}
|
|
||||||
<span className="tabular-nums">{aliveCount}/{players.length}</span>
|
<span className="tabular-nums">{aliveCount}/{players.length}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
|
<div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
|
||||||
{sorted.map(p=>{
|
{sorted.map(p=>{
|
||||||
void avatarVer
|
|
||||||
const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100)
|
const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100)
|
||||||
const armor = clamp(p.armor ?? 0, 0, 100)
|
const armor = clamp(p.armor ?? 0, 0, 100)
|
||||||
const dead = p.alive === false || hp <= 0
|
const dead = p.alive === false || hp <= 0
|
||||||
const entry = avatarById[p.id] as any
|
const entry: AvatarEntry | undefined = byId[p.id]
|
||||||
const avatarUrl = isBotId(p.id)
|
const avatarUrl = isBotId(p.id)
|
||||||
? BOT_ICON
|
? BOT_ICON
|
||||||
: (entry && !entry?.notFound && entry?.avatar ? entry.avatar : '/assets/img/avatars/default_steam_avatar.jpg')
|
: (entry && !entry?.notFound && entry?.avatar ? entry.avatar : '/assets/img/avatars/default_steam_avatar.jpg')
|
||||||
@ -271,19 +279,23 @@ export default function TeamSidebar({
|
|||||||
<div className={`flex ${isRight ? 'flex-row-reverse text-right' : 'flex-row'} items-center gap-3`}>
|
<div className={`flex ${isRight ? 'flex-row-reverse text-right' : 'flex-row'} items-center gap-3`}>
|
||||||
{/* Avatar mit Bomben-Glow / Dead-Desaturierung */}
|
{/* Avatar mit Bomben-Glow / Dead-Desaturierung */}
|
||||||
<div className={`rounded-full ${p.hasBomb ? 'ring-2 ring-red-500/70 shadow-[0_0_12px_rgba(239,68,68,.35)]' : ''}`}>
|
<div className={`rounded-full ${p.hasBomb ? 'ring-2 ring-red-500/70 shadow-[0_0_12px_rgba(239,68,68,.35)]' : ''}`}>
|
||||||
<img
|
<span className={`relative block h-12 w-12 rounded-full border border-white/10 ring-2 ${ringColor} bg-neutral-900 p-1 overflow-hidden ${dead ? 'grayscale opacity-70' : ''}`}>
|
||||||
|
<Image
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={p.name || p.id}
|
alt={p.name || p.id}
|
||||||
className={`w-12 h-12 rounded-full border border-white/10 ring-2 ${ringColor} bg-neutral-900 object-contain p-1 ${dead ? 'grayscale opacity-70' : ''}`}
|
fill
|
||||||
width={48} height={48} loading="lazy"
|
sizes="48px"
|
||||||
|
className="object-contain"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex-1 min-w-0 flex flex-col ${isRight ? 'items-end' : 'items-start'}`}>
|
<div className={`flex-1 min-w-0 flex flex-col ${isRight ? 'items-end' : 'items-start'}`}>
|
||||||
{/* Kopfzeile: Name & Gear je Seite */}
|
{/* Kopfzeile: Name & Gear */}
|
||||||
{!isRight ? (
|
{!isRight ? (
|
||||||
<div className="flex items-center justify-between w-full min-h-[22px] gap-2">
|
<div className="flex items-center justify-between w-full min-h-[22px] gap-2">
|
||||||
<span className={`truncate font-medium text-left tracking-wide [font-variant-numeric:tabular-nums]`}>
|
<span className="truncate font-medium text-left tracking-wide [font-variant-numeric:tabular-nums]">
|
||||||
{p.name || p.id}
|
{p.name || p.id}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
@ -292,7 +304,7 @@ export default function TeamSidebar({
|
|||||||
).map(icon => (
|
).map(icon => (
|
||||||
icon.key === 'c4'
|
icon.key === 'c4'
|
||||||
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
|
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
|
||||||
: <img key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} className="h-5 w-5 opacity-90" />
|
: <IconImg key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} w={20} h={20} className="inline-flex" />
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -304,16 +316,16 @@ export default function TeamSidebar({
|
|||||||
).map(icon => (
|
).map(icon => (
|
||||||
icon.key === 'c4'
|
icon.key === 'c4'
|
||||||
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
|
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
|
||||||
: <img key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} className="h-5 w-5 opacity-90" />
|
: <IconImg key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} w={20} h={20} className="inline-flex" />
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className={`truncate font-medium text-right tracking-wide [font-variant-numeric:tabular-nums]`}>
|
<span className="truncate font-medium text-right tracking-wide [font-variant-numeric:tabular-nums]">
|
||||||
{p.name || p.id}
|
{p.name || p.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Waffenzeile: Primär (links/rechts je nach align) — Sekundär+Messer auf der Gegenseite */}
|
{/* Waffenzeile */}
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'mt-1 w-full flex items-center',
|
'mt-1 w-full flex items-center',
|
||||||
@ -325,11 +337,13 @@ export default function TeamSidebar({
|
|||||||
{/* Primär */}
|
{/* Primär */}
|
||||||
{primIcon && (
|
{primIcon && (
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<img
|
<IconImg
|
||||||
src={primIcon}
|
src={primIcon}
|
||||||
alt={prim?.name ?? 'primary'}
|
alt={prim?.name ?? 'primary'}
|
||||||
title={prim?.name ?? 'primary'}
|
title={prim?.name ?? 'primary'}
|
||||||
className={`h-16 w-16 transition filter p-2 rounded-md ${
|
w={64}
|
||||||
|
h={64}
|
||||||
|
className={`p-2 rounded-md ${
|
||||||
primActive
|
primActive
|
||||||
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30'
|
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30'
|
||||||
: 'grayscale brightness-90 contrast-75 opacity-90'
|
: 'grayscale brightness-90 contrast-75 opacity-90'
|
||||||
@ -338,52 +352,52 @@ export default function TeamSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sekundär + Messer (als Gruppe) */}
|
{/* Sekundär + Messer */}
|
||||||
{(secIcon || knifeIcon) && (
|
{(secIcon || knifeIcon) && (
|
||||||
<div
|
<div className={['flex items-center gap-2', !primIcon ? (isRight ? 'justify-end' : 'justify-start') : ''].join(' ')}>
|
||||||
className={[
|
|
||||||
'flex items-center gap-2',
|
|
||||||
// Wenn keine Primärwaffe existiert, die Gruppe passend ausrichten
|
|
||||||
!primIcon ? (isRight ? 'justify-end' : 'justify-start') : ''
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{secIcon && (
|
{secIcon && (
|
||||||
<img
|
<IconImg
|
||||||
src={secIcon}
|
src={secIcon}
|
||||||
alt={sec?.name ?? 'secondary'}
|
alt={sec?.name ?? 'secondary'}
|
||||||
title={sec?.name ?? 'secondary'}
|
title={sec?.name ?? 'secondary'}
|
||||||
className={`h-10 w-10 transition filter p-2 rounded-md ${
|
w={40}
|
||||||
secActive ? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30' : 'grayscale brightness-90 contrast-75 opacity-90'
|
h={40}
|
||||||
|
className={`p-2 rounded-md ${
|
||||||
|
secActive
|
||||||
|
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30'
|
||||||
|
: 'grayscale brightness-90 contrast-75 opacity-90'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{knifeIcon && (
|
{knifeIcon && (
|
||||||
<img
|
<IconImg
|
||||||
src={knifeIcon}
|
src={knifeIcon}
|
||||||
alt={knife?.name ?? 'knife'}
|
alt={knife?.name ?? 'knife'}
|
||||||
title={knife?.name ?? 'knife'}
|
title={knife?.name ?? 'knife'}
|
||||||
className={`h-10 w-10 transition filter ${
|
w={40}
|
||||||
knifeActive ? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2' : 'grayscale brightness-90 contrast-75 opacity-90'
|
h={40}
|
||||||
}`}
|
className={knifeActive
|
||||||
|
? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2'
|
||||||
|
: 'grayscale brightness-90 contrast-75 opacity-90'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Granaten: ohne Count; Icon mehrfach je Anzahl */}
|
{/* Granaten */}
|
||||||
<div className={`mt-2 flex items-center gap-1 ${isRight ? 'justify-start' : 'justify-end'}`}>
|
<div className={`mt-2 flex items-center gap-1 ${isRight ? 'justify-start' : 'justify-end'}`}>
|
||||||
{GRENADE_DISPLAY_ORDER.flatMap(k=>{
|
{GRENADE_DISPLAY_ORDER.flatMap(k=>{
|
||||||
const c = p.grenades?.[k] ?? 0
|
const c = p.grenades?.[k] ?? 0
|
||||||
if (!c) return []
|
if (!c) return []
|
||||||
const src = grenadeIconFromKey(k)
|
const src = grenadeIconFromKey(k)
|
||||||
return Array.from({ length: c }, (_,i)=>( // je Anzahl ein Icon
|
return Array.from({ length: c }, (_,i)=>(
|
||||||
<img key={`${k}-${i}`} src={src} alt={k} title={k} className="h-4 w-4 opacity-90" />
|
<IconImg key={`${k}-${i}`} src={src} alt={k} title={k} w={16} h={16} />
|
||||||
))
|
))
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HP / Armor Bars (SVG-Icons weiß) */}
|
{/* HP / Armor Bars */}
|
||||||
<div className="mt-2 w-full space-y-2">
|
<div className="mt-2 w-full space-y-2">
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<div
|
<div
|
||||||
@ -393,16 +407,13 @@ export default function TeamSidebar({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
// nur der Füllbalken bekommt ggf. das Blinken
|
|
||||||
'h-full transition-[width] duration-300 ease-out',
|
'h-full transition-[width] duration-300 ease-out',
|
||||||
hp > 66 ? 'bg-green-500' : hp > 20 ? 'bg-amber-500' : 'bg-red-500',
|
hp > 66 ? 'bg-green-500' : hp > 20 ? 'bg-amber-500' : 'bg-red-500',
|
||||||
hp > 0 && hp <= 20 ? 'animate-hpPulse' : ''
|
hp > 0 && hp <= 20 ? 'animate-hpPulse' : ''
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={{ width: `${hp}%` }}
|
style={{ width: `${hp}%` }}
|
||||||
/>
|
/>
|
||||||
{/* Ticks */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 opacity-70 mix-blend-overlay bg-[repeating-linear-gradient(to_right,transparent,transparent_11px,rgba(255,255,255,0.06)_12px)]" />
|
<div className="pointer-events-none absolute inset-0 opacity-70 mix-blend-overlay bg-[repeating-linear-gradient(to_right,transparent,transparent_11px,rgba(255,255,255,0.06)_12px)]" />
|
||||||
{/* Label */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-between px-2 text-[11px] font-semibold text-white/95">
|
<div className="absolute inset-0 flex items-center justify-between px-2 text-[11px] font-semibold text-white/95">
|
||||||
<span className="flex items-center gap-1 select-none"><HeartIcon /></span>
|
<span className="flex items-center gap-1 select-none"><HeartIcon /></span>
|
||||||
<span className="tabular-nums select-none drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">{hp}</span>
|
<span className="tabular-nums select-none drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">{hp}</span>
|
||||||
|
|||||||
@ -42,19 +42,16 @@ export function useOverview(activeMapKey: string | null, playersForAutoFit: {x:n
|
|||||||
return () => { cancel = true; };
|
return () => { cancel = true; };
|
||||||
}, [activeMapKey]);
|
}, [activeMapKey]);
|
||||||
|
|
||||||
const { folderKey, imageCandidates } = useMemo(() => {
|
const imageCandidates = useMemo(() => {
|
||||||
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] };
|
if (!activeMapKey) return [] as string[];
|
||||||
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey;
|
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey;
|
||||||
const base = `/assets/img/radar/${activeMapKey}`;
|
const base = `/assets/img/radar/${activeMapKey}`;
|
||||||
return {
|
return [
|
||||||
folderKey: short,
|
|
||||||
imageCandidates: [
|
|
||||||
`${base}/de_${short}_radar_psd.png`,
|
`${base}/de_${short}_radar_psd.png`,
|
||||||
`${base}/de_${short}_lower_radar_psd.png`,
|
`${base}/de_${short}_lower_radar_psd.png`,
|
||||||
`${base}/de_${short}_v1_radar_psd.png`,
|
`${base}/de_${short}_v1_radar_psd.png`,
|
||||||
`${base}/de_${short}_radar.png`,
|
`${base}/de_${short}_radar.png`,
|
||||||
],
|
];
|
||||||
};
|
|
||||||
}, [activeMapKey]);
|
}, [activeMapKey]);
|
||||||
|
|
||||||
const currentSrc = imageCandidates[srcIdx];
|
const currentSrc = imageCandidates[srcIdx];
|
||||||
|
|||||||
@ -1,11 +1,29 @@
|
|||||||
// /src/app/[locale]/components/radar/hooks/useRadarState.ts
|
// /src/app/[locale]/components/radar/hooks/useRadarState.ts
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '../lib/types';
|
import {
|
||||||
|
BombState,
|
||||||
|
DeathMarker,
|
||||||
|
Grenade,
|
||||||
|
PlayerState,
|
||||||
|
Score,
|
||||||
|
Trail,
|
||||||
|
WsStatus,
|
||||||
|
} from '../lib/types';
|
||||||
import { UI } from '../lib/ui';
|
import { UI } from '../lib/ui';
|
||||||
import { asNum, mapTeam, steamIdOf } from '../lib/helpers';
|
import { asNum, mapTeam, steamIdOf } from '../lib/helpers';
|
||||||
import { normalizeGrenades } from '../lib/grenades';
|
import { normalizeGrenades } from '../lib/grenades';
|
||||||
|
|
||||||
|
/* ---------- kleine Safe-Helper ---------- */
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
const isObj = (v: unknown): v is UnknownRecord => !!v && typeof v === 'object';
|
||||||
|
const getObj = (v: unknown): UnknownRecord | undefined => (isObj(v) ? v : undefined);
|
||||||
|
const getArr = (v: unknown): unknown[] | undefined => (Array.isArray(v) ? v : undefined);
|
||||||
|
const num = (v: unknown, def?: number) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : def;
|
||||||
|
};
|
||||||
|
|
||||||
export function useRadarState(mySteamId: string | null) {
|
export function useRadarState(mySteamId: string | null) {
|
||||||
// WS / Map
|
// WS / Map
|
||||||
const [radarWsStatus, setGameWsStatus] = useState<WsStatus>('idle');
|
const [radarWsStatus, setGameWsStatus] = useState<WsStatus>('idle');
|
||||||
@ -19,7 +37,7 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
// Deaths
|
// Deaths
|
||||||
const deathSeqRef = useRef(0);
|
const deathSeqRef = useRef(0);
|
||||||
const deathSeenRef = useRef<Set<string>>(new Set());
|
const deathSeenRef = useRef<Set<string>>(new Set());
|
||||||
const lastAlivePosRef = useRef<Map<string, {x:number,y:number}>>(new Map());
|
const lastAlivePosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||||
|
|
||||||
// Grenaden + Trails
|
// Grenaden + Trails
|
||||||
const grenadesRef = useRef<Map<string, Grenade>>(new Map());
|
const grenadesRef = useRef<Map<string, Grenade>>(new Map());
|
||||||
@ -40,7 +58,11 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
useState<'freezetime' | 'live' | 'bomb' | 'over' | 'warmup' | 'unknown'>('unknown');
|
useState<'freezetime' | 'live' | 'bomb' | 'over' | 'warmup' | 'unknown'>('unknown');
|
||||||
const roundEndsAtRef = useRef<number | null>(null);
|
const roundEndsAtRef = useRef<number | null>(null);
|
||||||
const bombEndsAtRef = useRef<number | null>(null);
|
const bombEndsAtRef = useRef<number | null>(null);
|
||||||
const defuseRef = useRef<{ by: string|null; hasKit: boolean; endsAt: number|null }>({ by: null, hasKit: false, endsAt: null });
|
const defuseRef = useRef<{ by: string | null; hasKit: boolean; endsAt: number | null }>({
|
||||||
|
by: null,
|
||||||
|
hasKit: false,
|
||||||
|
endsAt: null,
|
||||||
|
});
|
||||||
const [score, setScore] = useState<Score>({ ct: 0, t: 0, round: null });
|
const [score, setScore] = useState<Score>({ ct: 0, t: 0, round: null });
|
||||||
|
|
||||||
// flush-batching
|
// flush-batching
|
||||||
@ -58,14 +80,21 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
}, 66);
|
}, 66);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(
|
||||||
if (flushTimer.current != null) { window.clearTimeout(flushTimer.current); flushTimer.current = null; }
|
() => () => {
|
||||||
}, []);
|
if (flushTimer.current != null) {
|
||||||
|
window.clearTimeout(flushTimer.current);
|
||||||
|
flushTimer.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ⚠️ nur von mySteamId abhängig (playersRef.current wird direkt gelesen)
|
||||||
const myTeam = useMemo<'T' | 'CT' | string | null>(() => {
|
const myTeam = useMemo<'T' | 'CT' | string | null>(() => {
|
||||||
if (!mySteamId) return null;
|
if (!mySteamId) return null;
|
||||||
return playersRef.current.get(mySteamId)?.team ?? null;
|
return playersRef.current.get(mySteamId)?.team ?? null;
|
||||||
}, [players, mySteamId]);
|
}, [mySteamId]);
|
||||||
|
|
||||||
const addDeathMarker = (x: number, y: number, steamId?: string) => {
|
const addDeathMarker = (x: number, y: number, steamId?: string) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -79,8 +108,8 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
|
|
||||||
const addDeathMarkerFor = (id: string, xNow: number, yNow: number) => {
|
const addDeathMarkerFor = (id: string, xNow: number, yNow: number) => {
|
||||||
const last = lastAlivePosRef.current.get(id);
|
const last = lastAlivePosRef.current.get(id);
|
||||||
const x = Number.isFinite(last?.x) ? last!.x : xNow;
|
const x = Number.isFinite(last?.x) ? (last as { x: number; y: number }).x : xNow;
|
||||||
const y = Number.isFinite(last?.y) ? last!.y : yNow;
|
const y = Number.isFinite(last?.y) ? (last as { x: number; y: number }).y : yNow;
|
||||||
addDeathMarker(x, y, id);
|
addDeathMarker(x, y, id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -104,66 +133,87 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
|
|
||||||
const updateBombFromPlayers = () => {
|
const updateBombFromPlayers = () => {
|
||||||
if (bombRef.current?.status === 'planted') return;
|
if (bombRef.current?.status === 'planted') return;
|
||||||
const carrier = Array.from(playersRef.current.values()).find(p => p.hasBomb);
|
const carrier = Array.from(playersRef.current.values()).find((p) => p.hasBomb);
|
||||||
if (carrier) {
|
if (carrier) {
|
||||||
bombRef.current = {
|
bombRef.current = {
|
||||||
x: carrier.x, y: carrier.y, z: carrier.z,
|
x: carrier.x,
|
||||||
|
y: carrier.y,
|
||||||
|
z: carrier.z,
|
||||||
status: 'carried',
|
status: 'carried',
|
||||||
changedAt: bombRef.current?.status === 'carried'
|
changedAt:
|
||||||
? bombRef.current.changedAt
|
bombRef.current?.status === 'carried' ? bombRef.current.changedAt : Date.now(),
|
||||||
: Date.now(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Player Upsert (gekürzt – Logik aus deiner Datei) --------------
|
/* ---------- Player-Upsert (typsicher) ---------- */
|
||||||
function upsertPlayer(e:any) {
|
function upsertPlayer(e: unknown) {
|
||||||
const id = steamIdOf(e); if (!id) return;
|
const src = getObj(e);
|
||||||
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates;
|
if (!src) return;
|
||||||
const x = asNum(e.x ?? (Array.isArray(pos) ? pos?.[0] : pos?.x));
|
|
||||||
const y = asNum(e.y ?? (Array.isArray(pos) ? pos?.[1] : pos?.y));
|
const id = steamIdOf(src);
|
||||||
const z = asNum(e.z ?? (Array.isArray(pos) ? pos?.[2] : pos?.z), 0);
|
if (!id) return;
|
||||||
|
|
||||||
|
const pos = src.pos ?? src.position ?? src.location ?? src.coordinates;
|
||||||
|
const x = asNum(src.x ?? (Array.isArray(pos) ? pos?.[0] : (pos as UnknownRecord | undefined)?.x));
|
||||||
|
const y = asNum(src.y ?? (Array.isArray(pos) ? pos?.[1] : (pos as UnknownRecord | undefined)?.y));
|
||||||
|
const z = asNum(src.z ?? (Array.isArray(pos) ? pos?.[2] : (pos as UnknownRecord | undefined)?.z), 0);
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||||
|
|
||||||
const hpProbe = asNum(e.hp ?? e.health ?? e.state?.health, NaN);
|
|
||||||
const old = playersRef.current.get(id);
|
const old = playersRef.current.get(id);
|
||||||
const nextAlive = Number.isFinite(hpProbe) ? hpProbe > 0 : (old?.alive ?? true);
|
|
||||||
if ((old?.alive !== false) && nextAlive === false) addDeathMarker(x, y, id);
|
|
||||||
|
|
||||||
|
const hpProbe = asNum(src.hp ?? (getObj(src.state)?.health), NaN);
|
||||||
|
const nextAlive = Number.isFinite(hpProbe) ? (hpProbe as number) > 0 : (old?.alive ?? true);
|
||||||
|
|
||||||
|
if ((old?.alive !== false) && nextAlive === false) addDeathMarker(x, y, id);
|
||||||
if (nextAlive === true) lastAlivePosRef.current.set(id, { x, y });
|
if (nextAlive === true) lastAlivePosRef.current.set(id, { x, y });
|
||||||
else if (nextAlive === false && (old?.alive !== false)) addDeathMarkerFor(id, x, y);
|
else if (nextAlive === false && (old?.alive !== false)) addDeathMarkerFor(id, x, y);
|
||||||
|
|
||||||
|
type WeaponRec = { name?: unknown; state?: unknown } | string;
|
||||||
|
const weaponsArr = getArr(src.weapons) as WeaponRec[] | undefined;
|
||||||
|
const activeFromArr =
|
||||||
|
weaponsArr?.find((w) => isObj(w) && String(w.state ?? '').toLowerCase() === 'active') ?? null;
|
||||||
const activeWeaponName =
|
const activeWeaponName =
|
||||||
(typeof e.activeWeapon === 'string' && e.activeWeapon) ||
|
(typeof src.activeWeapon === 'string' && src.activeWeapon) ||
|
||||||
(e.activeWeapon?.name ?? null) ||
|
(getObj(src.activeWeapon)?.name as string | undefined) ||
|
||||||
(Array.isArray(e.weapons)
|
(isObj(activeFromArr) ? (activeFromArr.name as string | undefined) : undefined) ||
|
||||||
? (e.weapons.find((w:any) => (w?.state ?? '').toLowerCase() === 'active')?.name ?? null)
|
null;
|
||||||
: null);
|
|
||||||
|
|
||||||
|
const stateObj = getObj(src.state);
|
||||||
playersRef.current.set(id, {
|
playersRef.current.set(id, {
|
||||||
id,
|
id,
|
||||||
name: e.name ?? old?.name ?? null,
|
name: (src.name as string | undefined) ?? old?.name ?? null,
|
||||||
team: mapTeam(e.team ?? old?.team),
|
team: mapTeam(src.team ?? old?.team),
|
||||||
x, y, z,
|
x,
|
||||||
yaw: Number.isFinite(Number(e.yaw)) ? Number(e.yaw) : (old?.yaw ?? null),
|
y,
|
||||||
|
z,
|
||||||
|
yaw: Number.isFinite(num(src.yaw)) ? (num(src.yaw) as number) : (old?.yaw ?? null),
|
||||||
alive: nextAlive,
|
alive: nextAlive,
|
||||||
hasBomb: Boolean(e.hasBomb) || Boolean(old?.hasBomb),
|
hasBomb: Boolean(src.hasBomb) || Boolean(old?.hasBomb),
|
||||||
hp: Number.isFinite(hpProbe) ? hpProbe : (old?.hp ?? null),
|
hp: Number.isFinite(hpProbe) ? (hpProbe as number) : (old?.hp ?? null),
|
||||||
armor: Number.isFinite(asNum(e.armor ?? e.state?.armor, NaN)) ? asNum(e.armor ?? e.state?.armor, NaN) : (old?.armor ?? null),
|
armor:
|
||||||
helmet: (e.helmet ?? e.hasHelmet ?? e.state?.helmet) ?? (old?.helmet ?? null),
|
Number.isFinite(asNum(src.armor ?? stateObj?.armor, NaN))
|
||||||
defuse: (e.defuse ?? e.hasDefuse ?? e.hasDefuser ?? e.state?.defusekit) ?? (old?.defuse ?? null),
|
? (asNum(src.armor ?? stateObj?.armor, NaN) as number)
|
||||||
|
: (old?.armor ?? null),
|
||||||
|
helmet: (src.helmet ?? src.hasHelmet ?? stateObj?.helmet) as boolean | null ?? (old?.helmet ?? null),
|
||||||
|
defuse:
|
||||||
|
(src.defuse ??
|
||||||
|
(src as UnknownRecord)['hasDefuse'] ??
|
||||||
|
(src as UnknownRecord)['hasDefuser'] ??
|
||||||
|
stateObj?.defusekit) as boolean | null ?? (old?.defuse ?? null),
|
||||||
activeWeapon: activeWeaponName ?? old?.activeWeapon ?? null,
|
activeWeapon: activeWeaponName ?? old?.activeWeapon ?? null,
|
||||||
weapons: Array.isArray(e.weapons) ? e.weapons : (old?.weapons ?? null),
|
weapons: Array.isArray(src.weapons) ? (src.weapons as unknown[]) : (old?.weapons ?? null),
|
||||||
nades: old?.nades ?? null,
|
nades: old?.nades ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Handlers für GameSocket ---------------------------------------
|
/* ---------- Handlers für GameSocket ---------- */
|
||||||
const handlePlayersAll = (msg:any) => {
|
const handlePlayersAll = (msg: unknown) => {
|
||||||
const pcd = msg?.phase ?? msg?.phase_countdowns;
|
const m = getObj(msg) ?? {};
|
||||||
const phase = String(pcd?.phase ?? '').toLowerCase();
|
const pcd = getObj(m.phase) ?? getObj(m.phase_countdowns);
|
||||||
|
const phaseStr = String((pcd?.phase ?? '') as string).toLowerCase();
|
||||||
|
|
||||||
if (phase === 'freezetime' && (deathMarkersRef.current.length || trailsRef.current.size)) {
|
if (phaseStr === 'freezetime' && (deathMarkersRef.current.length || trailsRef.current.size)) {
|
||||||
clearRoundArtifacts(true);
|
clearRoundArtifacts(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,118 +221,180 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
const sec = Number(pcd.phase_ends_in);
|
const sec = Number(pcd.phase_ends_in);
|
||||||
if (Number.isFinite(sec)) {
|
if (Number.isFinite(sec)) {
|
||||||
roundEndsAtRef.current = Date.now() + sec * 1000;
|
roundEndsAtRef.current = Date.now() + sec * 1000;
|
||||||
setRoundPhase(String(pcd.phase ?? 'unknown').toLowerCase() as any);
|
const rp = (['freezetime', 'live', 'bomb', 'over', 'warmup'].includes(phaseStr)
|
||||||
|
? phaseStr
|
||||||
|
: 'unknown') as typeof roundPhase;
|
||||||
|
setRoundPhase(rp);
|
||||||
}
|
}
|
||||||
} else if (pcd?.phase) {
|
} else if (pcd?.phase) {
|
||||||
setRoundPhase(String(pcd.phase).toLowerCase() as any);
|
const rp = (['freezetime', 'live', 'bomb', 'over', 'warmup'].includes(phaseStr)
|
||||||
|
? phaseStr
|
||||||
|
: 'unknown') as typeof roundPhase;
|
||||||
|
setRoundPhase(rp);
|
||||||
}
|
}
|
||||||
if ((pcd?.phase ?? '').toLowerCase() === 'over') {
|
if (phaseStr === 'over') {
|
||||||
roundEndsAtRef.current = null;
|
roundEndsAtRef.current = null;
|
||||||
bombEndsAtRef.current = null;
|
bombEndsAtRef.current = null;
|
||||||
defuseRef.current = { by: null, hasKit: false, endsAt: null };
|
defuseRef.current = { by: null, hasKit: false, endsAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spieler (gekürzt, robust genug)
|
// Spieler aus allplayers/players
|
||||||
const apObj = msg?.allplayers;
|
const apObj = getObj(m.allplayers);
|
||||||
const apArr = Array.isArray(msg?.players) ? msg.players : null;
|
const apArr = getArr(m.players);
|
||||||
const upsertFromPayload = (p:any) => {
|
|
||||||
const id = steamIdOf(p); if (!id) return;
|
const upsertFromPayload = (p: unknown) => {
|
||||||
const pos = p.position ?? p.pos ?? p.location ?? p.coordinates ?? p.eye ?? p.pos;
|
const o = getObj(p);
|
||||||
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] }
|
if (!o) return;
|
||||||
: typeof pos === 'object' ? pos : { x: p.x, y: p.y, z: p.z };
|
const id = steamIdOf(o);
|
||||||
const { x=0, y=0, z=0 } = xyz;
|
if (!id) return;
|
||||||
const hpNum = Number(p?.state?.health ?? p?.hp);
|
|
||||||
const isAlive = Number.isFinite(hpNum) ? hpNum > 0 : (playersRef.current.get(id)?.alive ?? true);
|
const pos =
|
||||||
|
o.position ?? o.pos ?? o.location ?? o.coordinates ?? o.eye ?? o.pos;
|
||||||
|
const arr = getArr(pos);
|
||||||
|
const obj = getObj(pos);
|
||||||
|
const x = asNum(o.x ?? arr?.[0] ?? obj?.x);
|
||||||
|
const y = asNum(o.y ?? arr?.[1] ?? obj?.y);
|
||||||
|
const z = asNum(o.z ?? arr?.[2] ?? obj?.z, 0);
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||||
|
|
||||||
|
const hpNum = num(getObj(o.state)?.health ?? o.hp);
|
||||||
|
const isAlive = Number.isFinite(hpNum ?? NaN)
|
||||||
|
? (hpNum as number) > 0
|
||||||
|
: (playersRef.current.get(id)?.alive ?? true);
|
||||||
|
|
||||||
if ((playersRef.current.get(id)?.alive ?? true) && !isAlive) addDeathMarker(x, y, id);
|
if ((playersRef.current.get(id)?.alive ?? true) && !isAlive) addDeathMarker(x, y, id);
|
||||||
|
|
||||||
|
const prev = playersRef.current.get(id);
|
||||||
playersRef.current.set(id, {
|
playersRef.current.set(id, {
|
||||||
id,
|
id,
|
||||||
name: p?.name ?? playersRef.current.get(id)?.name ?? null,
|
name: (o.name as string | undefined) ?? prev?.name ?? null,
|
||||||
team: mapTeam(p?.team ?? playersRef.current.get(id)?.team),
|
team: mapTeam(o.team ?? prev?.team),
|
||||||
x, y, z,
|
x,
|
||||||
yaw: playersRef.current.get(id)?.yaw ?? null,
|
y,
|
||||||
|
z,
|
||||||
|
yaw: prev?.yaw ?? null,
|
||||||
alive: isAlive,
|
alive: isAlive,
|
||||||
hasBomb: Boolean(playersRef.current.get(id)?.hasBomb),
|
hasBomb: Boolean(prev?.hasBomb),
|
||||||
hp: Number.isFinite(hpNum) ? hpNum : (playersRef.current.get(id)?.hp ?? null),
|
hp: Number.isFinite(hpNum ?? NaN) ? (hpNum as number) : (prev?.hp ?? null),
|
||||||
armor: playersRef.current.get(id)?.armor ?? null,
|
armor: prev?.armor ?? null,
|
||||||
helmet: playersRef.current.get(id)?.helmet ?? null,
|
helmet: prev?.helmet ?? null,
|
||||||
defuse: playersRef.current.get(id)?.defuse ?? null,
|
defuse: prev?.defuse ?? null,
|
||||||
activeWeapon: playersRef.current.get(id)?.activeWeapon ?? null,
|
activeWeapon: prev?.activeWeapon ?? null,
|
||||||
weapons: playersRef.current.get(id)?.weapons ?? null,
|
weapons: prev?.weapons ?? null,
|
||||||
nades: playersRef.current.get(id)?.nades ?? null,
|
nades: prev?.nades ?? null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (apObj && typeof apObj === 'object') for (const k of Object.keys(apObj)) upsertFromPayload(apObj[k]);
|
|
||||||
|
if (apObj) for (const k of Object.keys(apObj)) upsertFromPayload(apObj[k as keyof typeof apObj]);
|
||||||
else if (apArr) for (const p of apArr) upsertFromPayload(p);
|
else if (apArr) for (const p of apArr) upsertFromPayload(p);
|
||||||
|
|
||||||
// Scores (robust, gekürzt)
|
// Scores
|
||||||
const pick = (v:any)=> Number.isFinite(Number(v)) ? Number(v) : null;
|
const pick = (v: unknown) => (Number.isFinite(Number(v)) ? Number(v) : null);
|
||||||
const ct = pick(msg?.score?.ct) ?? pick(msg?.scores?.ct) ?? pick(msg?.map?.team_ct?.score) ?? 0;
|
const ct =
|
||||||
const t = pick(msg?.score?.t) ?? pick(msg?.scores?.t) ?? pick(msg?.map?.team_t?.score) ?? 0;
|
pick(getObj(m.score)?.ct) ??
|
||||||
const rnd= pick(msg?.round) ?? pick(msg?.rounds?.played) ?? pick(msg?.map?.round) ?? null;
|
pick(getObj(m.scores)?.ct) ??
|
||||||
|
pick(getObj(m.map)?.team_ct && getObj(getObj(m.map)?.team_ct)?.score) ??
|
||||||
|
0;
|
||||||
|
const t =
|
||||||
|
pick(getObj(m.score)?.t) ??
|
||||||
|
pick(getObj(m.scores)?.t) ??
|
||||||
|
pick(getObj(m.map)?.team_t && getObj(getObj(m.map)?.team_t)?.score) ??
|
||||||
|
0;
|
||||||
|
const rnd =
|
||||||
|
pick(m.round) ?? pick(getObj(m.rounds)?.played) ?? pick(getObj(m.map)?.round) ?? null;
|
||||||
|
|
||||||
setScore({ ct, t, round: rnd });
|
setScore({ ct, t, round: rnd });
|
||||||
|
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGrenades = (g:any) => {
|
const handleGrenades = (g: unknown) => {
|
||||||
const list = normalizeGrenades(g);
|
const list = normalizeGrenades(g);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Trails nur für eigene Projektile
|
// Trails nur für eigene Projektile
|
||||||
const mine = mySteamId
|
const mine = mySteamId
|
||||||
? list.filter(n => n.ownerId === mySteamId && n.phase === 'projectile')
|
? list.filter((n) => n.ownerId === mySteamId && n.phase === 'projectile')
|
||||||
: [];
|
: [];
|
||||||
const seenTrailIds = new Set<string>();
|
const seenTrailIds = new Set<string>();
|
||||||
for (const it of mine) {
|
for (const it of mine) {
|
||||||
seenTrailIds.add(it.id);
|
seenTrailIds.add(it.id);
|
||||||
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 };
|
const prev =
|
||||||
|
trailsRef.current.get(it.id) ?? ({ id: it.id, kind: it.kind, pts: [], lastSeen: 0 } as Trail);
|
||||||
const last = prev.pts[prev.pts.length - 1];
|
const last = prev.pts[prev.pts.length - 1];
|
||||||
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
|
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
|
||||||
prev.pts.push({ x: it.x, y: it.y });
|
prev.pts.push({ x: it.x, y: it.y });
|
||||||
if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints);
|
if (prev.pts.length > UI.trail.maxPoints)
|
||||||
|
prev.pts = prev.pts.slice(-UI.trail.maxPoints);
|
||||||
}
|
}
|
||||||
prev.kind = it.kind; prev.lastSeen = now;
|
prev.kind = it.kind;
|
||||||
|
prev.lastSeen = now;
|
||||||
trailsRef.current.set(it.id, prev);
|
trailsRef.current.set(it.id, prev);
|
||||||
}
|
}
|
||||||
for (const [id, tr] of trailsRef.current) {
|
for (const [id, tr] of trailsRef.current) {
|
||||||
if (!seenTrailIds.has(id) && now - tr.lastSeen > UI.trail.fadeMs) trailsRef.current.delete(id);
|
if (!seenTrailIds.has(id) && now - tr.lastSeen > UI.trail.fadeMs) {
|
||||||
|
trailsRef.current.delete(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanftes Mergen
|
// sanftes Mergen + Verfall
|
||||||
const next = new Map<string, Grenade>(grenadesRef.current);
|
const next = new Map<string, Grenade>(grenadesRef.current);
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
for (const it of list) { seenIds.add(it.id); next.set(it.id, { ...(next.get(it.id) || {}), ...it }); }
|
for (const it of list) {
|
||||||
|
seenIds.add(it.id);
|
||||||
|
next.set(it.id, { ...(next.get(it.id) || {}), ...it });
|
||||||
|
}
|
||||||
for (const [id, nade] of next) {
|
for (const [id, nade] of next) {
|
||||||
if (!seenIds.has(id) && nade.phase === 'projectile') next.delete(id);
|
if (!seenIds.has(id) && nade.phase === 'projectile') next.delete(id);
|
||||||
if ((nade.phase === 'effect' || nade.phase === 'exploded') && nade.expiresAt != null && nade.expiresAt <= now) next.delete(id);
|
if (
|
||||||
|
(nade.phase === 'effect' || nade.phase === 'exploded') &&
|
||||||
|
nade.expiresAt != null &&
|
||||||
|
nade.expiresAt <= now
|
||||||
|
)
|
||||||
|
next.delete(id);
|
||||||
}
|
}
|
||||||
grenadesRef.current = next;
|
grenadesRef.current = next;
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBomb = (normalizeBomb:(b:any)=>BombState|null) => (b:any) => {
|
const handleBomb =
|
||||||
|
(normalizeBomb: (b: unknown) => BombState | null) =>
|
||||||
|
(b: unknown) => {
|
||||||
const prev = bombRef.current;
|
const prev = bombRef.current;
|
||||||
const nb = normalizeBomb(b);
|
const nb = normalizeBomb(b);
|
||||||
if (!nb) return;
|
if (!nb) return;
|
||||||
const withPos = {
|
const withPos = {
|
||||||
x: Number.isFinite(nb.x) ? nb.x : (prev?.x ?? 0),
|
x: Number.isFinite(nb.x) ? nb.x : prev?.x ?? 0,
|
||||||
y: Number.isFinite(nb.y) ? nb.y : (prev?.y ?? 0),
|
y: Number.isFinite(nb.y) ? nb.y : prev?.y ?? 0,
|
||||||
z: Number.isFinite(nb.z) ? nb.z : (prev?.z ?? 0),
|
z: Number.isFinite(nb.z) ? nb.z : prev?.z ?? 0,
|
||||||
};
|
};
|
||||||
const sameStatus = prev && prev.status === nb.status;
|
const sameStatus = prev && prev.status === nb.status;
|
||||||
bombRef.current = { ...withPos, status: nb.status, changedAt: sameStatus ? prev!.changedAt : Date.now() };
|
bombRef.current = {
|
||||||
|
...withPos,
|
||||||
|
status: nb.status,
|
||||||
|
changedAt: sameStatus ? prev!.changedAt : Date.now(),
|
||||||
|
};
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// state
|
// state
|
||||||
radarWsStatus, setGameWsStatus,
|
radarWsStatus,
|
||||||
activeMapKey, setActiveMapKey,
|
setGameWsStatus,
|
||||||
players, playersRef, hoveredPlayerId, setHoveredPlayerId,
|
activeMapKey,
|
||||||
grenades, trails, deathMarkers,
|
setActiveMapKey,
|
||||||
|
players,
|
||||||
|
playersRef,
|
||||||
|
hoveredPlayerId,
|
||||||
|
setHoveredPlayerId,
|
||||||
|
grenades,
|
||||||
|
trails,
|
||||||
|
deathMarkers,
|
||||||
bomb,
|
bomb,
|
||||||
roundPhase, roundEndsAtRef, bombEndsAtRef, defuseRef,
|
roundPhase,
|
||||||
|
roundEndsAtRef,
|
||||||
|
bombEndsAtRef,
|
||||||
|
defuseRef,
|
||||||
score,
|
score,
|
||||||
myTeam,
|
myTeam,
|
||||||
|
|
||||||
|
|||||||
@ -31,16 +31,22 @@ export function defaultLifeMs(kind: Grenade['kind'], phase: Grenade['phase'] | n
|
|||||||
|
|
||||||
/* ───────── Normalisierung ───────── */
|
/* ───────── Normalisierung ───────── */
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
const getObj = (v: unknown): UnknownRecord | undefined =>
|
||||||
|
v != null && typeof v === 'object' ? (v as UnknownRecord) : undefined;
|
||||||
|
const getArr = (v: unknown): unknown[] | undefined =>
|
||||||
|
Array.isArray(v) ? v : undefined;
|
||||||
|
|
||||||
const KIND_MAP: Record<string, Grenade['kind']> = {
|
const KIND_MAP: Record<string, Grenade['kind']> = {
|
||||||
smoke: 'smoke', smokegrenade: 'smoke',
|
smoke: 'smoke', smokegrenade: 'smoke',
|
||||||
molotov: 'molotov', incendiary: 'incendiary', incgrenade: 'incendiary',
|
molotov: 'molotov', incendiary: 'incendiary', incgrenade: 'incendiary',
|
||||||
inferno: 'molotov', fire: 'molotov', firebomb: 'molotov', // häufige Synonyme
|
inferno: 'molotov', fire: 'molotov', firebomb: 'molotov',
|
||||||
he: 'he', hegrenade: 'he', frag: 'he', explosive: 'he',
|
he: 'he', hegrenade: 'he', frag: 'he', explosive: 'he',
|
||||||
flash: 'flash', flashbang: 'flash',
|
flash: 'flash', flashbang: 'flash',
|
||||||
decoy: 'decoy'
|
decoy: 'decoy'
|
||||||
};
|
};
|
||||||
|
|
||||||
const asNum = (n: any, d = NaN) => {
|
const asNum = (n: unknown, d = NaN) => {
|
||||||
const v = Number(n);
|
const v = Number(n);
|
||||||
return Number.isFinite(v) ? v : d;
|
return Number.isFinite(v) ? v : d;
|
||||||
};
|
};
|
||||||
@ -51,19 +57,26 @@ const parseVec3String = (str?: string) => {
|
|||||||
return { x, y, z };
|
return { x, y, z };
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsePos = (g: any) => {
|
const parsePos = (g: UnknownRecord) => {
|
||||||
// akzeptiert {x,y,z}, [x,y,z], "x, y, z" oder einzelne Felder
|
// akzeptiert {x,y,z}, [x,y,z], "x, y, z" oder einzelne Felder
|
||||||
const pos = g.pos ?? g.position ?? g.location ?? g.coordinates ?? g.origin ?? [g.x, g.y, g.z];
|
const posSrc =
|
||||||
if (Array.isArray(pos)) {
|
g.pos ?? g.position ?? g.location ?? g.coordinates ?? g.origin ?? [g.x, g.y, g.z];
|
||||||
return { x: asNum(pos[0]), y: asNum(pos[1]), z: asNum(pos[2], 0) };
|
|
||||||
|
const arr = getArr(posSrc);
|
||||||
|
if (arr) {
|
||||||
|
return { x: asNum(arr[0]), y: asNum(arr[1]), z: asNum(arr[2], 0) };
|
||||||
}
|
}
|
||||||
if (typeof pos === 'string') {
|
|
||||||
const v = parseVec3String(pos);
|
if (typeof posSrc === 'string') {
|
||||||
|
const v = parseVec3String(posSrc);
|
||||||
return { x: v.x, y: v.y, z: asNum(v.z, 0) };
|
return { x: v.x, y: v.y, z: asNum(v.z, 0) };
|
||||||
}
|
}
|
||||||
if (pos && typeof pos === 'object') {
|
|
||||||
return { x: asNum(pos.x), y: asNum(pos.y), z: asNum(pos.z, 0) };
|
const o = getObj(posSrc);
|
||||||
|
if (o) {
|
||||||
|
return { x: asNum(o.x), y: asNum(o.y), z: asNum(o.z, 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { x: asNum(g.x), y: asNum(g.y), z: asNum(g.z, 0) };
|
return { x: asNum(g.x), y: asNum(g.y), z: asNum(g.z, 0) };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -78,13 +91,18 @@ export function teamOfGrenade(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Liefert eine normalisierte Liste von Grenades. */
|
/** Liefert eine normalisierte Liste von Grenades. */
|
||||||
export function normalizeGrenades(raw: any): Grenade[] {
|
export function normalizeGrenades(raw: unknown): Grenade[] {
|
||||||
const arr = Array.isArray(raw) ? raw : Object.values(raw ?? {});
|
const src = getObj(raw);
|
||||||
|
const arr = Array.isArray(raw) ? (raw as unknown[]) : Object.values(src ?? {});
|
||||||
const out: Grenade[] = [];
|
const out: Grenade[] = [];
|
||||||
|
|
||||||
for (const g of arr) {
|
for (const gi of arr) {
|
||||||
|
const g = getObj(gi) ?? {};
|
||||||
|
|
||||||
// Kind
|
// Kind
|
||||||
const kindRaw = String(g.kind ?? g.type ?? g.weapon ?? g.name ?? g.nade ?? 'unknown').toLowerCase();
|
const kindRaw = String(
|
||||||
|
(g.kind ?? g.type ?? g.weapon ?? g.name ?? g.nade ?? 'unknown') as string
|
||||||
|
).toLowerCase();
|
||||||
const kind: Grenade['kind'] = KIND_MAP[kindRaw] ?? 'unknown';
|
const kind: Grenade['kind'] = KIND_MAP[kindRaw] ?? 'unknown';
|
||||||
|
|
||||||
// Position
|
// Position
|
||||||
@ -92,45 +110,54 @@ export function normalizeGrenades(raw: any): Grenade[] {
|
|||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||||
|
|
||||||
// Phase
|
// Phase
|
||||||
const phaseRaw = String(g.phase ?? g.state ?? g.status ?? '').toLowerCase();
|
const phaseRaw = String((g.phase ?? g.state ?? g.status ?? '') as string).toLowerCase();
|
||||||
const hasEffectHints = typeof g.effectTimeSec === 'number' || typeof g.lifeElapsedMs === 'number' || typeof g.expiresAt === 'number';
|
const hasEffectHints =
|
||||||
let phase: Grenade['phase'] =
|
typeof g.effectTimeSec === 'number' ||
|
||||||
phaseRaw.includes('effect') || hasEffectHints ? 'effect'
|
typeof g.lifeElapsedMs === 'number' ||
|
||||||
: phaseRaw.includes('explode') ? 'exploded'
|
typeof g.expiresAt === 'number';
|
||||||
|
const phase: Grenade['phase'] =
|
||||||
|
phaseRaw.includes('effect') || hasEffectHints
|
||||||
|
? 'effect'
|
||||||
|
: phaseRaw.includes('explode')
|
||||||
|
? 'exploded'
|
||||||
: 'projectile';
|
: 'projectile';
|
||||||
|
|
||||||
// Heading (aus velocity/forward)
|
// Heading (aus velocity/forward)
|
||||||
let headingRad: number | null = null;
|
let headingRad: number | null = null;
|
||||||
const vel = g.vel ?? g.velocity ?? g.dir ?? g.forward;
|
const vel = getObj(g.vel) ?? getObj(g.velocity) ?? getObj(g.dir) ?? getObj(g.forward);
|
||||||
if (vel && Number.isFinite(vel.x) && Number.isFinite(vel.y)) {
|
if (vel && Number.isFinite(asNum(vel.x)) && Number.isFinite(asNum(vel.y))) {
|
||||||
headingRad = Math.atan2(Number(vel.y), Number(vel.x));
|
headingRad = Math.atan2(Number(vel.y), Number(vel.x));
|
||||||
} else if (Number.isFinite(g.headingRad)) {
|
} else if (Number.isFinite(asNum(g.headingRad))) {
|
||||||
headingRad = Number(g.headingRad);
|
headingRad = Number(g.headingRad);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ID (stabil genug; Engine-ID bevorzugen)
|
// ID (stabil genug; Engine-ID bevorzugen)
|
||||||
const id = String(g.id ?? g.entityid ?? g.entindex ?? `${kind}#${Math.round(x)}:${Math.round(y)}:${Math.round(z)}`);
|
const id = String(
|
||||||
|
(g.id ?? g.entityid ?? g.entindex ??
|
||||||
|
`${kind}#${Math.round(x)}:${Math.round(y)}:${Math.round(z)}`) as string
|
||||||
|
);
|
||||||
|
|
||||||
// Meta
|
// Meta
|
||||||
const team = g.team === 'T' || g.team === 'CT' ? g.team : null;
|
const team = g.team === 'T' || g.team === 'CT' ? (g.team as 'T' | 'CT') : null;
|
||||||
const radius = Number.isFinite(Number(g.radius)) ? Number(g.radius) : null;
|
const radius = Number.isFinite(asNum(g.radius)) ? Number(g.radius) : null;
|
||||||
const spawnedAt = Number.isFinite(Number(g.spawnedAt ?? g.t)) ? Number(g.spawnedAt ?? g.t) : Date.now();
|
const spawnedAt = Number.isFinite(asNum(g.spawnedAt ?? g.t))
|
||||||
const ownerId = g.ownerId ?? g.owner ?? g.thrower ?? g.player ?? g.userid ?? null;
|
? Number(g.spawnedAt ?? g.t)
|
||||||
|
: Date.now();
|
||||||
|
const ownerRaw =
|
||||||
|
g.ownerId ?? g.owner ?? g.thrower ?? g.player ?? g.userid ?? null;
|
||||||
|
const ownerId = ownerRaw != null ? String(ownerRaw) : null;
|
||||||
|
|
||||||
// Zeit-/Effektfelder vom Server (optional)
|
// Zeit-/Effektfelder vom Server (optional)
|
||||||
let effectTimeSec = typeof g.effectTimeSec === 'number' ? g.effectTimeSec : undefined;
|
let effectTimeSec =
|
||||||
let lifeElapsedMs = typeof g.lifeElapsedMs === 'number' ? g.lifeElapsedMs : undefined;
|
typeof g.effectTimeSec === 'number' ? (g.effectTimeSec as number) : undefined;
|
||||||
let lifeLeftMs = typeof g.lifeLeftMs === 'number' ? g.lifeLeftMs : undefined;
|
let lifeElapsedMs =
|
||||||
let expiresAt = typeof g.expiresAt === 'number' ? g.expiresAt : undefined;
|
typeof g.lifeElapsedMs === 'number' ? (g.lifeElapsedMs as number) : undefined;
|
||||||
|
let lifeLeftMs =
|
||||||
|
typeof g.lifeLeftMs === 'number' ? (g.lifeLeftMs as number) : undefined;
|
||||||
|
let expiresAt =
|
||||||
|
typeof g.expiresAt === 'number' ? (g.expiresAt as number) : undefined;
|
||||||
|
|
||||||
/* ── Smoke lokal um +2s verlängern ─────────────────────────────────
|
/* ── Smoke lokal um +2s verlängern ─────────────────────────────── */
|
||||||
Strategie:
|
|
||||||
- Wir definieren eine *lokale* Gesamtdauer = default(21s) + 2s Linger.
|
|
||||||
- Wenn der Server effectTimeSec liefert, „drehen wir die Zeit zurück“
|
|
||||||
(elapsed -= 2s), wodurch rechnerisch 2s mehr Restzeit bleiben.
|
|
||||||
- Andernfalls erhöhen wir die Restzeit bzw. schieben expiresAt nach hinten.
|
|
||||||
- Wir setzen expiresAt mindestens auf „jetzt + lifeLeftMs“, damit der
|
|
||||||
Renderer die Smoke nicht vorzeitig entfernt. */
|
|
||||||
if (kind === 'smoke' && phase === 'effect') {
|
if (kind === 'smoke' && phase === 'effect') {
|
||||||
const base = defaultLifeMs('smoke', 'effect'); // 21_000
|
const base = defaultLifeMs('smoke', 'effect'); // 21_000
|
||||||
const total = base + SMOKE_LINGER_MS; // 23_000
|
const total = base + SMOKE_LINGER_MS; // 23_000
|
||||||
@ -138,7 +165,7 @@ export function normalizeGrenades(raw: any): Grenade[] {
|
|||||||
|
|
||||||
if (typeof effectTimeSec === 'number') {
|
if (typeof effectTimeSec === 'number') {
|
||||||
const elapsedRaw = Math.max(0, Math.round(effectTimeSec * 1000)) + SMOKE_LINGER_MS;
|
const elapsedRaw = Math.max(0, Math.round(effectTimeSec * 1000)) + SMOKE_LINGER_MS;
|
||||||
const elapsedAdj = Math.max(0, elapsedRaw); // „+2s länger“
|
const elapsedAdj = Math.max(0, elapsedRaw);
|
||||||
const left = Math.max(0, total - elapsedAdj);
|
const left = Math.max(0, total - elapsedAdj);
|
||||||
|
|
||||||
lifeElapsedMs = elapsedAdj;
|
lifeElapsedMs = elapsedAdj;
|
||||||
@ -148,20 +175,16 @@ export function normalizeGrenades(raw: any): Grenade[] {
|
|||||||
const expLocal = now + left;
|
const expLocal = now + left;
|
||||||
expiresAt = Math.max(expiresAt ?? 0, expLocal);
|
expiresAt = Math.max(expiresAt ?? 0, expLocal);
|
||||||
} else if (typeof lifeElapsedMs === 'number') {
|
} else if (typeof lifeElapsedMs === 'number') {
|
||||||
// Wir kennen die verstrichene Zeit → Rest = total - elapsed
|
|
||||||
const left = Math.max(0, total - lifeElapsedMs);
|
const left = Math.max(0, total - lifeElapsedMs);
|
||||||
lifeLeftMs = Math.max(lifeLeftMs ?? 0, left);
|
lifeLeftMs = Math.max(lifeLeftMs ?? 0, left);
|
||||||
expiresAt = Math.max(expiresAt ?? 0, now + (lifeLeftMs ?? 0));
|
expiresAt = Math.max(expiresAt ?? 0, now + (lifeLeftMs ?? 0));
|
||||||
} else if (typeof lifeLeftMs === 'number') {
|
} else if (typeof lifeLeftMs === 'number') {
|
||||||
// Wir kennen nur die Restzeit → +2s addieren
|
|
||||||
lifeLeftMs = Math.max(0, lifeLeftMs + SMOKE_LINGER_MS);
|
lifeLeftMs = Math.max(0, lifeLeftMs + SMOKE_LINGER_MS);
|
||||||
expiresAt = Math.max(expiresAt ?? 0, now + lifeLeftMs);
|
expiresAt = Math.max(expiresAt ?? 0, now + lifeLeftMs);
|
||||||
} else if (typeof expiresAt === 'number') {
|
} else if (typeof expiresAt === 'number') {
|
||||||
// Nur expiresAt bekannt → um +2s schieben
|
|
||||||
expiresAt = expiresAt + SMOKE_LINGER_MS;
|
expiresAt = expiresAt + SMOKE_LINGER_MS;
|
||||||
lifeLeftMs = Math.max(0, expiresAt - now);
|
lifeLeftMs = Math.max(0, expiresAt - now);
|
||||||
} else {
|
} else {
|
||||||
// Keine Zeitangaben → aus Spawn + total ableiten
|
|
||||||
const exp = spawnedAt + total;
|
const exp = spawnedAt + total;
|
||||||
expiresAt = exp;
|
expiresAt = exp;
|
||||||
lifeElapsedMs = Math.max(0, now - spawnedAt);
|
lifeElapsedMs = Math.max(0, now - spawnedAt);
|
||||||
@ -171,14 +194,21 @@ export function normalizeGrenades(raw: any): Grenade[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.push({
|
out.push({
|
||||||
id, kind, x, y, z: Number.isFinite(z) ? z : 0,
|
id,
|
||||||
|
kind,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z: Number.isFinite(z) ? z : 0,
|
||||||
radius,
|
radius,
|
||||||
expiresAt: expiresAt ?? null,
|
expiresAt: expiresAt ?? null,
|
||||||
team, phase, headingRad, spawnedAt,
|
team,
|
||||||
ownerId: ownerId ? String(ownerId) : null,
|
phase,
|
||||||
|
headingRad,
|
||||||
|
spawnedAt,
|
||||||
|
ownerId,
|
||||||
effectTimeSec,
|
effectTimeSec,
|
||||||
lifeElapsedMs,
|
lifeElapsedMs,
|
||||||
lifeLeftMs
|
lifeLeftMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,51 +2,82 @@
|
|||||||
|
|
||||||
import { Mapper, Overview } from './types';
|
import { Mapper, Overview } from './types';
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
const getObj = (v: unknown): UnknownRecord | undefined =>
|
||||||
|
v != null && typeof v === 'object' ? (v as UnknownRecord) : undefined;
|
||||||
|
|
||||||
export const RAD2DEG = 180 / Math.PI;
|
export const RAD2DEG = 180 / Math.PI;
|
||||||
|
|
||||||
export const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:');
|
export const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:');
|
||||||
|
|
||||||
export const asNum = (n: any, def=0) => { const v = Number(n); return Number.isFinite(v) ? v : def };
|
export const asNum = (n: unknown, def = 0): number => {
|
||||||
|
const v = Number(n);
|
||||||
|
return Number.isFinite(v) ? v : def;
|
||||||
|
};
|
||||||
|
|
||||||
export function contrastStroke(hex: string) {
|
export function contrastStroke(hex: string) {
|
||||||
const h = hex.replace('#', '');
|
const h = hex.replace('#', '');
|
||||||
const r = parseInt(h.slice(0, 2), 16) / 255;
|
const r = parseInt(h.slice(0, 2), 16) / 255;
|
||||||
const g = parseInt(h.slice(2, 4), 16) / 255;
|
const g = parseInt(h.slice(2, 4), 16) / 255;
|
||||||
const b = parseInt(h.slice(4, 6), 16) / 255;
|
const b = parseInt(h.slice(4, 6), 16) / 255;
|
||||||
const toL = (c:number) => (c<=0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4));
|
const toL = (c: number) =>
|
||||||
|
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
const L = 0.2126 * toL(r) + 0.7152 * toL(g) + 0.0722 * toL(b);
|
const L = 0.2126 * toL(r) + 0.7152 * toL(g) + 0.0722 * toL(b);
|
||||||
return L > 0.6 ? '#111111' : '#ffffff';
|
return L > 0.6 ? '#111111' : '#ffffff';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTeam(t: any): 'T' | 'CT' | string {
|
export function mapTeam(t: unknown): 'T' | 'CT' | string {
|
||||||
if (t === 2 || t === 'T' || t === 't') return 'T';
|
if (t === 2 || t === 'T' || t === 't') return 'T';
|
||||||
if (t === 3 || t === 'CT' || t === 'ct') return 'CT';
|
if (t === 3 || t === 'CT' || t === 'ct') return 'CT';
|
||||||
return String(t ?? '');
|
return String(t ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFiniteXY = (x:any,y:any) => Number.isFinite(x) && Number.isFinite(y) && !(x===0 && y===0);
|
const isFiniteXY = (x: unknown, y: unknown) =>
|
||||||
|
Number.isFinite(x) && Number.isFinite(y) && !(x === 0 && y === 0);
|
||||||
|
|
||||||
export function pickVec2Loose(v:any): {x:number,y:number}|null {
|
export function pickVec2Loose(
|
||||||
|
v: unknown
|
||||||
|
): { x: number; y: number } | null {
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
|
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
const x = Number(v[0]), y = Number(v[1]);
|
const x = Number(v[0]),
|
||||||
return isFiniteXY(x,y) ? {x,y} : null;
|
y = Number(v[1]);
|
||||||
}
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
const [xs,ys] = v.split(','); const x = Number(xs), y = Number(ys);
|
|
||||||
return isFiniteXY(x,y) ? {x,y} : null;
|
|
||||||
}
|
|
||||||
const x = Number(v?.x), y = Number(v?.y);
|
|
||||||
return isFiniteXY(x, y) ? { x, y } : null;
|
return isFiniteXY(x, y) ? { x, y } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const steamIdOf = (src: any): string | null => {
|
if (typeof v === 'string') {
|
||||||
const raw = src?.steamId ?? src?.steam_id ?? src?.steamid ?? src?.id ?? src?.entityId ?? src?.entindex;
|
const [xs, ys] = v.split(',');
|
||||||
const s = raw != null ? String(raw) : '';
|
const x = Number(xs),
|
||||||
if (/^\d{17}$/.test(s)) return s;
|
y = Number(ys);
|
||||||
const name = (src?.name ?? src?.playerName ?? '').toString().trim();
|
return isFiniteXY(x, y) ? { x, y } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = getObj(v);
|
||||||
|
const x = Number(o?.x),
|
||||||
|
y = Number(o?.y);
|
||||||
|
return isFiniteXY(x, y) ? { x, y } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const steamIdOf = (src: unknown): string | null => {
|
||||||
|
const s = getObj(src);
|
||||||
|
const raw =
|
||||||
|
s?.steamId ??
|
||||||
|
s?.steam_id ??
|
||||||
|
s?.steamid ??
|
||||||
|
s?.id ??
|
||||||
|
s?.entityId ??
|
||||||
|
s?.entindex;
|
||||||
|
|
||||||
|
const rawStr = raw != null ? String(raw) : '';
|
||||||
|
if (/^\d{17}$/.test(rawStr)) return rawStr;
|
||||||
|
|
||||||
|
const name = String(
|
||||||
|
(s?.name ?? s?.playerName ?? '') as string
|
||||||
|
).trim();
|
||||||
if (name) return `BOT:${name}`;
|
if (name) return `BOT:${name}`;
|
||||||
if (s && s !== '0' && s.toUpperCase() !== 'BOT') return s;
|
|
||||||
|
if (rawStr && rawStr !== '0' && rawStr.toUpperCase() !== 'BOT') return rawStr;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,20 +93,27 @@ export function defaultWorldToPx(imgSize: {w:number;h:number}|null): Mapper {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseOverviewJson(j: any): Overview | null {
|
export function parseOverviewJson(j: unknown): Overview | null {
|
||||||
const posX = Number(j?.posX ?? j?.pos_x);
|
const o = getObj(j) ?? {};
|
||||||
const posY = Number(j?.posY ?? j?.pos_y);
|
const posX = Number(o?.posX ?? (o as UnknownRecord)['pos_x']);
|
||||||
const scale = Number(j?.scale);
|
const posY = Number(o?.posY ?? (o as UnknownRecord)['pos_y']);
|
||||||
const rotate = Number(j?.rotate ?? 0);
|
const scale = Number(o?.scale);
|
||||||
|
const rotate = Number(o?.rotate ?? 0);
|
||||||
if (![posX, posY, scale].every(Number.isFinite)) return null;
|
if (![posX, posY, scale].every(Number.isFinite)) return null;
|
||||||
return { posX, posY, scale, rotate };
|
return { posX, posY, scale, rotate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseValveKvOverview(txt: string): Overview | null {
|
export function parseValveKvOverview(txt: string): Overview | null {
|
||||||
const clean = txt.replace(/\/\/.*$/gm, '');
|
const clean = txt.replace(/\/\/.*$/gm, '');
|
||||||
const pick = (k: string) => { const m = clean.match(new RegExp(`"\\s*${k}\\s*"\\s*"([^"]+)"`)); return m ? Number(m[1]) : NaN; };
|
const pick = (k: string) => {
|
||||||
const posX = pick('pos_x'), posY = pick('pos_y'), scale = pick('scale');
|
const m = clean.match(new RegExp(`"\\s*${k}\\s*"\\s*"([^"]+)"`));
|
||||||
const r = pick('rotate'); const rotate = Number.isFinite(r) ? r : 0;
|
return m ? Number(m[1]) : NaN;
|
||||||
|
};
|
||||||
|
const posX = pick('pos_x'),
|
||||||
|
posY = pick('pos_y'),
|
||||||
|
scale = pick('scale');
|
||||||
|
const r = pick('rotate');
|
||||||
|
const rotate = Number.isFinite(r) ? r : 0;
|
||||||
if (![posX, posY, scale].every(Number.isFinite)) return null;
|
if (![posX, posY, scale].every(Number.isFinite)) return null;
|
||||||
return { posX, posY, scale, rotate };
|
return { posX, posY, scale, rotate };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
// /src/app/[locale]/components/settings/account/AppearanceSettings.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
export default function AppearanceSettings() {
|
export default function AppearanceSettings() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
@ -18,44 +20,60 @@ export default function AppearanceSettings() {
|
|||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ id: 'system', label: tSettings("sections.account.page.AppearanceSettings.theme.system"), img: 'account-system-image.svg' },
|
{
|
||||||
{ id: 'light', label: tSettings("sections.account.page.AppearanceSettings.theme.light"), img: 'account-light-image.svg' },
|
id: 'system',
|
||||||
{ id: 'dark', label: tSettings("sections.account.page.AppearanceSettings.theme.dark"), img: 'account-dark-image.svg' },
|
label: tSettings('sections.account.page.AppearanceSettings.theme.system'),
|
||||||
]
|
img: 'account-system-image.svg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'light',
|
||||||
|
label: tSettings('sections.account.page.AppearanceSettings.theme.light'),
|
||||||
|
img: 'account-light-image.svg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dark',
|
||||||
|
label: tSettings('sections.account.page.AppearanceSettings.theme.dark'),
|
||||||
|
img: 'account-dark-image.svg',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-3 sm:py-4 space-y-5">
|
<div className="py-3 sm:py-4 space-y-5">
|
||||||
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
|
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
|
||||||
<div className="sm:col-span-4 2xl:col-span-2">
|
<div className="sm:col-span-4 2xl:col-span-2">
|
||||||
<label className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
|
<label className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
|
||||||
{tSettings("sections.account.page.AppearanceSettings.name")}
|
{tSettings('sections.account.page.AppearanceSettings.name')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
||||||
<p className="text-sm text-gray-500 dark:text-neutral-500">
|
<p className="text-sm text-gray-500 dark:text-neutral-500">
|
||||||
{tSettings("sections.account.page.AppearanceSettings.description")}
|
{tSettings('sections.account.page.AppearanceSettings.description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="mt-3 text-sm font-semibold text-gray-800 dark:text-neutral-200">
|
<h3 className="mt-3 text-sm font-semibold text-gray-800 dark:text-neutral-200">
|
||||||
{tSettings("sections.account.page.AppearanceSettings.theme-mode")}
|
{tSettings('sections.account.page.AppearanceSettings.theme-mode')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 dark:text-neutral-500">
|
<p className="text-sm text-gray-500 dark:text-neutral-500">
|
||||||
{tSettings("sections.account.page.AppearanceSettings.theme-mode-description")}
|
{tSettings('sections.account.page.AppearanceSettings.theme-mode-description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<div className="grid grid-cols-3 gap-x-2 sm:gap-x-4">
|
<div className="grid grid-cols-3 gap-x-2 sm:gap-x-4">
|
||||||
{options.map(({ id, label, img }) => {
|
{options.map(({ id, label, img }) => {
|
||||||
const isChecked = theme === id
|
const isChecked = theme === id
|
||||||
|
const src = img ? `/assets/img/themes/${img}` : '/assets/img/logos/cs2.webp'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={id}
|
key={id}
|
||||||
htmlFor={`theme-${id}`}
|
htmlFor={`theme-${id}`}
|
||||||
className={`w-full sm:w-auto flex flex-col bg-white text-center cursor-pointer rounded-xl ring-1 ring-gray-200 dark:bg-neutral-800 dark:text-neutral-200 dark:ring-neutral-700
|
className={[
|
||||||
${isChecked ? 'ring-1 ring-blue-600 dark:ring-blue-500' : 'ring-1 ring-gray-200 dark:ring-neutral-700'}`}
|
'w-full sm:w-auto flex flex-col bg-white text-center cursor-pointer rounded-xl ring-1',
|
||||||
|
'dark:bg-neutral-800 dark:text-neutral-200',
|
||||||
|
isChecked ? 'ring-blue-600 dark:ring-blue-500' : 'ring-gray-200 dark:ring-neutral-700',
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@ -66,13 +84,25 @@ export default function AppearanceSettings() {
|
|||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={() => setTheme(id)}
|
onChange={() => setTheme(id)}
|
||||||
/>
|
/>
|
||||||
<img className="rounded-t-[14px] -mt-px" src={img ? `/assets/img/themes/${img}` : '/assets/img/logos/cs2.webp'} alt={label} loading="lazy" />
|
|
||||||
|
<div className="relative w-full aspect-[16/9] rounded-t-[14px] overflow-hidden -mt-px">
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 220px, (min-width: 640px) 33vw, 100vw"
|
||||||
|
className="object-cover"
|
||||||
|
// Falls deine Assets SVGs sind und du keine Optimierung willst:
|
||||||
|
// unoptimized={src.endsWith('.svg')}
|
||||||
|
priority={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={`py-3 px-2 text-sm font-semibold rounded-b-xl
|
className={[
|
||||||
${isChecked
|
'py-3 px-2 text-sm font-semibold rounded-b-xl',
|
||||||
? 'bg-blue-600 text-white'
|
isChecked ? 'bg-blue-600 text-white' : 'text-gray-800 dark:text-neutral-200',
|
||||||
: 'text-gray-800 dark:text-neutral-200'
|
].join(' ')}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Popover from '../../Popover'
|
import Popover from '../../Popover'
|
||||||
import Button from '../../Button'
|
import Button from '../../Button'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export default function AuthCodeSettings() {
|
export default function AuthCodeSettings() {
|
||||||
const [authCode, setAuthCode] = useState('')
|
const [authCode, setAuthCode] = useState('')
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Popover from '../../Popover'
|
import Popover from '../../Popover'
|
||||||
import Button from '../../Button'
|
import Button from '../../Button'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export default function LatestKnownCodeSettings() {
|
export default function LatestKnownCodeSettings() {
|
||||||
const [lastKnownShareCode, setLastKnownShareCode] = useState('')
|
const [lastKnownShareCode, setLastKnownShareCode] = useState('')
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import Popover from '../../Popover'
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
// Versuche, native Liste zu nehmen; fallback auf gängige Auswahl.
|
// Fallback-Liste für Umgebungen ohne Intl.supportedValuesOf
|
||||||
const FALLBACK_TIMEZONES = [
|
const FALLBACK_TIMEZONES = [
|
||||||
'UTC',
|
'UTC',
|
||||||
'Europe/Berlin', 'Europe/Vienna', 'Europe/Zurich',
|
'Europe/Berlin', 'Europe/Vienna', 'Europe/Zurich',
|
||||||
@ -14,16 +13,21 @@ const FALLBACK_TIMEZONES = [
|
|||||||
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
||||||
'America/Sao_Paulo',
|
'America/Sao_Paulo',
|
||||||
'Asia/Tokyo', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Kolkata',
|
'Asia/Tokyo', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Kolkata',
|
||||||
'Australia/Sydney'
|
'Australia/Sydney',
|
||||||
]
|
]
|
||||||
|
|
||||||
function getTimeZones(): string[] {
|
function getTimeZones(): string[] {
|
||||||
// @ts-ignore – Node/Browser, je nach Runtime verfügbar
|
// typ-sicher prüfen, ohne ts-ignore
|
||||||
if (typeof Intl.supportedValuesOf === 'function') {
|
const maybeSupportedValuesOf = (Intl as unknown as { supportedValuesOf?: (k: string) => unknown[] }).supportedValuesOf
|
||||||
|
if (typeof maybeSupportedValuesOf === 'function') {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
const list = maybeSupportedValuesOf('timeZone')
|
||||||
return Intl.supportedValuesOf('timeZone') as string[]
|
if (Array.isArray(list) && list.every((z) => typeof z === 'string')) {
|
||||||
} catch {}
|
return list as string[]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fall back
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return FALLBACK_TIMEZONES
|
return FALLBACK_TIMEZONES
|
||||||
}
|
}
|
||||||
@ -50,7 +54,7 @@ export default function UserSettings() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/user/timezone', { cache: 'no-store' })
|
const res = await fetch('/api/user/timezone', { cache: 'no-store' })
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
const tz = data?.timeZone ?? null
|
const tz = (data as { timeZone?: string })?.timeZone ?? null
|
||||||
setTimeZone(tz)
|
setTimeZone(tz)
|
||||||
setInitialTz(tz)
|
setInitialTz(tz)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -61,9 +65,8 @@ export default function UserSettings() {
|
|||||||
})()
|
})()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Helper: Speichern (mit Abort bei schnellen Wechseln)
|
// Speichern (mit Abort bei schnellen Wechseln)
|
||||||
const persist = async (tz: string | null) => {
|
const persist = async (tz: string | null) => {
|
||||||
// laufende Anfrage abbrechen
|
|
||||||
inFlight.current?.abort()
|
inFlight.current?.abort()
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
inFlight.current = ctrl
|
inFlight.current = ctrl
|
||||||
@ -77,22 +80,23 @@ export default function UserSettings() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ timeZone: tz }),
|
body: JSON.stringify({ timeZone: tz }),
|
||||||
signal: ctrl.signal
|
signal: ctrl.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const j = await res.json().catch(() => ({}))
|
const j = await res.json().catch(() => ({}))
|
||||||
throw new Error(j?.message || `HTTP ${res.status}`)
|
throw new Error((j as { message?: string })?.message || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
setInitialTz(tz)
|
setInitialTz(tz)
|
||||||
setSavedOk(true)
|
setSavedOk(true)
|
||||||
setTouched(false) // <- hinzu
|
setTouched(false)
|
||||||
// kleines Auto-Reset des „Gespeichert“-Hinweises
|
|
||||||
window.setTimeout(() => setSavedOk(null), 2000)
|
window.setTimeout(() => setSavedOk(null), 2000)
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
if (e?.name === 'AbortError') return
|
// Abort sauber behandeln
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||||
|
const msg = e instanceof Error ? e.message : 'Save failed'
|
||||||
console.error('[UserSettings] Speichern fehlgeschlagen:', e)
|
console.error('[UserSettings] Speichern fehlgeschlagen:', e)
|
||||||
setSavedOk(false)
|
setSavedOk(false)
|
||||||
setErrorMsg(e?.message ?? 'Save failed')
|
setErrorMsg(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@ -105,7 +109,7 @@ export default function UserSettings() {
|
|||||||
|
|
||||||
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
|
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
|
||||||
debounceTimer.current = window.setTimeout(() => {
|
debounceTimer.current = window.setTimeout(() => {
|
||||||
persist(timeZone ?? null)
|
void persist(timeZone ?? null)
|
||||||
}, 400) as unknown as number
|
}, 400) as unknown as number
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -116,34 +120,29 @@ export default function UserSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="py-3 sm:py-4 space-y-5">
|
<div className="py-3 sm:py-4 space-y-5">
|
||||||
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
|
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
|
||||||
{/* Label + Hilfe */}
|
|
||||||
<div className="sm:col-span-4 2xl:col-span-2">
|
<div className="sm:col-span-4 2xl:col-span-2">
|
||||||
<label htmlFor="user-timezone" className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
|
<label htmlFor="user-timezone" className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
|
||||||
{tSettings('sections.user.timezone-label')}
|
{tSettings('sections.user.timezone-label')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Eingabe */}
|
|
||||||
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
id="user-timezone"
|
id="user-timezone"
|
||||||
value={timeZone ?? ''} // bleibt kontrolliert
|
value={timeZone ?? ''} // kontrollierter Input
|
||||||
onChange={(e) => { setTimeZone(e.target.value || null); setTouched(true) }}
|
onChange={(e) => { setTimeZone(e.target.value || null); setTouched(true) }}
|
||||||
disabled={saving || loading} // während Lade-/Speicherphase sperren
|
disabled={saving || loading}
|
||||||
aria-busy={loading ? 'true' : undefined} // a11y
|
aria-busy={loading ? 'true' : undefined}
|
||||||
className="max-w-md rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm disabled:opacity-70"
|
className="max-w-md rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
// Nur während des Ladens:
|
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{tCommon("loading")}...
|
{tCommon('loading')}…
|
||||||
</option>
|
</option>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<option value="">
|
<option value="">{tSettings('sections.user.timezone-system')}</option>
|
||||||
{tSettings('sections.user.timezone-system')}
|
|
||||||
</option>
|
|
||||||
{timeZones.map((tz) => (
|
{timeZones.map((tz) => (
|
||||||
<option key={tz} value={tz}>{tz}</option>
|
<option key={tz} value={tz}>{tz}</option>
|
||||||
))}
|
))}
|
||||||
@ -151,7 +150,6 @@ export default function UserSettings() {
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Live-Status rechts (optional) */}
|
|
||||||
<span className="text-xs min-w-[80px] text-right">
|
<span className="text-xs min-w-[80px] text-right">
|
||||||
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving')}…</span>}
|
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving')}…</span>}
|
||||||
{savedOk === true && <span className="text-teal-600">✓ {tCommon('saved')}</span>}
|
{savedOk === true && <span className="text-teal-600">✓ {tCommon('saved')}</span>}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// app/[locale]/settings/_components/PrivacySettings.tsx
|
// /src/app/[locale]/components/settings/privacy/PrivacySettings.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
@ -26,7 +26,10 @@ export default function PrivacySettings() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/user/privacy', { cache: 'no-store' })
|
const res = await fetch('/api/user/privacy', { cache: 'no-store' })
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
const value = typeof data?.canBeInvited === 'boolean' ? data.canBeInvited : true
|
const value =
|
||||||
|
typeof (data as { canBeInvited?: unknown })?.canBeInvited === 'boolean'
|
||||||
|
? (data as { canBeInvited: boolean }).canBeInvited
|
||||||
|
: true
|
||||||
setCanBeInvited(value)
|
setCanBeInvited(value)
|
||||||
setInitial(value)
|
setInitial(value)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -52,20 +55,24 @@ export default function PrivacySettings() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ canBeInvited: value }),
|
body: JSON.stringify({ canBeInvited: value }),
|
||||||
signal: ctrl.signal
|
signal: ctrl.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const j = await res.json().catch(() => ({}))
|
const j = await res.json().catch(() => ({}))
|
||||||
throw new Error(j?.message || `HTTP ${res.status}`)
|
const msg =
|
||||||
|
(j as { message?: string })?.message || `HTTP ${res.status}`
|
||||||
|
throw new Error(msg)
|
||||||
}
|
}
|
||||||
setInitial(value)
|
setInitial(value)
|
||||||
setSavedOk(true)
|
setSavedOk(true)
|
||||||
window.setTimeout(() => setSavedOk(null), 2000)
|
window.setTimeout(() => setSavedOk(null), 2000)
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
if (e?.name === 'AbortError') return
|
// Abort sauber behandeln
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||||
|
const msg = e instanceof Error ? e.message : 'Save failed'
|
||||||
console.error('[PrivacySettings] save failed:', e)
|
console.error('[PrivacySettings] save failed:', e)
|
||||||
setSavedOk(false)
|
setSavedOk(false)
|
||||||
setErrorMsg(e?.message ?? 'Save failed')
|
setErrorMsg(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@ -76,7 +83,7 @@ export default function PrivacySettings() {
|
|||||||
if (canBeInvited === initial) return
|
if (canBeInvited === initial) return
|
||||||
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
|
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
|
||||||
debounceTimer.current = window.setTimeout(() => {
|
debounceTimer.current = window.setTimeout(() => {
|
||||||
persist(canBeInvited)
|
void persist(canBeInvited)
|
||||||
}, 400) as unknown as number
|
}, 400) as unknown as number
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
|
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
|
||||||
@ -85,28 +92,23 @@ export default function PrivacySettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700">
|
<div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700">
|
||||||
{/* Zeile: alles vertikal mittig */}
|
|
||||||
<div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-center">
|
<div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-center">
|
||||||
{/* Label-Spalte */}
|
|
||||||
<div className="sm:col-span-4 2xl:col-span-2 flex items-center">
|
<div className="sm:col-span-4 2xl:col-span-2 flex items-center">
|
||||||
<span className="inline-block text-sm text-gray-500 dark:text-neutral-500">
|
<span className="inline-block text-sm text-gray-500 dark:text-neutral-500">
|
||||||
{tSettings('sections.privacy.invites.label')}
|
{tSettings('sections.privacy.invites.label')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inhalt-Spalte */}
|
|
||||||
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
||||||
{/* Switch + Hilfstext rechts → vertikal mittig */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Toggle */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
onClick={() => setCanBeInvited(v => !v)}
|
onClick={() => setCanBeInvited((v) => !v)}
|
||||||
className={[
|
className={[
|
||||||
'relative inline-flex h-6 w-11 items-center rounded-full transition',
|
'relative inline-flex h-6 w-11 items-center rounded-full transition',
|
||||||
canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700',
|
canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700',
|
||||||
'disabled:opacity-60 disabled:cursor-not-allowed'
|
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
aria-pressed={canBeInvited}
|
aria-pressed={canBeInvited}
|
||||||
aria-label={tSettings('sections.privacy.invites.label')}
|
aria-label={tSettings('sections.privacy.invites.label')}
|
||||||
@ -119,15 +121,12 @@ export default function PrivacySettings() {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Rechts: Hilfs-Text + Status NEBENeinander */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{/* Hilfstext links, darf umbrechen */}
|
|
||||||
<p className="m-0 text-sm text-gray-500 dark:text-neutral-400 min-w-0 flex-1">
|
<p className="m-0 text-sm text-gray-500 dark:text-neutral-400 min-w-0 flex-1">
|
||||||
{tSettings('sections.privacy.invites.help')}
|
{tSettings('sections.privacy.invites.help')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Status rechts daneben, bleibt in einer Zeile */}
|
|
||||||
<div className="ml-auto text-xs whitespace-nowrap" aria-live="polite">
|
<div className="ml-auto text-xs whitespace-nowrap" aria-live="polite">
|
||||||
{loading && (
|
{loading && (
|
||||||
<span className="text-gray-500 dark:text-neutral-400">
|
<span className="text-gray-500 dark:text-neutral-400">
|
||||||
@ -140,9 +139,7 @@ export default function PrivacySettings() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{savedOk === true && (
|
{savedOk === true && (
|
||||||
<span className="text-teal-600">
|
<span className="text-teal-600">✓ {tCommon('saved') ?? 'Gespeichert'}</span>
|
||||||
✓ {tCommon('saved') ?? 'Gespeichert'}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{savedOk === false && (
|
{savedOk === false && (
|
||||||
<span className="text-red-600">
|
<span className="text-red-600">
|
||||||
|
|||||||
@ -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 base = host ? `${proto}://${host}` : (process.env.NEXTAUTH_URL ?? 'http://localhost:3000')
|
||||||
|
|
||||||
const insecure = new Agent({ connect: { rejectUnauthorized: false } })
|
const insecure = new Agent({ connect: { rejectUnauthorized: false } })
|
||||||
const init: any = { cache: 'no-store' }
|
const init: RequestInit & { dispatcher?: Agent } = { cache: 'no-store' }
|
||||||
if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') {
|
if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') {
|
||||||
init.dispatcher = insecure
|
init.dispatcher = insecure
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,242 @@
|
|||||||
export default function Page() {
|
// /src/app/[locale]/page.tsx
|
||||||
return (
|
'use client'
|
||||||
<>
|
|
||||||
<h1>Home</h1>
|
import { useEffect, useState } from 'react'
|
||||||
</>
|
import { useTranslations } from 'next-intl'
|
||||||
);
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
// ---- minimal types (no any)
|
||||||
|
type TeamLike = { id?: string; name?: string; teamname?: string; logo?: string | null }
|
||||||
|
type TeamsJson = { teams?: TeamLike[] } | { data?: TeamLike[] } | TeamLike[] | unknown
|
||||||
|
|
||||||
|
function parseTeams(json: TeamsJson): TeamLike[] {
|
||||||
|
if (Array.isArray(json)) return json as TeamLike[]
|
||||||
|
if (typeof json === 'object' && json !== null) {
|
||||||
|
const o = json as Record<string, unknown>
|
||||||
|
if (Array.isArray(o.teams)) return o.teams as TeamLike[]
|
||||||
|
if (Array.isArray(o.data)) return o.data as TeamLike[]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const t = useTranslations('dashboard')
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [teams, setTeams] = useState<TeamLike[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let aborted = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const json: unknown = await res.json().catch(() => ({}))
|
||||||
|
if (!aborted) setTeams(parseTeams(json))
|
||||||
|
} catch {
|
||||||
|
if (!aborted) setTeams([])
|
||||||
|
} finally {
|
||||||
|
if (!aborted) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
aborted = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* HERO */}
|
||||||
|
<section className="relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-6 sm:p-8 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||||
|
<div className="absolute -top-24 right-0 h-64 w-64 rounded-full blur-3xl opacity-30 bg-gradient-to-br from-indigo-500 to-blue-400 dark:opacity-20" />
|
||||||
|
<div className="absolute -bottom-24 left-0 h-64 w-64 rounded-full blur-3xl opacity-30 bg-gradient-to-tr from-rose-500 to-amber-400 dark:opacity-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse items-start gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-neutral-300">
|
||||||
|
Organisiere Matches, lade Mitspieler ein und analysiere Demos – alles an einem Ort.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTAs */}
|
||||||
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
href="/team"
|
||||||
|
className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900"
|
||||||
|
>
|
||||||
|
<svg className="mr-2 h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M10 3a1 1 0 0 1 1 1v5h5a1 1 0 1 1 0 2h-5v5a1 1 0 1 1-2 0v-5H4a1 1 0 1 1 0-2h5V4a1 1 0 0 1 1-1z" />
|
||||||
|
</svg>
|
||||||
|
Team erstellen
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/teams"
|
||||||
|
className="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M7 10a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm10 0a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM7 12c-3.866 0-7 2.239-7 5v1a1 1 0 0 0 1 1h12v-2c0-2.761-3.134-5-7-5Zm10 0c-.7 0-1.374.088-2 .248 2.39 1.023 4 2.96 4 5.252v2h4a1 1 0 0 0 1-1v-1c0-2.761-3.134-5-7-5Z" />
|
||||||
|
</svg>
|
||||||
|
Team finden & beitreten
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/schedule"
|
||||||
|
className="inline-flex items-center rounded-md border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100 dark:border-indigo-900/40 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50"
|
||||||
|
>
|
||||||
|
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M7 2a1 1 0 0 1 1 1v1h8V3a1 1 0 1 1 2 0v1h1a2 2 0 0 1 2 2v3H2V6a2 2 0 0 1 2-2h1V3a1 1 0 0 1 2 0v1ZM2 10h20v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8Zm5 3a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H7Z" />
|
||||||
|
</svg>
|
||||||
|
Match planen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* small visual on the right */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="relative h-28 w-28 overflow-hidden rounded-xl ring-1 ring-gray-200 dark:ring-neutral-800">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/logos/cs2.webp"
|
||||||
|
alt="CS2"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="112px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GRID: Teams + Activity + Promo */}
|
||||||
|
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Your Teams */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Deine Teams</h2>
|
||||||
|
<Link
|
||||||
|
href="/teams"
|
||||||
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
Alle ansehen →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="animate-pulse rounded-lg border border-gray-200 p-4 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
<div className="mb-3 h-10 w-10 rounded-full bg-gray-200 dark:bg-neutral-800" />
|
||||||
|
<div className="h-4 w-2/3 rounded bg-gray-200 dark:bg-neutral-800" />
|
||||||
|
<div className="mt-2 h-3 w-1/2 rounded bg-gray-100 dark:bg-neutral-800/80" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : teams.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-neutral-800 dark:text-neutral-400">
|
||||||
|
Du hast noch kein Team.{' '}
|
||||||
|
<Link className="text-indigo-600 hover:underline dark:text-indigo-300" href="/team/create">
|
||||||
|
Jetzt erstellen
|
||||||
|
</Link>{' '}
|
||||||
|
oder{' '}
|
||||||
|
<Link className="text-indigo-600 hover:underline dark:text-indigo-300" href="/teams">
|
||||||
|
beitreten
|
||||||
|
</Link>.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{teams.map((t) => {
|
||||||
|
const label = t.teamname ?? t.name ?? 'Unbenannt'
|
||||||
|
const logoPath = t.logo ? `/assets/img/logos/${t.logo}` : '/assets/img/logos/cs2.webp'
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={t.id ?? label}
|
||||||
|
className="group rounded-lg border border-gray-200 p-4 transition hover:shadow-sm dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative h-10 w-10 overflow-hidden rounded-full ring-1 ring-gray-200 dark:ring-neutral-700">
|
||||||
|
<Image src={logoPath} alt={label} fill className="object-cover" sizes="40px" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{label}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-neutral-400">Team</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Link
|
||||||
|
href="/team"
|
||||||
|
className="inline-flex items-center text-xs font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
Team öffnen →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Aktivität</h2>
|
||||||
|
<Link
|
||||||
|
href="/matches"
|
||||||
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
Matches ansehen →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-neutral-400">
|
||||||
|
Hier erscheinen demnächst Match-Updates, Map-Votes & Einladungen in Echtzeit.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Promo / Feature highlight */}
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-amber-200/60 bg-amber-50 p-5 dark:border-amber-900/40 dark:bg-amber-900/20">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-amber-400/90 text-white shadow-sm dark:bg-amber-500">
|
||||||
|
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
|
||||||
|
<path d="M10 2l1.5 4.5L16 8l-4.5 1.5L10 14l-1.5-4.5L4 8l4.5-1.5L10 2zm-6 10l.9 2.7L8 16l-2.7.9L4 20l-.9-2.7L0 16l2.7-.9L4 12zM16 10l.6 1.8L19 12l-1.4 1.1L18 15l-1.6-.8L15 15l.4-1.9L14 12l2.4-.2L16 10z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Pro-Workflow</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-amber-900/90 dark:text-amber-200/90">
|
||||||
|
Map-Vote, Server-Connect Banner & Demo-Parsing – optimiere deinen Ablauf in wenigen Klicks.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Link href="/schedule" className="text-xs font-semibold text-amber-900 underline underline-offset-4 dark:text-amber-200">
|
||||||
|
Match planen
|
||||||
|
</Link>
|
||||||
|
<Link href="/upload" className="text-xs font-semibold text-amber-900 underline underline-offset-4 dark:text-amber-200">
|
||||||
|
Demo importieren
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">Tipps</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600 dark:text-neutral-300">
|
||||||
|
<li>• Lade dein Team ein und setze Rollen (Leader / Spieler).</li>
|
||||||
|
<li>• Teile den Share-Code, um Demos automatisch zu analysieren.</li>
|
||||||
|
<li>• Aktiviere Browser-Benachrichtigungen für Map-Vote-Updates.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,6 @@ export default function ProfileHeader({ user: u }: Props) {
|
|||||||
const showGameBan = (u.numberOfGameBans ?? 0) > 0
|
const showGameBan = (u.numberOfGameBans ?? 0) > 0
|
||||||
const showComm = !!u.communityBanned
|
const showComm = !!u.communityBanned
|
||||||
const showEcon = !!u.economyBan && u.economyBan !== 'none'
|
const showEcon = !!u.economyBan && u.economyBan !== 'none'
|
||||||
const showLastBan = typeof u.daysSinceLastBan === 'number'
|
|
||||||
const hasAnyBan = showVac || showGameBan || showComm || showEcon
|
const hasAnyBan = showVac || showGameBan || showComm || showEcon
|
||||||
const hasFaceit = !!u.faceitUrl
|
const hasFaceit = !!u.faceitUrl
|
||||||
|
|
||||||
@ -60,7 +59,7 @@ export default function ProfileHeader({ user: u }: Props) {
|
|||||||
aria-label="Faceit-Profil öffnen" title={`Faceit-Profil von ${u.faceitNickname ?? u.name ?? ''}`}
|
aria-label="Faceit-Profil öffnen" title={`Faceit-Profil von ${u.faceitNickname ?? u.name ?? ''}`}
|
||||||
className="inline-flex size-9 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
|
className="inline-flex size-9 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<img src="/assets/img/logos/faceit.svg" alt="" className="h-5 w-5" aria-hidden />
|
<Image src="/assets/img/logos/faceit.svg" alt="" width={16} height={16} aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { NextRequest } from 'next/server'
|
|
||||||
|
|
||||||
export default async function ProfileRedirectPage() {
|
export default async function ProfileRedirectPage() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
|
||||||
import Switch from '../components/Switch'
|
|
||||||
import Button from '../components/Button'
|
|
||||||
import CommunityMatchList from '../components/CommunityMatchList'
|
import CommunityMatchList from '../components/CommunityMatchList'
|
||||||
import Card from '../components/Card'
|
import Card from '../components/Card'
|
||||||
|
|
||||||
@ -18,9 +13,7 @@ type Match = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MatchesPage() {
|
export default function MatchesPage() {
|
||||||
const { data: session } = useSession()
|
const [, setMatches] = useState<Match[]>([])
|
||||||
const [matches, setMatches] = useState<Match[]>([])
|
|
||||||
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/schedule')
|
fetch('/api/schedule')
|
||||||
@ -32,12 +25,6 @@ export default function MatchesPage() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filteredMatches = onlyOwnTeam && session?.user?.team
|
|
||||||
? matches.filter(
|
|
||||||
(m) => m.teamA.id === session.user.team || m.teamB.id === session.user.team
|
|
||||||
)
|
|
||||||
: matches
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card maxWidth='auto'>
|
<Card maxWidth='auto'>
|
||||||
<CommunityMatchList matchType="community" />
|
<CommunityMatchList matchType="community" />
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
// app/[locale]/settings/_sections/AccountSection.tsx
|
// /src/app/[locale]/settings/_sections/AccountSection.tsx
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { getServerSession } from 'next-auth'
|
|
||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import AuthCodeSettings from '../../components/settings/account/AuthCodeSettings'
|
import AuthCodeSettings from '../../components/settings/account/AuthCodeSettings'
|
||||||
import LatestKnownCodeSettings from '../../components/settings/account/ShareCodeSettings'
|
import LatestKnownCodeSettings from '../../components/settings/account/ShareCodeSettings'
|
||||||
@ -9,17 +7,6 @@ import LatestKnownCodeSettings from '../../components/settings/account/ShareCode
|
|||||||
export default async function AccountSection() {
|
export default async function AccountSection() {
|
||||||
const tSettings = await getTranslations('settings')
|
const tSettings = await getTranslations('settings')
|
||||||
|
|
||||||
// Session laden (passe das an deine authOptions an)
|
|
||||||
const session = await getServerSession(/* authOptions */)
|
|
||||||
const steamId = (session as any)?.user?.id ?? null
|
|
||||||
|
|
||||||
const user = steamId
|
|
||||||
? await prisma.user.findUnique({
|
|
||||||
where: { steamId },
|
|
||||||
select: { faceitId: true, faceitNickname: true, faceitAvatar: true },
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="account" className="scroll-mt-16 pb-10">
|
<section id="account" className="scroll-mt-16 pb-10">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { sessionAuthOptions } from '@/lib/auth'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { decrypt, encrypt } from '@/lib/crypto'
|
import { decrypt, encrypt } from '@/lib/crypto'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
|
|
||||||
const EXPIRY_DAYS = 30
|
const EXPIRY_DAYS = 30
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
|
|
||||||
|
|||||||
@ -50,18 +50,12 @@ export async function GET(req: NextRequest) {
|
|||||||
const steamId = /* session?.user?.id o.ä. */ null
|
const steamId = /* session?.user?.id o.ä. */ null
|
||||||
if (!steamId) return NextResponse.redirect('/settings?faceit=no_user')
|
if (!steamId) return NextResponse.redirect('/settings?faceit=no_user')
|
||||||
|
|
||||||
const expires = token.expires_in ? new Date(Date.now() + token.expires_in * 1000) : null
|
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { steamId },
|
where: { steamId },
|
||||||
data: {
|
data: {
|
||||||
faceitId: me.guid ?? null,
|
faceitId: me.guid ?? null,
|
||||||
faceitNickname: me.nickname ?? null,
|
faceitNickname: me.nickname ?? null,
|
||||||
faceitAvatar: me.avatar ?? null,
|
faceitAvatar: me.avatar ?? null,
|
||||||
// Tokens nur speichern, wenn nötig:
|
|
||||||
// faceitAccessToken: token.access_token,
|
|
||||||
// faceitRefreshToken: token.refresh_token ?? null,
|
|
||||||
// faceitTokenExpiresAt: expires,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
@ -42,7 +42,7 @@ async function findCurrentMatch() {
|
|||||||
return upcoming
|
return upcoming
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(_req: NextRequest) {
|
export async function GET() {
|
||||||
const match = await findCurrentMatch()
|
const match = await findCurrentMatch()
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return NextResponse.json({ matchId: null, steamIds: [], total: 0 }, { headers: { 'Cache-Control': 'no-store' } })
|
return NextResponse.json({ matchId: null, steamIds: [], total: 0 }, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Notifications für aktuellen User laden
|
// ✅ Notifications für aktuellen User laden
|
||||||
export async function GET(_req: NextRequest) {
|
export async function GET() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const meSteamId = (session?.user as { steamId?: string })?.steamId
|
const meSteamId = (session?.user as { steamId?: string })?.steamId
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ export async function GET(_req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Alle Notifications auf "gelesen" setzen
|
// ✅ Alle Notifications auf "gelesen" setzen
|
||||||
export async function PUT(_req: NextRequest) {
|
export async function PUT() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const meSteamId = (session?.user as { steamId?: string })?.steamId
|
const meSteamId = (session?.user as { steamId?: string })?.steamId
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
// app/api/notifications/mark-all-read/route.ts
|
// /src/app/api/notifications/mark-all-read/route.ts
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
|
|
||||||
if (!session?.user?.steamId) {
|
if (!session?.user?.steamId) {
|
||||||
|
|||||||
@ -46,7 +46,6 @@ export async function GET() {
|
|||||||
const formatted = matches.map(m => {
|
const formatted = matches.map(m => {
|
||||||
const matchDate =
|
const matchDate =
|
||||||
m.demoDate ??
|
m.demoDate ??
|
||||||
// @ts-ignore – falls du optional noch ein „date“-Feld hast
|
|
||||||
(m as any).date ??
|
(m as any).date ??
|
||||||
m.createdAt
|
m.createdAt
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
// /src/app/api/stats/[steamId]/route.ts
|
// /src/app/api/stats/[steamId]/route.ts
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import type { AsyncParams } from '@/types/next' // ← nutzt deinen Typ
|
import type { AsyncParams } from '@/types/next'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
ctx: AsyncParams<{ steamId: string }>
|
ctx: AsyncParams<{ steamId: string }>
|
||||||
) {
|
) {
|
||||||
const { steamId } = await ctx.params // ← params auflösen
|
const { steamId } = await ctx.params
|
||||||
|
|
||||||
if (!steamId) {
|
if (!steamId) {
|
||||||
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
||||||
@ -59,13 +59,15 @@ export async function GET(
|
|||||||
orderBy: { match: { demoDate: 'asc' } },
|
orderBy: { match: { demoDate: 'asc' } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// kleiner Helper ohne `any`
|
||||||
|
const readRounds = (v: unknown): number | null => {
|
||||||
|
const r = (v as { rounds?: unknown } | null | undefined)?.rounds
|
||||||
|
return typeof r === 'number' && Number.isFinite(r) ? r : null
|
||||||
|
}
|
||||||
|
|
||||||
const FallbackDate = new Date(0).toISOString().split('T')[0] // "1970-01-01"
|
const FallbackDate = new Date(0).toISOString().split('T')[0] // "1970-01-01"
|
||||||
const stats = matches.map((entry) => {
|
const stats = matches.map((entry) => {
|
||||||
const rounds =
|
const rounds = readRounds(entry.stats /* | { roundCount?: unknown } */)
|
||||||
(entry.stats as any)?.rounds ??
|
|
||||||
// (entry.match as any)?.roundCount ??
|
|
||||||
null
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: entry.match?.demoDate?.toISOString().split('T')[0] ?? FallbackDate,
|
date: entry.match?.demoDate?.toISOString().split('T')[0] ?? FallbackDate,
|
||||||
kills: entry.stats?.kills ?? 0,
|
kills: entry.stats?.kills ?? 0,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
|
|
||||||
if (!session || !session.user?.steamId) {
|
if (!session || !session.user?.steamId) {
|
||||||
|
|||||||
@ -18,8 +18,30 @@ export async function GET(
|
|||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
include: {
|
include: {
|
||||||
leader: true,
|
leader: {
|
||||||
invites: { include: { user: true } },
|
select: {
|
||||||
|
steamId: true,
|
||||||
|
name: true,
|
||||||
|
avatar: true,
|
||||||
|
location: true,
|
||||||
|
premierRank: true,
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invites: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
steamId: true,
|
||||||
|
name: true,
|
||||||
|
avatar: true,
|
||||||
|
location: true,
|
||||||
|
premierRank: true,
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -44,7 +66,18 @@ export async function GET(
|
|||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const toPlayer = (u: any): Player => ({
|
// 1) Gemeinsamer Typ für alle "User"-Objekte, die wir in Player mappen
|
||||||
|
type UserLike = {
|
||||||
|
steamId: string
|
||||||
|
name: string | null
|
||||||
|
avatar: string | null
|
||||||
|
location: string | null
|
||||||
|
premierRank: number | null
|
||||||
|
isAdmin: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Ein Helper: Prisma -> Player
|
||||||
|
const toPlayer = (u: UserLike): Player => ({
|
||||||
steamId: u.steamId,
|
steamId: u.steamId,
|
||||||
name: u.name ?? 'Unbekannt',
|
name: u.name ?? 'Unbekannt',
|
||||||
avatar: u.avatar ?? '/assets/img/avatars/default.png',
|
avatar: u.avatar ?? '/assets/img/avatars/default.png',
|
||||||
|
|||||||
@ -30,12 +30,6 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ▸ Alle Teammitglieder (alt + neu) als Zielgruppe ------------------------- */
|
|
||||||
const allPlayers = [
|
|
||||||
...team.activePlayers,
|
|
||||||
...team.inactivePlayers,
|
|
||||||
]
|
|
||||||
|
|
||||||
/* ▸ SSE-Push --------------------------------------------------------------- */
|
/* ▸ SSE-Push --------------------------------------------------------------- */
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type : 'team-member-joined',
|
type : 'team-member-joined',
|
||||||
|
|||||||
@ -5,6 +5,12 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// Minimaler Type-Guard für Prisma KnownRequestError
|
||||||
|
type KnownPrismaError = { code: string; meta?: Record<string, unknown> }
|
||||||
|
function isKnownPrismaError(e: unknown): e is KnownPrismaError {
|
||||||
|
return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
|
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
|
||||||
@ -33,13 +39,11 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 })
|
return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// user dem Team zuordnen
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { steamId: leader },
|
where: { steamId: leader },
|
||||||
data: { teamId: newTeam.id },
|
data: { teamId: newTeam.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 🔔 (optional) persistente Notification
|
|
||||||
const note = await prisma.notification.create({
|
const note = await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
steamId: leader,
|
steamId: leader,
|
||||||
@ -50,7 +54,6 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ➜ Sofort an Notification-Center
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'notification',
|
type: 'notification',
|
||||||
targetUserIds: [leader],
|
targetUserIds: [leader],
|
||||||
@ -61,14 +64,12 @@ export async function POST(req: NextRequest) {
|
|||||||
createdAt: note.createdAt.toISOString(),
|
createdAt: note.createdAt.toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ✅ ➜ HIER: Self-Refresh für den Ersteller
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'self-updated', // <— stelle sicher, dass dein Client darauf hört
|
type: 'self-updated',
|
||||||
targetUserIds: [leader],
|
targetUserIds: [leader],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Optional) Broadcasts
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-created',
|
type: 'team-created',
|
||||||
title: 'Team erstellt',
|
title: 'Team erstellt',
|
||||||
@ -85,8 +86,8 @@ export async function POST(req: NextRequest) {
|
|||||||
{ message: 'Team erstellt', team: newTeam },
|
{ message: 'Team erstellt', team: newTeam },
|
||||||
{ headers: { 'Cache-Control': 'no-store' } },
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
)
|
)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error?.code === 'P2002') {
|
if (isKnownPrismaError(error) && error.code === 'P2002') {
|
||||||
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
|
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
|
||||||
}
|
}
|
||||||
console.error('❌ Fehler beim Team erstellen:', error)
|
console.error('❌ Fehler beim Team erstellen:', error)
|
||||||
|
|||||||
@ -5,9 +5,26 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// kleiner Body-Parser für sichere Typen
|
||||||
|
function parseBody(v: unknown): { teamId?: string; newName?: string } {
|
||||||
|
if (!v || typeof v !== 'object') return {}
|
||||||
|
const r = v as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
teamId : typeof r.teamId === 'string' ? r.teamId : undefined,
|
||||||
|
newName: typeof r.newName === 'string' ? r.newName : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prisma-Fehler-Shape (ohne Import von Prisma-Typen)
|
||||||
|
type KnownPrismaError = { code: string; meta?: Record<string, unknown> }
|
||||||
|
function isKnownPrismaError(e: unknown): e is KnownPrismaError {
|
||||||
|
return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { teamId, newName } = await req.json()
|
const raw = await req.json().catch(() => null)
|
||||||
|
const { teamId, newName } = parseBody(raw)
|
||||||
const name = (newName ?? '').trim()
|
const name = (newName ?? '').trim()
|
||||||
|
|
||||||
if (!teamId || !name) {
|
if (!teamId || !name) {
|
||||||
@ -24,19 +41,19 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Umbenennen (Unique-Constraint beachten)
|
// Umbenennen (Unique-Constraint beachten)
|
||||||
let updated
|
const updated = await prisma.team.update({
|
||||||
try {
|
|
||||||
updated = await prisma.team.update({
|
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
data: { name },
|
data: { name },
|
||||||
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
|
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
})
|
}).catch((e: unknown) => {
|
||||||
} catch (e: any) {
|
if (isKnownPrismaError(e) && e.code === 'P2002') {
|
||||||
if (e?.code === 'P2002') {
|
|
||||||
return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 })
|
return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 })
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
})
|
||||||
|
|
||||||
|
// Wenn wir oben bereits mit NextResponse zurückgekehrt sind, ist updated ein Response
|
||||||
|
if (updated instanceof NextResponse) return updated
|
||||||
|
|
||||||
// Zielnutzer (Leader + aktive + inaktive) für persistente Notifications
|
// Zielnutzer (Leader + aktive + inaktive) für persistente Notifications
|
||||||
const targets = Array.from(new Set(
|
const targets = Array.from(new Set(
|
||||||
@ -49,7 +66,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const text = `Team wurde umbenannt in "${updated.name}".`
|
const text = `Team wurde umbenannt in "${updated.name}".`
|
||||||
|
|
||||||
// Persistente Notifications an Team-Mitglieder + Live-Zustellung (nur an diese Nutzer)
|
// Persistente Notifications + Live-Zustellung
|
||||||
if (targets.length) {
|
if (targets.length) {
|
||||||
const created = await Promise.all(
|
const created = await Promise.all(
|
||||||
targets.map(steamId =>
|
targets.map(steamId =>
|
||||||
@ -80,25 +97,24 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Globale Team-Events (Broadcast, KEIN targetUserIds) für alle Clients
|
// Broadcast-Events
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-renamed',
|
type: 'team-renamed',
|
||||||
teamId,
|
teamId,
|
||||||
message: text,
|
message: text,
|
||||||
newName: updated.name,
|
newName: updated.name,
|
||||||
})
|
})
|
||||||
|
await sendServerSSEMessage({ type: 'team-updated', teamId })
|
||||||
// Optionaler Failsafe-Reload als Broadcast
|
|
||||||
await sendServerSSEMessage({
|
|
||||||
type: 'team-updated',
|
|
||||||
teamId,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: true, team: { id: updated.id, name: updated.name } },
|
{ success: true, team: { id: updated.id, name: updated.name } },
|
||||||
{ headers: { 'Cache-Control': 'no-store' } },
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
|
// Fallback: P2002 nochmals abfangen, falls es außerhalb des catch-Blocks auftritt
|
||||||
|
if (isKnownPrismaError(err) && err.code === 'P2002') {
|
||||||
|
return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 })
|
||||||
|
}
|
||||||
console.error('Fehler beim Umbenennen:', err)
|
console.error('Fehler beim Umbenennen:', err)
|
||||||
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,31 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
type KnownPrismaErrorShape = { code: string; meta?: Record<string, unknown> };
|
||||||
|
|
||||||
|
// kleines Body-Parser-Helper
|
||||||
|
function parseBody(v: unknown): { teamId?: string; newLeaderSteamId?: string } {
|
||||||
|
if (!v || typeof v !== 'object') return {}
|
||||||
|
const r = v as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
teamId: typeof r.teamId === 'string' ? r.teamId : undefined,
|
||||||
|
newLeaderSteamId: typeof r.newLeaderSteamId === 'string' ? r.newLeaderSteamId : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type Guard für PrismaClientKnownRequestError
|
||||||
|
function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
|
||||||
|
return !!e
|
||||||
|
&& typeof e === 'object'
|
||||||
|
&& 'code' in e
|
||||||
|
&& typeof (e as { code: unknown }).code === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { teamId, newLeaderSteamId } = await req.json()
|
const raw = await req.json().catch(() => null)
|
||||||
|
const { teamId, newLeaderSteamId } = parseBody(raw)
|
||||||
|
|
||||||
if (!teamId || !newLeaderSteamId) {
|
if (!teamId || !newLeaderSteamId) {
|
||||||
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@ -18,7 +40,7 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
if (!team) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
|
if (!team) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
|
||||||
|
|
||||||
// ❗ Bereits Leader eines anderen Teams?
|
// Bereits Leader eines anderen Teams?
|
||||||
const otherLedTeam = await prisma.team.findFirst({
|
const otherLedTeam = await prisma.team.findFirst({
|
||||||
where: { leaderId: newLeaderSteamId, NOT: { id: teamId } },
|
where: { leaderId: newLeaderSteamId, NOT: { id: teamId } },
|
||||||
select: { id: true, name: true }
|
select: { id: true, name: true }
|
||||||
@ -45,7 +67,7 @@ export async function POST(req: NextRequest) {
|
|||||||
data : { leaderId: newLeaderSteamId },
|
data : { leaderId: newLeaderSteamId },
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Benachrichtigung & SSE unverändert ---
|
// Benachrichtigungen & SSE
|
||||||
const newLeader = await prisma.user.findUnique({
|
const newLeader = await prisma.user.findUnique({
|
||||||
where : { steamId: newLeaderSteamId },
|
where : { steamId: newLeaderSteamId },
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
@ -109,15 +131,20 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
|
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
|
||||||
} catch (error: any) {
|
} catch (err: unknown) {
|
||||||
// Falls du zusätzlich im Prisma-Schema @@unique([leaderId]) gesetzt hast:
|
// unique-Constraint auf leaderId behandeln (falls im Schema vorhanden)
|
||||||
if (error?.code === 'P2002' && error?.meta?.target?.includes('leaderId')) {
|
if (isKnownPrismaError(err) && err.code === 'P2002') {
|
||||||
|
const target = err.meta?.target; // type: unknown
|
||||||
|
|
||||||
|
if (Array.isArray(target) && target.includes('leaderId')) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' },
|
{ message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
console.error('Fehler beim Leaderwechsel:', error)
|
}
|
||||||
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
|
|
||||||
|
console.error('Fehler beim Leaderwechsel:', err);
|
||||||
|
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,19 +2,29 @@
|
|||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth' // ⬅️ hier umstellen
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
import type { TeamJoinPolicy } from '@/types/team'
|
import type { TeamJoinPolicy } from '@/types/team'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
const ALLOWED = ['REQUEST', 'INVITE_ONLY'] as const
|
// Einmal zentral definieren und später benutzen
|
||||||
type AllowedPolicy = (typeof ALLOWED)[number]
|
const ALLOWED: readonly TeamJoinPolicy[] = ['REQUEST', 'INVITE_ONLY'] as const
|
||||||
|
|
||||||
|
type Body = { teamId?: string; joinPolicy?: TeamJoinPolicy }
|
||||||
|
|
||||||
|
function parseBody(v: unknown): Body {
|
||||||
|
if (!v || typeof v !== 'object') return {}
|
||||||
|
const r = v as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
teamId: typeof r.teamId === 'string' ? r.teamId : undefined,
|
||||||
|
joinPolicy: typeof r.joinPolicy === 'string' ? (r.joinPolicy as TeamJoinPolicy) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// ⬇️ statt getServerSession(authOptions(req))
|
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
|
|
||||||
const meId = session?.user?.steamId
|
const meId = session?.user?.steamId
|
||||||
@ -22,14 +32,14 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({} as any))
|
const raw: unknown = await req.json().catch(() => null)
|
||||||
const teamId: string | undefined = body?.teamId
|
const { teamId, joinPolicy } = parseBody(raw)
|
||||||
const joinPolicy: TeamJoinPolicy | undefined = body?.joinPolicy
|
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||||
}
|
}
|
||||||
if (!joinPolicy || !ALLOWED.includes(joinPolicy as AllowedPolicy)) {
|
// Nutzung des zentralen ALLOWED, kein Alias nötig
|
||||||
|
if (!joinPolicy || !ALLOWED.includes(joinPolicy)) {
|
||||||
return NextResponse.json({ message: 'Ungültige joinPolicy' }, { status: 400 })
|
return NextResponse.json({ message: 'Ungültige joinPolicy' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,7 +74,6 @@ export async function POST(req: NextRequest) {
|
|||||||
select: { id: true, joinPolicy: true },
|
select: { id: true, joinPolicy: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fire-and-forget SSE
|
|
||||||
Promise.resolve().then(() =>
|
Promise.resolve().then(() =>
|
||||||
sendServerSSEMessage({
|
sendServerSSEMessage({
|
||||||
type: 'team-updated',
|
type: 'team-updated',
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
// /src/app/api/user/activity/route.ts
|
// /src/app/api/user/activity/route.ts
|
||||||
import { NextResponse, NextRequest } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const steamId = session?.user?.steamId // <-- hier definieren
|
const steamId = session?.user?.steamId // <-- hier definieren
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
// /src/app/api/user/away/route.ts
|
// /src/app/api/user/away/route.ts
|
||||||
import { NextResponse, NextRequest } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
|
|
||||||
|
|||||||
@ -13,12 +13,15 @@ export async function POST(
|
|||||||
const { action } = await ctx.params
|
const { action } = await ctx.params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json().catch(() => ({} as any))
|
const body = (await req.json().catch(() => ({}))) as Partial<{
|
||||||
|
invitationId: string
|
||||||
|
teamId: string
|
||||||
|
steamId: string
|
||||||
|
}>
|
||||||
|
|
||||||
// NEU: neben invitationId auch teamId+steamId als Fallback akzeptieren
|
const incomingInvitationId = body.invitationId?.trim() || undefined
|
||||||
const incomingInvitationId: string | undefined = body.invitationId
|
const fallbackTeamId = body.teamId?.trim() || undefined
|
||||||
const fallbackTeamId: string | undefined = body.teamId
|
const fallbackSteamId = body.steamId?.trim() || undefined
|
||||||
const fallbackSteamId: string | undefined = body.steamId
|
|
||||||
|
|
||||||
// Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId)
|
// Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId)
|
||||||
const invitation =
|
const invitation =
|
||||||
@ -281,8 +284,9 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
|
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('Fehler bei Einladung:', error)
|
const err = error instanceof Error ? error : new Error(String(error))
|
||||||
|
console.error('Fehler bei Einladung:', err)
|
||||||
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
// /src/app/api/user/invitations/route.ts
|
// /src/app/api/user/invitations/route.ts
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
// /src/app/api/user/offline/route.ts
|
// /src/app/api/user/offline/route.ts
|
||||||
import { NextResponse, NextRequest } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { getServerSession } from 'next-auth'
|
|||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET() {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
if (!session?.user?.steamId) {
|
if (!session?.user?.steamId) {
|
||||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// /src/app/api/user/route.ts
|
// /src/app/api/user/route.ts
|
||||||
import { NextResponse, type NextRequest } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { sessionAuthOptions } from '@/lib/auth';
|
import { sessionAuthOptions } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
@ -17,7 +17,7 @@ type SlimPlayer = {
|
|||||||
// Hilfstyp für Session, damit TS weiß, dass es user?.steamId gibt
|
// Hilfstyp für Session, damit TS weiß, dass es user?.steamId gibt
|
||||||
type SessionShape = { user?: { steamId?: string } } | null;
|
type SessionShape = { user?: { steamId?: string } } | null;
|
||||||
|
|
||||||
export async function GET(_req: NextRequest) {
|
export async function GET() {
|
||||||
const session = (await getServerSession(sessionAuthOptions)) as SessionShape;
|
const session = (await getServerSession(sessionAuthOptions)) as SessionShape;
|
||||||
const steamId = session?.user?.steamId;
|
const steamId = session?.user?.steamId;
|
||||||
|
|
||||||
|
|||||||
@ -10,15 +10,13 @@ function isValidIanaOrNull(v: unknown): v is string | null {
|
|||||||
if (v === null) return true
|
if (v === null) return true
|
||||||
if (typeof v !== 'string' || v.trim() === '') return false
|
if (typeof v !== 'string' || v.trim() === '') return false
|
||||||
// Validate via Intl.supportedValuesOf if available
|
// Validate via Intl.supportedValuesOf if available
|
||||||
// @ts-ignore
|
|
||||||
const list: string[] | undefined = typeof Intl.supportedValuesOf === 'function'
|
const list: string[] | undefined = typeof Intl.supportedValuesOf === 'function'
|
||||||
// @ts-ignore
|
|
||||||
? Intl.supportedValuesOf('timeZone')
|
? Intl.supportedValuesOf('timeZone')
|
||||||
: undefined
|
: undefined
|
||||||
return list ? list.includes(v) : true // wenn kein Support: großzügig erlauben
|
return list ? list.includes(v) : true // wenn kein Support: großzügig erlauben
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
if (!session?.user?.steamId) {
|
if (!session?.user?.steamId) {
|
||||||
@ -29,8 +27,9 @@ export async function GET(req: NextRequest) {
|
|||||||
select: { timeZone: true }
|
select: { timeZone: true }
|
||||||
})
|
})
|
||||||
return NextResponse.json({ timeZone: user?.timeZone ?? null })
|
return NextResponse.json({ timeZone: user?.timeZone ?? null })
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.error('[TZ][GET] failed', e)
|
const err = e instanceof Error ? e : new Error(String(e))
|
||||||
|
console.error('[TZ][GET] failed:', err.message, err)
|
||||||
return NextResponse.json({ message: 'Internal error' }, { status: 500 })
|
return NextResponse.json({ message: 'Internal error' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,8 +54,9 @@ export async function PUT(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.error('[TZ][PUT] failed', e)
|
const err = e instanceof Error ? e : new Error(String(e))
|
||||||
|
console.error('[TZ][PUT] failed:', err.message, err)
|
||||||
return NextResponse.json({ message: 'Internal error' }, { status: 500 })
|
return NextResponse.json({ message: 'Internal error' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// /src/app/api/user/winrate/route.ts
|
// /src/app/api/user/winrate/route.ts
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
|
|
||||||
@ -202,5 +203,6 @@ export async function POST(req: NextRequest) {
|
|||||||
if (typeof body.onlyActive === 'boolean') {
|
if (typeof body.onlyActive === 'boolean') {
|
||||||
params.searchParams.set('onlyActive', String(body.onlyActive))
|
params.searchParams.set('onlyActive', String(body.onlyActive))
|
||||||
}
|
}
|
||||||
return GET(new Request(params.toString()) as any)
|
const nextReq = new NextRequest(params.toString(), { method: 'GET' })
|
||||||
|
return GET(nextReq)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -417,7 +417,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -431,7 +431,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
|
|||||||
@ -418,7 +418,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -432,7 +432,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
|
|||||||
@ -417,7 +417,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -431,7 +431,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export const buildAuthOptions = (req: NextRequest): NextAuthOptions => ({
|
|||||||
const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`)
|
const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`)
|
||||||
const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`)
|
const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`)
|
||||||
if (isSignOut) return `${baseUrl}/`
|
if (isSignOut) return `${baseUrl}/`
|
||||||
if (isSignIn || url === baseUrl) return `${baseUrl}/dashboard`
|
if (isSignIn || url === baseUrl) return `${baseUrl}/`
|
||||||
return url.startsWith(baseUrl) ? url : baseUrl
|
return url.startsWith(baseUrl) ? url : baseUrl
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -125,8 +125,13 @@
|
|||||||
"day": "Tag"
|
"day": "Tag"
|
||||||
},
|
},
|
||||||
"mapvote": {
|
"mapvote": {
|
||||||
"open": "offen",
|
"mode": "Modus",
|
||||||
"opens-in": "öffnet in"
|
"open": "Offen",
|
||||||
|
"open-small": "offen",
|
||||||
|
"opens-in": "Öffnet in",
|
||||||
|
"completed": "Voting abgeschlossen!",
|
||||||
|
"vote-now": "vote",
|
||||||
|
"to-match-start": "zum Matchbeginn"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Benachrichtigungen",
|
"title": "Benachrichtigungen",
|
||||||
|
|||||||
@ -125,8 +125,13 @@
|
|||||||
"day": "Day"
|
"day": "Day"
|
||||||
},
|
},
|
||||||
"mapvote": {
|
"mapvote": {
|
||||||
"open": "open",
|
"mode": "Mode",
|
||||||
"opens-in": "opens in"
|
"open": "Open",
|
||||||
|
"open-small": "open",
|
||||||
|
"opens-in": "Opens in",
|
||||||
|
"completed": "Voting completed!",
|
||||||
|
"vote-now": "vote",
|
||||||
|
"to-match-start": "to match start"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
|
|||||||
@ -28,7 +28,7 @@ function stripLeadingLocale(pathname: string, locales: readonly string[]) {
|
|||||||
}
|
}
|
||||||
function isProtectedPath(pathnameNoLocale: string) {
|
function isProtectedPath(pathnameNoLocale: string) {
|
||||||
return (
|
return (
|
||||||
pathnameNoLocale.startsWith('/dashboard') ||
|
pathnameNoLocale.startsWith('/') ||
|
||||||
pathnameNoLocale.startsWith('/settings') ||
|
pathnameNoLocale.startsWith('/settings') ||
|
||||||
pathnameNoLocale.startsWith('/matches') ||
|
pathnameNoLocale.startsWith('/matches') ||
|
||||||
pathnameNoLocale.startsWith('/team') ||
|
pathnameNoLocale.startsWith('/team') ||
|
||||||
@ -73,7 +73,7 @@ export default async function middleware(req: NextRequest) {
|
|||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
const currentLocale = getCurrentLocaleFromPath(pathname, locales, defaultLocale);
|
const currentLocale = getCurrentLocaleFromPath(pathname, locales, defaultLocale);
|
||||||
const redirectUrl = url.clone();
|
const redirectUrl = url.clone();
|
||||||
redirectUrl.pathname = `/${currentLocale}/dashboard`;
|
redirectUrl.pathname = `/${currentLocale}/`;
|
||||||
return NextResponse.redirect(redirectUrl);
|
return NextResponse.redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user