From 5a3faaf1febaafb105dd3cdb4eb4eb9bd46c1a81 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:30:11 +0200 Subject: [PATCH] updated for build --- src/app/[locale]/admin/server/page.tsx | 24 +- .../admin/teams/[teamId]/TeamAdminClient.tsx | 14 +- src/app/[locale]/components/Button.tsx | 6 +- src/app/[locale]/components/Chart.tsx | 178 ++- src/app/[locale]/components/ComboBox.tsx | 104 +- .../components/CommunityMatchList.tsx | 160 +- .../[locale]/components/CreateTeamButton.tsx | 30 +- .../components/DatePickerWithTime.tsx | 2 +- src/app/[locale]/components/EditButton.tsx | 30 +- .../components/EditMatchMetaModal.tsx | 50 +- .../components/EditMatchPlayersModal.tsx | 22 +- src/app/[locale]/components/FaceitStat.tsx | 4 +- src/app/[locale]/components/GameBanner.tsx | 120 +- .../components/GameBannerController.tsx | 100 +- .../components/InvitePlayersModal.tsx | 192 ++- .../[locale]/components/LeaveTeamModal.tsx | 2 +- src/app/[locale]/components/MapVoteBanner.tsx | 216 ++- src/app/[locale]/components/MapVotePanel.tsx | 336 ++--- .../components/MapVoteProfileCard.tsx | 2 - src/app/[locale]/components/MatchDetails.tsx | 147 +- .../[locale]/components/MatchPlayerCard.tsx | 32 +- .../[locale]/components/MatchReadyOverlay.tsx | 177 ++- src/app/[locale]/components/MiniCard.tsx | 96 +- src/app/[locale]/components/MiniCardDummy.tsx | 5 +- .../[locale]/components/MiniPlayerCard.tsx | 173 ++- src/app/[locale]/components/Modal.tsx | 46 +- src/app/[locale]/components/NoTeamView.tsx | 74 +- .../[locale]/components/NotificationBell.tsx | 149 +- src/app/[locale]/components/PlayerCard.tsx | 4 +- .../[locale]/components/ReadyOverlayHost.tsx | 166 ++- src/app/[locale]/components/ScrollSpyTabs.tsx | 100 +- src/app/[locale]/components/Sidebar.tsx | 17 +- src/app/[locale]/components/SidebarFooter.tsx | 49 +- src/app/[locale]/components/Tab.tsx | 2 - src/app/[locale]/components/Tabs.tsx | 65 +- src/app/[locale]/components/TeamCard.tsx | 185 ++- .../[locale]/components/TeamCardComponent.tsx | 281 ++-- .../components/TeamInvitationBanner.tsx | 33 +- .../[locale]/components/TeamMemberView.tsx | 393 +++-- src/app/[locale]/components/TeamSelector.tsx | 26 +- .../[locale]/components/TelemetrySocket.tsx | 181 ++- .../components/UserAvatarWithStatus.tsx | 75 +- .../components/admin/MatchesAdminManager.tsx | 31 +- .../components/admin/teams/AdminTeamsView.tsx | 3 - .../components/profile/[steamId]/Profile.tsx | 13 +- .../profile/[steamId]/matches/MatchesList.tsx | 17 +- .../profile/[steamId]/stats/StatsView.tsx | 129 +- .../[locale]/components/radar/GameSocket.tsx | 251 ++-- .../[locale]/components/radar/LiveRadar.tsx | 1294 ++++++++--------- .../[locale]/components/radar/RadarCanvas.tsx | 27 +- .../components/radar/StaticEffects.tsx | 141 +- .../[locale]/components/radar/TeamSidebar.tsx | 117 +- .../components/radar/hooks/useOverview.ts | 19 +- .../components/radar/hooks/useRadarState.ts | 328 +++-- .../[locale]/components/radar/lib/grenades.ts | 132 +- .../[locale]/components/radar/lib/helpers.ts | 102 +- .../settings/account/AppearanceSettings.tsx | 110 +- .../settings/account/AuthCodeSettings.tsx | 4 +- .../settings/account/ShareCodeSettings.tsx | 2 +- .../settings/account/UserSettings.tsx | 60 +- .../settings/privacy/PrivacySettings.tsx | 43 +- src/app/[locale]/dashboard/page.tsx | 62 - .../match-details/[matchId]/layout.tsx | 2 +- src/app/[locale]/page.tsx | 247 +++- .../profile/[steamId]/ProfileHeader.tsx | 3 +- src/app/[locale]/profile/page.tsx | 1 - src/app/[locale]/schedule/page.tsx | 15 +- .../settings/_sections/AccountSection.tsx | 15 +- src/app/api/cs2/authcode/route.ts | 2 +- src/app/api/cs2/sharecode/route.ts | 2 +- src/app/api/faceit/callback/route.ts | 6 - src/app/api/matches/current/route.ts | 4 +- src/app/api/notifications/create/route.ts | 4 +- .../api/notifications/mark-all-read/route.ts | 6 +- src/app/api/notifications/route.ts | 4 +- src/app/api/schedule/route.ts | 1 - src/app/api/stats/[steamId]/route.ts | 16 +- src/app/api/steam/profile/route.ts | 4 +- src/app/api/team/[teamId]/route.ts | 39 +- src/app/api/team/add-players/route.ts | 6 - src/app/api/team/create/route.ts | 17 +- src/app/api/team/rename/route.ts | 56 +- src/app/api/team/transfer-leader/route.ts | 51 +- src/app/api/team/update-join-policy/route.ts | 27 +- src/app/api/user/activity/route.ts | 4 +- src/app/api/user/away/route.ts | 4 +- .../api/user/invitations/[action]/route.ts | 18 +- src/app/api/user/invitations/route.ts | 4 +- src/app/api/user/offline/route.ts | 4 +- src/app/api/user/privacy/route.ts | 2 +- src/app/api/user/route.ts | 4 +- src/app/api/user/timezone/route.ts | 14 +- src/app/api/user/winrate/route.ts | 6 +- src/generated/prisma/edge.js | 4 +- src/generated/prisma/index.js | 4 +- src/generated/prisma/wasm.js | 4 +- src/lib/auth.ts | 2 +- src/messages/de.json | 9 +- src/messages/en.json | 9 +- src/middleware.ts | 4 +- 100 files changed, 4361 insertions(+), 3216 deletions(-) delete mode 100644 src/app/[locale]/dashboard/page.tsx diff --git a/src/app/[locale]/admin/server/page.tsx b/src/app/[locale]/admin/server/page.tsx index 433134b..05b3901 100644 --- a/src/app/[locale]/admin/server/page.tsx +++ b/src/app/[locale]/admin/server/page.tsx @@ -9,12 +9,6 @@ import ServerView from '../../components/admin/server/ServerView' export const dynamic = 'force-dynamic' -// Wichtig: Promises wie in .next/types -type PageProps = { - params?: Promise<{ locale?: string }> - searchParams?: Promise> -} - async function ensureConfig() { return prisma.serverConfig.upsert({ where: { id: 'default' }, @@ -29,16 +23,20 @@ async function ensureConfig() { }) } -export default async function AdminServerPage(_props: PageProps) { - // Falls du locale brauchst: - // const { locale } = (await _props.params) ?? {} - +export default async function AdminServerPage() { const session = await getServerSession(sessionAuthOptions) - const me = session?.user as any | undefined - if (!me?.steamId || !me?.isAdmin) { + type AdminUser = { steamId: string; isAdmin: boolean } + const isAdminUser = (u: unknown): u is AdminUser => { + const r = u as Record | null | undefined + return !!r && typeof r.steamId === 'string' && typeof r.isAdmin === 'boolean' + } + + const meUnknown = session?.user + if (!isAdminUser(meUnknown)) { redirect('/') } + const me = meUnknown // ab hier getypt: AdminUser const [cfg, meUser] = await Promise.all([ ensureConfig(), @@ -73,7 +71,7 @@ export default async function AdminServerPage(_props: PageProps) { if (clientApiKey) { await tx.user.update({ - where: { steamId: me?.steamId }, + where: { steamId: me.steamId }, data: { pterodactylClientApiKey: clientApiKey }, }) } diff --git a/src/app/[locale]/admin/teams/[teamId]/TeamAdminClient.tsx b/src/app/[locale]/admin/teams/[teamId]/TeamAdminClient.tsx index ffedfd7..f655d12 100644 --- a/src/app/[locale]/admin/teams/[teamId]/TeamAdminClient.tsx +++ b/src/app/[locale]/admin/teams/[teamId]/TeamAdminClient.tsx @@ -1,7 +1,7 @@ // /src/app/(admin)/admin/teams/[teamId]/TeamAdminClient.tsx 'use client' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useSession } from 'next-auth/react' import LoadingSpinner from '../../../components/LoadingSpinner' import TeamMemberView from '../../../components/TeamMemberView' @@ -21,7 +21,6 @@ export default function TeamAdminClient({ teamId }: Props) { const [showLeaveModal, setShowLeaveModal] = useState(false) const [showInviteModal, setShowInviteModal] = useState(false) - const fetchTeam = useCallback(async () => { const result = await reloadTeam(teamId) if (result) setTeam(result) @@ -32,19 +31,18 @@ export default function TeamAdminClient({ teamId }: Props) { if (teamId) fetchTeam() }, [teamId, fetchTeam]) - // 👇 WICHTIG: subscribe by steamId (passt zu deinem SSE-Server) useEffect(() => { const steamId = session?.user?.steamId if (!steamId) return - // ggf. .env nutzen: z. B. NEXT_PUBLIC_SSE_URL=http://localhost:3001 const base = process.env.NEXT_PUBLIC_SSE_URL ?? 'http://localhost:3001' const url = `${base}/events?steamId=${encodeURIComponent(steamId)}` let es: EventSource | null = new EventSource(url, { withCredentials: false }) - const onTeamUpdated = (ev: MessageEvent) => { + // Listener als EventListener typisieren + const onTeamUpdated: EventListener = (ev) => { try { - const msg = JSON.parse(ev.data) + const msg = JSON.parse((ev as MessageEvent).data as string) if (msg.teamId === teamId) { fetchTeam() } @@ -56,11 +54,9 @@ export default function TeamAdminClient({ teamId }: Props) { es.addEventListener('team-updated', onTeamUpdated) es.onerror = () => { - // sanftes Reconnect es?.close() es = null setTimeout(() => { - // neuer EventSource const next = new EventSource(url, { withCredentials: false }) next.addEventListener('team-updated', onTeamUpdated) next.onerror = () => { next.close() } @@ -69,7 +65,7 @@ export default function TeamAdminClient({ teamId }: Props) { } return () => { - es?.removeEventListener('team-updated', onTeamUpdated as any) + es?.removeEventListener('team-updated', onTeamUpdated) es?.close() } }, [session?.user?.steamId, teamId, fetchTeam]) diff --git a/src/app/[locale]/components/Button.tsx b/src/app/[locale]/components/Button.tsx index 798b9b8..e53e6b2 100644 --- a/src/app/[locale]/components/Button.tsx +++ b/src/app/[locale]/components/Button.tsx @@ -1,3 +1,5 @@ +// /src/app/[locale]/components/Button.tsx + 'use client' import { ReactNode, forwardRef, useState, useRef, useEffect, ButtonHTMLAttributes } from 'react' @@ -40,7 +42,7 @@ const Button = forwardRef(function Button( ref ) { const [open, setOpen] = useState(false) - const [direction, setDirection] = useState<'up' | 'down'>('down') + const [, setDirection] = useState<'up' | 'down'>('down') const localRef = useRef(null) const buttonRef = (ref as React.RefObject) || localRef @@ -159,7 +161,7 @@ const Button = forwardRef(function Button( setDirection(spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? 'up' : 'down') }) } - }, [open, dropDirection]) + }, [buttonRef, open, dropDirection]) const toggle = (event: React.MouseEvent) => { const next = !open diff --git a/src/app/[locale]/components/Chart.tsx b/src/app/[locale]/components/Chart.tsx index 745570b..8a73bfb 100644 --- a/src/app/[locale]/components/Chart.tsx +++ b/src/app/[locale]/components/Chart.tsx @@ -1,4 +1,4 @@ -// components/Chart.tsx +// /src/app/[locale]/components/Chart.tsx 'use client'; import React, { @@ -16,6 +16,8 @@ import { type ChartData, type ChartOptions, type Plugin, + type ChartConfiguration, + type RadialLinearScaleOptions, CategoryScale, LinearScale, RadialLinearScale, @@ -120,7 +122,7 @@ function getImage(src: string): HTMLImageElement { return img; } -function _Chart( +function ChartInner( props: BaseProps, ref: React.Ref ) { @@ -141,16 +143,15 @@ function _Chart( onReady, ariaLabel, - radarIcons, + // ⚠️ bewusst NICHT destrukturieren: radarIcons, radarIconLabelColor, radarAddRingOffset + // werden innerhalb von plugin/mergedOptions über `props.*` genutzt – so vermeiden wir + // “defined but never used”-Warnungen. radarIconSize = 40, radarIconLabels = false, radarIconLabelFont = '12px Inter, system-ui, sans-serif', - radarIconLabelColor = '#ffffff', radarIconLabelMargin = 6, - radarHideTicks = false, radarMax, radarStepSize, - radarAddRingOffset = false, } = props; const canvasRef = useRef(null); @@ -161,7 +162,10 @@ function _Chart( const baseData = useMemo | undefined>(() => { if (data) return data; if (!labels || !datasets) return undefined; - return { labels, datasets: datasets as any } as ChartData; + + // Datensätze in das erwartete Typfeld casten (ohne `any`) + const dsTyped = datasets as unknown as NonNullable['datasets']>; + return { labels, datasets: dsTyped } as ChartData; }, [data, labels, datasets]); // ▼ Für RADAR: Daten intern um +20 verschieben (Plot), Original bleibt in Props. @@ -169,62 +173,91 @@ function _Chart( if (!baseData) return baseData; if (type !== 'radar') return baseData; - // flache Kopie, Datensätze klonen und data +20 - const cloned: any = { - ...baseData, - datasets: (baseData.datasets ?? []).map((ds: any) => { - // Nur numerische Arrays anfassen - const d = Array.isArray(ds.data) - ? ds.data.map((v: any) => - typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v - ) - : ds.data; + // datasets generisch transformieren (ohne `any`) + const dsArray = + (baseData.datasets ?? []) as unknown as Array>; + const dsShifted = dsArray.map((ds) => { + const d = (ds.data as unknown[] | undefined)?.map((v) => + typeof v === 'number' && Number.isFinite(v) ? v + RADAR_OFFSET : v + ); + return { ...ds, data: d } as Record; + }); - return { ...ds, data: d }; - }), - }; - return cloned; + return { + labels: baseData.labels as ChartData['labels'], + datasets: dsShifted as unknown as NonNullable['datasets']>, + } as ChartData; }, [baseData, type]); - /* ---------- Radar Scale ---------- */ + // ---------- Radar Scale ---------- const radarScaleOpts = useMemo(() => { if (type !== 'radar') return undefined; - const gridColor = 'rgba(255,255,255,0.10)'; + const gridColor = 'rgba(255,255,255,0.10)'; const angleColor = 'rgba(255,255,255,0.12)'; - const ticks: any = { - beginAtZero: true, - showLabelBackdrop: false, + const ticks: NonNullable = { + display: true, color: 'rgba(255,255,255,0.6)', + font: { size: 12 }, + padding: 0, backdropColor: 'transparent', - ...(radarHideTicks ? { display: false } : {}), + backdropPadding: 0, + showLabelBackdrop: false, + textStrokeColor: 'transparent', + textStrokeWidth: 0, + z: 0, + major: { enabled: false }, ...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}), + callback: (value) => String(value), }; - const r: any = { - suggestedMin: 0, - grid: { color: gridColor, lineWidth: 1 }, - angleLines: { display: true, color: angleColor, lineWidth: 1 }, + const pointLabels: NonNullable = { + display: false, + color: '#ffffff', + font: { size: 12 }, + padding: 0, + backdropColor: 'transparent', + backdropPadding: 0, + borderRadius: 0, + centerPointLabels: false, + }; + + // ⬇︎ HIER: r als RadialLinearScaleOptions typisieren + const r: RadialLinearScaleOptions = { + display: true, + alignToPixels: false, + backgroundColor: 'transparent', + reverse: false, ticks, - pointLabels: { display: false }, + grid: { color: gridColor, lineWidth: 1 }, + angleLines: { + display: true, + color: angleColor, + lineWidth: 1, + borderDash: [], + borderDashOffset: 0, + }, + pointLabels, + suggestedMin: 0, }; - // WICHTIG: max anheben, damit +20 nicht abschneidet + // ⬇︎ ohne any cast if (typeof radarMax === 'number') { r.max = radarMax + RADAR_OFFSET; r.suggestedMax = radarMax + RADAR_OFFSET; } return { r }; - }, [type, radarHideTicks, radarStepSize, radarMax]); + }, [type, radarStepSize, radarMax]); /* ---------- Radar Icons Plugin ---------- */ const [radarPlugin] = useState>(() => ({ id: 'radarIconsPlugin', afterDatasetsDraw(chart) { const ctx = chart.ctx as CanvasRenderingContext2D; - const scale: any = (chart as any).scales?.r; + const maybeScales = (chart as unknown as { scales?: { r?: { max: number; getPointPositionForValue: (i: number, v: number) => { x: number; y: number } } } }).scales; + const scale = maybeScales?.r; if (!scale) return; const lbls = chart.data.labels as string[] | undefined; @@ -233,14 +266,14 @@ function _Chart( const icons = props.radarIcons ?? []; ctx.save(); - (ctx as any).resetTransform?.(); + (ctx as unknown as { resetTransform?: () => void }).resetTransform?.(); ctx.beginPath(); ctx.rect(0, 0, chart.width, chart.height); ctx.clip(); - const ca = (chart as any).chartArea as { left:number; right:number; top:number; bottom:number } | undefined; - const cx0 = scale.xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width as number) / 2); - const cy0 = scale.yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2); + const ca = (chart as unknown as { chartArea?: { left: number; right: number; top: number; bottom: number } }).chartArea; + const cx0 = (scale as unknown as { xCenter?: number }).xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width as number) / 2); + const cy0 = (scale as unknown as { yCenter?: number }).yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2); const half = (props.radarIconSize ?? 40) / 2; const gap = Math.max(4, props.radarIconLabelMargin ?? 6); @@ -251,9 +284,9 @@ function _Chart( ctx.fillStyle = props.radarIconLabelColor ?? '#ffffff'; for (let i = 0; i < lbls.length; i++) { - const p = scale.getPointPositionForValue(i, scale.max); - const px = p.x as number; - const py = p.y as number; + const p = scale.getPointPositionForValue(i, (scale as unknown as { max: number }).max); + const px = p.x; + const py = p.y; const dx = px - cx0; const dy = py - cy0; @@ -298,16 +331,18 @@ function _Chart( if (type === 'radar') { // Scales zusammenführen - (o as any).scales = { + (o as unknown as { scales?: Record }).scales = { ...(radarScaleOpts ?? {}), - ...(options?.scales as any), + ...(options?.scales as unknown as Record), }; // Tooltip: echten Wert (ohne Offset) anzeigen const userTooltip = options?.plugins?.tooltip; - const userLabelCb = userTooltip?.callbacks?.label; + const userLabelCb = userTooltip?.callbacks?.label as + | ((this: unknown, c: unknown) => unknown) + | undefined; - (o as any).plugins = { + (o as unknown as { plugins?: Record }).plugins = { legend: { display: false }, title: { display: false }, ...options?.plugins, @@ -315,14 +350,15 @@ function _Chart( ...userTooltip, callbacks: { ...userTooltip?.callbacks, - label: function (this: any, ctx: any) { - const shifted = Number(ctx.raw); + label: function (this: unknown, rawCtx: unknown) { + const ctx = rawCtx as { raw?: unknown; dataset?: { label?: string } }; + const shifted = Number((ctx as { raw?: unknown }).raw); const original = Number.isFinite(shifted) ? shifted - RADAR_OFFSET : shifted; - const shown = Math.round(original); // ⬅️ hier auf ganze Zahlen runden + const shown = Math.round(original); if (typeof userLabelCb === 'function') { - const clone = { ...ctx, raw: shown, parsed: { r: shown } }; - return (userLabelCb as (this: any, c: any) => any).call(this, clone); + const clone = { ...(ctx as Record), raw: shown, parsed: { r: shown } }; + return userLabelCb.call(this, clone); } const dsLabel = ctx.dataset?.label ? `${ctx.dataset.label}: ` : ''; @@ -330,7 +366,7 @@ function _Chart( }, }, }, - }; + } as Record; // Layout-Padding für Außenbeschriftungen (Icons/Labels) const fontPx = (() => { @@ -342,18 +378,18 @@ function _Chart( (radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) + 6 ); - const currentPadding = (o as any).layout?.padding ?? {}; - (o as any).layout = { - ...(o as any).layout, + const currentPadding = (o as unknown as { layout?: { padding?: Record } }).layout?.padding ?? {}; + (o as unknown as { layout?: { padding?: Record } }).layout = { + ...(o as unknown as { layout?: Record }).layout, padding: { - top: Math.max(pad, currentPadding.top ?? 0), - right: Math.max(pad, currentPadding.right ?? 0), - bottom: Math.max(pad, currentPadding.bottom ?? 0), - left: Math.max(pad, currentPadding.left ?? 0), + top: Math.max(pad, (currentPadding as Record).top ?? 0), + right: Math.max(pad, (currentPadding as Record).right ?? 0), + bottom: Math.max(pad, (currentPadding as Record).bottom ?? 0), + left: Math.max(pad, (currentPadding as Record).left ?? 0), }, }; } else if (options?.scales) { - (o as any).scales = { ...(options.scales as any) }; + (o as unknown as { scales?: Record }).scales = { ...(options.scales as unknown as Record) }; } return o; @@ -371,7 +407,7 @@ function _Chart( const mergedPlugins = useMemo[]>(() => { const list: Plugin[] = []; if (plugins?.length) list.push(...plugins); - if (type === 'radar') list.push(radarPlugin as any); + if (type === 'radar') list.push(radarPlugin as unknown as Plugin); return list; }, [plugins, type, radarPlugin]); @@ -379,7 +415,10 @@ function _Chart( const config = useMemo( () => ({ type, - data: (type === 'radar' ? (shiftedData ?? { labels: [], datasets: [] }) : (baseData ?? { labels: [], datasets: [] })) as ChartData, + data: (type === 'radar' + ? (shiftedData ?? { labels: [], datasets: [] }) + : (baseData ?? { labels: [], datasets: [] }) + ) as ChartData, options: mergedOptions, plugins: mergedPlugins, }), @@ -395,7 +434,8 @@ function _Chart( if (mustRecreate) { chartRef.current?.destroy(); - chartRef.current = new ChartJS(canvasRef.current, config as any); + const cfg = config as unknown as ChartConfiguration; + chartRef.current = new ChartJS(canvasRef.current, cfg); prevTypeRef.current = type; onReady?.(chartRef.current); return () => { @@ -403,14 +443,16 @@ function _Chart( chartRef.current = null; }; } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [config, type, redraw, onReady]); useEffect(() => { const c = chartRef.current; if (!c || redraw || prevTypeRef.current !== type) return; - (c as any).data = (type === 'radar' ? (shiftedData ?? c.data) : (baseData ?? c.data)); - (c as any).options = mergedOptions; + + (c as unknown as { data: unknown }).data = + (type === 'radar' ? (shiftedData ?? (c as unknown as { data: unknown }).data) : (baseData ?? (c as unknown as { data: unknown }).data)); + + (c as unknown as { options: unknown }).options = mergedOptions; c.update(); }, [baseData, shiftedData, mergedOptions, type, redraw]); @@ -443,8 +485,8 @@ function _Chart( ); } -const Chart = forwardRef(_Chart) as ( +const Chart = forwardRef(ChartInner) as ( p: BaseProps & { ref?: React.Ref } -) => ReturnType; +) => ReturnType; export default Chart; diff --git a/src/app/[locale]/components/ComboBox.tsx b/src/app/[locale]/components/ComboBox.tsx index 934d115..5a277f7 100644 --- a/src/app/[locale]/components/ComboBox.tsx +++ b/src/app/[locale]/components/ComboBox.tsx @@ -1,15 +1,21 @@ +// /src/app/[locale]/components/ComboBox.tsx + 'use client' +import { useState } from 'react' + type ComboItem = { id: string; label: string } type ComboBoxProps = { - value: string // ausgewählte ID - items: ComboItem[] // { id, label } + value: string + items: ComboItem[] onSelect: (id: string) => void } export default function ComboBox({ value, items, onSelect }: ComboBoxProps) { + const [isOpen, setIsOpen] = useState(false) const selected = items.find(i => i.id === value) + const listboxId = 'combo-listbox' return (
@@ -18,16 +24,21 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) { className="py-2.5 sm:py-3 ps-4 pe-9 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" type="text" role="combobox" - aria-expanded="false" + aria-haspopup="listbox" + aria-controls={listboxId} + aria-expanded={isOpen} value={selected?.label ?? ''} data-hs-combo-box-input="" readOnly /> - +
- {items.map((item) => ( -
onSelect(item.id)} - > -
- - {item.label} - - {item.id === value && ( - - - - + {items.map((item) => { + const selectedOpt = item.id === value + return ( +
{ onSelect(item.id); setIsOpen(false) }} + > +
+ + {item.label} - )} + {selectedOpt && ( + + + + + + )} +
-
- ))} + ) + })}
) diff --git a/src/app/[locale]/components/CommunityMatchList.tsx b/src/app/[locale]/components/CommunityMatchList.tsx index 1ddd42c..24e5143 100644 --- a/src/app/[locale]/components/CommunityMatchList.tsx +++ b/src/app/[locale]/components/CommunityMatchList.tsx @@ -4,12 +4,9 @@ import { useEffect, useState, useCallback, useMemo } from 'react' import { useSession } from 'next-auth/react' -import { useRouter, usePathname } from '@/i18n/navigation' import { useTranslations, useLocale } from 'next-intl' import Link from 'next/link' import Image from 'next/image' -import { format } from 'date-fns' -import { de } from 'date-fns/locale' import Switch from '../components/Switch' import Button from './Button' import Modal from './Modal' @@ -22,12 +19,33 @@ type Props = { matchType?: string } const getTeamLogo = (logo?: string | null) => logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp' -const toDateKey = (d: Date) => d.toISOString().slice(0, 10) -const weekdayDE = new Intl.DateTimeFormat('de-DE', { weekday: 'long' }) -const weekdayEN = new Intl.DateTimeFormat('en-GB', { weekday: 'long' }) - type TeamOption = { id: string; name: string; logo?: string | null } +type UnknownRec = Record; + +function isRecord(v: unknown): v is UnknownRec { + return !!v && typeof v === 'object'; +} + +type PlayerLike = { + user?: { steamId?: string | null } | null; + steamId?: string | null; +}; + +type TeamMaybeWithPlayers = { + id?: string | null; + players?: PlayerLike[] | null; +}; + +function toStringOrNull(v: unknown): string | null { + return typeof v === 'string' ? v : null; +} + +function firstString(...vals: unknown[]): string { + for (const v of vals) if (typeof v === 'string') return v; + return ''; +} + /** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */ function combineLocalDateTime(dateStr: string, timeStr: string) { const [y, m, d] = dateStr.split('-').map(Number) @@ -64,14 +82,6 @@ type ZonedParts = { year: number; month: number; day: number; hour: number; minute: number; }; -function getUserTimeZone(sessionTz?: string): string { - return ( - sessionTz || - Intl.DateTimeFormat().resolvedOptions().timeZone || - 'Europe/Berlin' - ); -} - function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts { const d = typeof date === 'string' ? new Date(date) : date; const parts = new Intl.DateTimeFormat(locale, { @@ -126,44 +136,42 @@ function isBanStatusFlagged(b?: BanStatus | null): boolean { /** Liefert Info, ob Match gebannte Spieler enthält (zählt beide Seiten) */ function matchBanInfo(m: Match): { hasBan: boolean; count: number; tooltip: string } { - // a) neues Shape (teamA.players / teamB.players) - const playersA = (m as any)?.teamA?.players ?? [] - const playersB = (m as any)?.teamB?.players ?? [] + const teamA = (m.teamA as unknown as TeamMaybeWithPlayers); + const teamB = (m.teamB as unknown as TeamMaybeWithPlayers); - // b) Fallback: flaches players-Array (falls API alt) - const flat = (m as any)?.players ?? [] + const playersA = Array.isArray(teamA?.players) ? teamA.players! : []; + const playersB = Array.isArray(teamB?.players) ? teamB.players! : []; - const all = Array.isArray(playersA) || Array.isArray(playersB) - ? [...(playersA ?? []), ...(playersB ?? [])] - : Array.isArray(flat) ? flat : [] + // Fallback: flaches players-Array (ältere API) + const flat = (m as unknown as { players?: PlayerLike[] | null }).players ?? []; - let count = 0 - const lines: string[] = [] + const all: PlayerLike[] = + playersA.length || playersB.length ? [...playersA, ...playersB] : Array.isArray(flat) ? flat : []; + + let count = 0; + const lines: string[] = []; for (const p of all) { - const user = p?.user ?? p // (Fallback falls p schon der User ist) - const name = user?.name ?? 'Unbekannt' - const b: BanStatus | undefined = user?.banStatus + const user = (p?.user as unknown as { name?: string | null; banStatus?: BanStatus | null }) ?? {}; + const name = user?.name ?? 'Unbekannt'; + const b = user?.banStatus ?? null; if (isBanStatusFlagged(b)) { - count++ - const parts: string[] = [] - if (b?.vacBanned) parts.push('VAC aktiv') - if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`) - if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`) - if (b?.communityBanned) parts.push('Community') - if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`) - if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`) - lines.push(`${name}: ${parts.join(' · ')}`) + count++; + const parts: string[] = []; + if (b?.vacBanned) parts.push('VAC aktiv'); + if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`); + if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`); + if (b?.communityBanned) parts.push('Community'); + if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`); + if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`); + lines.push(`${name}: ${parts.join(' · ')}`); } } - return { hasBan: count > 0, count, tooltip: lines.join('\n') } + return { hasBan: count > 0, count, tooltip: lines.join('\n') }; } - export default function CommunityMatchList({ matchType }: Props) { const { data: session } = useSession() - const router = useRouter() - const pathname = usePathname() const locale = useLocale() const userTZ = useUserTimeZone([session?.user?.steamId]) const weekdayFmt = useMemo(() => @@ -211,26 +219,36 @@ export default function CommunityMatchList({ matchType }: Props) { const mySteamId = session?.user?.steamId - const isOwnMatch = useCallback((m: any) => { - if (!mySteamId) return false + const isOwnMatch = useCallback((m: Match) => { + if (!mySteamId) return false; - // a) Neues Shape: teamA.players / teamB.players -> p.user.steamId - const inTeamA = m?.teamA?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false - const inTeamB = m?.teamB?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false - if (inTeamA || inTeamB) return true + const teamA = (m.teamA as unknown as TeamMaybeWithPlayers); + const teamB = (m.teamB as unknown as TeamMaybeWithPlayers); - // b) Manchmal flaches players-Array (falls noch vorhanden) - const inFlat = m?.players?.some((p: any) => - p?.user?.steamId === mySteamId || p?.steamId === mySteamId - ) ?? false - if (inFlat) return true + const inTeamA = + Array.isArray(teamA?.players) && + teamA.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId); - // c) Fallback (nur wenn du es noch möchtest): Team-Mitgliedschaft + const inTeamB = + Array.isArray(teamB?.players) && + teamB.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId); + + if (inTeamA || inTeamB) return true; + + // Fallback: flaches players-Array + const flatPlayers = (m as unknown as { players?: PlayerLike[] | null }).players ?? []; + const inFlat = + Array.isArray(flatPlayers) && + flatPlayers.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId); + + if (inFlat) return true; + + // Optionaler Fallback: Team-Mitgliedschaft const byTeamMembership = - !!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team) + !!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team); - return byTeamMembership - }, [mySteamId, session?.user?.team]) + return byTeamMembership; + }, [mySteamId, session?.user?.team]); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000) @@ -305,23 +323,23 @@ export default function CommunityMatchList({ matchType }: Props) { credentials: 'same-origin', // wichtig: Cookies mitnehmen signal: ctrl.signal, }) - const json = await res.json().catch(() => ({} as any)) + const json: unknown = await res.json().catch(() => ({})); + const raw: unknown = + (isRecord(json) && Array.isArray(json.teams)) ? json.teams : + (isRecord(json) && Array.isArray(json.data)) ? json.data : + (isRecord(json) && Array.isArray(json.items)) ? json.items : + Array.isArray(json) ? json : []; - // ➜ egal ob {teams: [...]}, {data: [...]}, {items: [...]} oder direkt [...] - const raw = - Array.isArray(json?.teams) ? json.teams : - Array.isArray(json?.data) ? json.data : - Array.isArray(json?.items) ? json.items : - Array.isArray(json) ? json : - [] + const arr: UnknownRec[] = Array.isArray(raw) ? (raw as UnknownRec[]) : []; - const opts: TeamOption[] = raw - .map((t: any) => ({ - id: t.id ?? t._id ?? t.teamId ?? t.uuid ?? '', - name: t.name ?? t.title ?? t.displayName ?? t.tag ?? 'Unbenanntes Team', - logo: t.logo ?? t.logoUrl ?? t.image ?? null, - })) - .filter((t: TeamOption) => !!t.id && !!t.name) + const opts: TeamOption[] = arr + .map((r) => { + const id = firstString(r.id, r._id, r.teamId, r.uuid); + const name = firstString(r.name, r.title, r.displayName, r.tag) || 'Unbenanntes Team'; + const logo = toStringOrNull(r.logo ?? r.logoUrl ?? r.image) ?? null; + return { id, name, logo }; + }) + .filter((t) => !!t.id && !!t.name); if (!ignore) setTeams(opts) } catch (e) { diff --git a/src/app/[locale]/components/CreateTeamButton.tsx b/src/app/[locale]/components/CreateTeamButton.tsx index 0a6c2b1..c4854f7 100644 --- a/src/app/[locale]/components/CreateTeamButton.tsx +++ b/src/app/[locale]/components/CreateTeamButton.tsx @@ -1,3 +1,5 @@ +// /src/app/[locale]/components/CreateTeamButton.tsx + 'use client' import { useState, forwardRef } from 'react' @@ -6,10 +8,22 @@ import Modal from './Modal' import Button from './Button' type CreateTeamButtonProps = { - /** Optional: Parent kann damit eine Liste refreshen */ setRefetchKey?: (key: string) => void } +type HSOverlayAPI = { + close: (el: Element | null) => void +}; + +type WindowWithHSOverlay = Window & { + HSOverlay?: HSOverlayAPI; +}; + +type CreateTeamResponse = { + team: { name: string }; + message?: string; +}; + const CreateTeamButton = forwardRef( ({ setRefetchKey }, ref) => { const { data: session } = useSession() @@ -23,8 +37,9 @@ const CreateTeamButton = forwardRef( const closeCreateModalAndCleanup = () => { const modalEl = document.getElementById('modal-create-team') - if (modalEl && (window as any).HSOverlay?.close) { - ;(window as any).HSOverlay.close(modalEl) + const win = window as WindowWithHSOverlay; + if (modalEl && win.HSOverlay?.close) { + win.HSOverlay.close(modalEl); } setShowModal(false) @@ -56,7 +71,7 @@ const CreateTeamButton = forwardRef( body: JSON.stringify({ teamname, leader: session?.user?.steamId }), }) - const result = await res.json() + const result: CreateTeamResponse = await res.json(); if (!res.ok) throw new Error(result.message || 'Fehler beim Erstellen') setStatus('success') @@ -71,9 +86,10 @@ const CreateTeamButton = forwardRef( setRefetchKey?.(Date.now().toString()) }) }, 800) - } catch (err: any) { - setStatus('error') - setMessage(err.message || 'Fehler beim Erstellen des Teams') + } catch (err: unknown) { + setStatus('error'); + const msg = err instanceof Error ? err.message : 'Fehler beim Erstellen des Teams'; + setMessage(msg); } } diff --git a/src/app/[locale]/components/DatePickerWithTime.tsx b/src/app/[locale]/components/DatePickerWithTime.tsx index be6ac1e..07a8a47 100644 --- a/src/app/[locale]/components/DatePickerWithTime.tsx +++ b/src/app/[locale]/components/DatePickerWithTime.tsx @@ -52,7 +52,7 @@ export default function DatePickerWithTime({ value, onChange }: DatePickerWithTi useEffect(() => { const newDate = new Date(year, month, value.getDate(), hour, minute); onChange(newDate); - }, [hour, minute, year, month]); + }, [onChange, value, hour, minute, year, month]); useEffect(() => { if (showPicker && buttonRef.current) { diff --git a/src/app/[locale]/components/EditButton.tsx b/src/app/[locale]/components/EditButton.tsx index dde1c24..f055688 100644 --- a/src/app/[locale]/components/EditButton.tsx +++ b/src/app/[locale]/components/EditButton.tsx @@ -1,22 +1,38 @@ +// /src/app/[locale]/components/EditButton.tsx 'use client' import { useSession } from 'next-auth/react' import Link from 'next/link' +import type { Match as FullMatch } from '@/types/match' -export default function EditButton({ match }: { match: any }) { +type Leaderish = string | { steamId?: string | null } | null | undefined +type TeamLeaderView = { leader?: Leaderish } | null | undefined + +// Wir brauchen nur id, teamA, teamB – und jeweils den leader +type MatchForEditButton = Pick & { + teamA: TeamLeaderView + teamB: TeamLeaderView +} + +function leaderIdOf(l: Leaderish): string | null { + if (!l) return null + if (typeof l === 'string') return l + return typeof l.steamId === 'string' ? l.steamId : null +} + +export default function EditButton({ match }: { match: MatchForEditButton }) { const { data: session } = useSession() + const me = session?.user?.steamId ?? null - const isLeader = - session?.user?.steamId && - (session.user.steamId === match.teamA.leader || - session.user.steamId === match.teamB.leader) - + const leaderA = leaderIdOf(match.teamA?.leader) + const leaderB = leaderIdOf(match.teamB?.leader) + const isLeader = !!me && (me === leaderA || me === leaderB) if (!isLeader) return null return (
Match bearbeiten diff --git a/src/app/[locale]/components/EditMatchMetaModal.tsx b/src/app/[locale]/components/EditMatchMetaModal.tsx index 55bc9ed..712f79b 100644 --- a/src/app/[locale]/components/EditMatchMetaModal.tsx +++ b/src/app/[locale]/components/EditMatchMetaModal.tsx @@ -1,4 +1,4 @@ -// /src/app/components/EditMatchMetaModal.tsx +// /src/app/[locale]/components/EditMatchMetaModal.tsx 'use client' import { useEffect, useMemo, useRef, useState } from 'react' @@ -13,6 +13,14 @@ type ZonedParts = { year: number; month: number; day: number; hour: number; minute: number; }; +type UnknownRec = Record; +const isRecord = (v: unknown): v is UnknownRec => !!v && typeof v === 'object'; +const isTeamOption = (v: unknown): v is TeamOption => + isRecord(v) && + typeof v.id === 'string' && + typeof v.name === 'string' && + (v.logo == null || typeof v.logo === 'string'); + type Props = { show: boolean onClose: () => void @@ -78,27 +86,16 @@ export default function EditMatchMetaModal({ defaultTeamAName, defaultTeamBName, defaultDateISO, - // defaultMap, - defaultVoteLeadMinutes = 60, onSaved, defaultBestOf = 3, }: Props) { /* ───────── Utils ───────── */ const normalizeBestOf = (bo: unknown): 3 | 5 => (Number(bo) === 5 ? 5 : 3) - const toDatetimeLocal = (iso?: string | null) => { - if (!iso) return '' - const d = new Date(iso) - const pad = (n: number) => String(n).padStart(2, '0') - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad( - d.getHours() - )}:${pad(d.getMinutes())}` - } /* ───────── Local state ───────── */ const [title, setTitle] = useState(defaultTitle ?? '') const [teamAId, setTeamAId] = useState(defaultTeamAId ?? '') const [teamBId, setTeamBId] = useState(defaultTeamBId ?? '') - const [voteLead, setVoteLead] = useState(defaultVoteLeadMinutes) const userTZ = getUserTimeZone(); const initDT = isoToLocalDateTimeStrings(defaultDateISO, userTZ); @@ -150,11 +147,12 @@ export default function EditMatchMetaModal({ ;(async () => { try { const res = await fetch('/api/teams', { cache: 'no-store' }) - const data = res.ok ? await res.json() : [] + const dataUnknown: unknown = res.ok ? await res.json() : [] if (!alive) return - const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? []) - setTeams((list ?? []).filter((t: any) => t?.id && t?.name)) - } catch (e: any) { + const listUnknown: unknown = Array.isArray(dataUnknown) ? dataUnknown : (isRecord(dataUnknown) ? dataUnknown.teams : []); + const list = Array.isArray(listUnknown) ? listUnknown.filter(isTeamOption) : []; + setTeams(list) + } catch (e: unknown) { if (!alive) return console.error('[EditMatchMetaModal] load teams failed:', e) setTeams([]) @@ -197,10 +195,7 @@ export default function EditMatchMetaModal({ const dt = isoToLocalDateTimeStrings(j?.matchDate ?? j?.demoDate ?? null, userTZ); setMatchDateStr(dt.dateStr); setMatchTimeStr(dt.timeStr); - const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes)) - ? Number(j.mapVote.leadMinutes) - : 60; - setVoteLead(leadMin); + const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes)) ? Number(j.mapVote.leadMinutes) : 60; // Vote-Open = MatchStart - leadMin const matchISO = combineLocalDateTime(dt.dateStr, dt.timeStr); @@ -213,10 +208,10 @@ export default function EditMatchMetaModal({ setBestOf(boFromMeta) setMetaBestOf(boFromMeta) setSaved(false) - } catch (e: any) { + } catch (e: unknown) { if (!alive) return console.error('[EditMatchMetaModal] reload meta failed:', e) - setError(e?.message || 'Konnte aktuelle Match-Metadaten nicht laden.') + setError(e instanceof Error ? e.message : 'Konnte aktuelle Match-Metadaten nicht laden.') } finally { if (alive) { setLoadingMeta(false) @@ -229,7 +224,7 @@ export default function EditMatchMetaModal({ alive = false metaFetchedRef.current = false } - }, [show, matchId]) + }, [show, matchId, userTZ]) /* ───────── Optionen für Selects ───────── */ const teamOptionsA = useMemo( @@ -241,9 +236,6 @@ export default function EditMatchMetaModal({ [teams, teamAId] ) - /* ───────── Hinweis-Flag nur vs. /meta ───────── */ - const showBoChangedHint = metaBestOf !== null && bestOf !== metaBestOf - /* ───────── Validation ───────── */ const canSave = useMemo(() => { if (saving || loadingMeta) return false @@ -256,7 +248,7 @@ export default function EditMatchMetaModal({ if (new Date(openISO).getTime() > new Date(matchISO).getTime()) return false if (teamAId && teamBId && teamAId === teamBId) return false return true - }, [saving, loadingMeta, teamAId, teamBId]) + }, [saving, loadingMeta, teamAId, teamBId, matchDateStr, matchTimeStr, voteOpenDateStr, voteOpenTimeStr]) /* ───────── Save ───────── */ const handleSave = async () => { @@ -298,9 +290,9 @@ export default function EditMatchMetaModal({ setSaved(true) onClose() setTimeout(() => onSaved?.(), 0) - } catch (e: any) { + } catch (e: unknown) { console.error('[EditMatchMetaModal] save error:', e) - setError(e?.message || 'Speichern fehlgeschlagen') + setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen') } finally { setSaving(false) } diff --git a/src/app/[locale]/components/EditMatchPlayersModal.tsx b/src/app/[locale]/components/EditMatchPlayersModal.tsx index 8681bf3..66c20c7 100644 --- a/src/app/[locale]/components/EditMatchPlayersModal.tsx +++ b/src/app/[locale]/components/EditMatchPlayersModal.tsx @@ -1,18 +1,10 @@ -/* ------------------------------------------------------------------ - /app/components/EditMatchPlayersModal.tsx - – zeigt ALLE Spieler des gewählten Teams & nutzt DroppableZone-IDs - "active" / "inactive" analog zur TeamMemberView. -------------------------------------------------------------------- */ +// /src/app/[locale]/components/EditMatchPlayersModal.tsx 'use client' import { useEffect, useState, useMemo } from 'react' -import { useSession } from 'next-auth/react' -import { - DndContext, closestCenter, DragOverlay, -} from '@dnd-kit/core' -import { - SortableContext, verticalListSortingStrategy, -} from '@dnd-kit/sortable' +import { useSession } from 'next-auth/react' +import { DndContext, closestCenter, DragOverlay, type DragStartEvent, type DragEndEvent } from '@dnd-kit/core' +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import Modal from '../components/Modal' import SortableMiniCard from '../components/SortableMiniCard' @@ -123,14 +115,14 @@ export default function EditMatchPlayersModal (props: Props) { setLoading(false) } })() - }, [show, team?.id]) + }, [show, team?.id, myInit, otherInitSet]) /* ---- Drag’n’Drop-Handler -------------------------------- */ - const onDragStart = ({ active }: any) => { + const onDragStart = ({ active }: DragStartEvent) => { setDragItem(players.find(p => p.steamId === active.id) ?? null) } - const onDragEnd = ({ active, over }: any) => { + const onDragEnd = ({ active, over }: DragEndEvent) => { setDragItem(null) if (!over) return diff --git a/src/app/[locale]/components/FaceitStat.tsx b/src/app/[locale]/components/FaceitStat.tsx index 3604323..f5e6c1b 100644 --- a/src/app/[locale]/components/FaceitStat.tsx +++ b/src/app/[locale]/components/FaceitStat.tsx @@ -1,11 +1,11 @@ -// /src/app/components/FaceitStat.tsx +// /src/app/[locale]/components/FaceitStat.tsx + 'use client' import React from 'react' import FaceitLevelImage from './FaceitLevelBadge' import FaceitElo from './FaceitElo' export default function FaceitStat({ - level, elo, size = 'md', }: { diff --git a/src/app/[locale]/components/GameBanner.tsx b/src/app/[locale]/components/GameBanner.tsx index 7b26fe8..7e8beaa 100644 --- a/src/app/[locale]/components/GameBanner.tsx +++ b/src/app/[locale]/components/GameBanner.tsx @@ -1,3 +1,5 @@ +// //src/app/[locale]/components/GameBanner.tsx + 'use client' import React, {useEffect, useMemo, useRef, useState} from 'react' @@ -25,11 +27,9 @@ type Variant = 'connected' | 'disconnected' /** ✅ NEU: Props, alle optional als Overrides */ type GameBannerProps = { variant?: Variant - /** true => Banner anzeigen (überschreibt interne Sichtbarkeitslogik), false => nie anzeigen */ visible?: boolean zIndex?: number inline?: boolean - serverLabel?: string mapKey?: string mapLabel?: string bgUrl?: string @@ -38,15 +38,59 @@ type GameBannerProps = { score?: string connectedCount?: number totalExpected?: number - missingCount?: number onReconnect?: () => void onDisconnect?: () => void } +type PlayerLike = { + steamId?: string | number | null + steam_id?: string | number | null + id?: string | number | null +} + +type PlayersMsg = { + type: 'players' + players: Array +} + +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 + + const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json()) /* ---------- helpers ---------- */ const hashStr = (s: string) => { let h = 5381; for (let i=0;i { if (!mapKey) return null const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase()) @@ -54,13 +98,35 @@ const pickMapImageFromOptions = (mapKey?: string) => { const idx = Math.abs(hashStr(mapKey)) % opt.images.length return opt.images[idx] ?? null } + const pickMapIcon = (mapKey?: string) => { if (!mapKey) return null const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase()) return opt?.icon ?? null } -const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '') -const toSet = (arr: Iterable) => 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) => new Set(Array.from(arr, v => String(v))) + +const parseWsData = (data: unknown): TelemetryMsg | null => { + try { + if (typeof data === 'string') return JSON.parse(data) as TelemetryMsg + if (data && typeof data === 'object') return data as TelemetryMsg + } catch { /* ignore */ } + return null +} + +const toFiniteOrNull = (v: unknown): number | null => { + const n = Number(v) + return Number.isFinite(n) ? n : null +} + function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) { const h = (host ?? '').trim() || '127.0.0.1' const p = (port ?? '').trim() || '8081' @@ -80,7 +146,6 @@ export default function GameBanner(props: GameBannerProps = {}) { visible: visibleProp, zIndex: zIndexProp, inline = false, - serverLabel, // aktuell nicht gerendert, nur angenommen mapKey: mapKeyProp, mapLabel: mapLabelProp, bgUrl: bgUrlProp, @@ -89,7 +154,6 @@ export default function GameBanner(props: GameBannerProps = {}) { score: scoreProp, connectedCount: connectedCountProp, totalExpected: totalExpectedProp, - missingCount: _missingCountProp, // aktuell nicht gerendert, nur angenommen onReconnect, onDisconnect, } = props @@ -178,38 +242,45 @@ export default function GameBanner(props: GameBannerProps = {}) { } ws.onmessage = (ev) => { - let msg: any = null - try { msg = JSON.parse(String(ev.data ?? '')) } catch {} + const msg = parseWsData(ev.data) if (!msg) return - if (msg.type === 'players' && Array.isArray(msg.players)) { - const ids = msg.players.map(sidOf).filter(Boolean) + if ('type' in msg && msg.type === 'players' && Array.isArray((msg as PlayersMsg).players)) { + const ids = (msg as PlayersMsg).players.map(sidOf).filter(Boolean) setTelemetrySet(toSet(ids)) return } - if (msg.type === 'player_join' && msg.player) { - const sid = sidOf(msg.player); if (!sid) return + + if ('type' in msg && msg.type === 'player_join' && 'player' in (msg as PlayerJoinMsg)) { + const sid = sidOf((msg as PlayerJoinMsg).player) + if (!sid) return setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next }) return } - if (msg.type === 'player_leave') { - const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? ''); if (!sid) return + + if ('type' in msg && msg.type === 'player_leave') { + const m = msg as PlayerLeaveMsg + const sid = sidOf({ steamId: m.steamId, steam_id: m.steam_id, id: m.id }) + if (!sid) return setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next }) return } - if (msg.type === 'score') { - const a = Number(msg.team1 ?? msg.ct) - const b = Number(msg.team2 ?? msg.t) - setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null }) + + if ('type' in msg && msg.type === 'score') { + const m = msg as ScoreMsg + const a = toFiniteOrNull(m.team1 ?? m.ct) + const b = toFiniteOrNull(m.team2 ?? m.t) + setScore({ a, b }) return } - if (msg.score) { - const a = Number(msg.score.team1 ?? msg.score.ct) - const b = Number(msg.score.team2 ?? msg.score.t) - setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null }) + + if ('score' in msg) { + const m = (msg as ScoreWrapMsg).score + const a = toFiniteOrNull(m?.team1 ?? m?.ct) + const b = toFiniteOrNull(m?.team2 ?? m?.t) + setScore({ a, b }) return } - // Phase ignorieren } } @@ -230,7 +301,6 @@ export default function GameBanner(props: GameBannerProps = {}) { // Prop kann Sichtbarkeit erzwingen/abschalten const canShowInternal = meIsParticipant && notExpired - if (visibleProp === false) return null const canShow = visibleProp === true ? true : canShowInternal // Connected Count + Variant diff --git a/src/app/[locale]/components/GameBannerController.tsx b/src/app/[locale]/components/GameBannerController.tsx index 5fe9234..4c5bc55 100644 --- a/src/app/[locale]/components/GameBannerController.tsx +++ b/src/app/[locale]/components/GameBannerController.tsx @@ -1,3 +1,5 @@ +// /src/app/[locale]/components/GameBannerController.tsx + 'use client' import {useEffect, useMemo, useRef, useState} from 'react' @@ -16,6 +18,43 @@ type LiveCfg = { updatedAt?: string } +type PlayerLike = { + steamId?: string | number | null + steam_id?: string | number | null + id?: string | number | null +} + +type PlayersMsg = { + type: 'players' + players: Array +} + +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 + const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json()) /* ---------- WS helpers ---------- */ @@ -31,8 +70,26 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) return `${proto}://${h}${portPart}${pa}` } -const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '') -const toSet = (arr: Iterable) => 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) => new Set(Array.from(arr, v => String(v))) + +function parseWsData(data: unknown): TelemetryMsg | null { + try { + if (typeof data === 'string') return JSON.parse(data) as TelemetryMsg + if (data && typeof data === 'object') return data as TelemetryMsg + } catch { /* ignore */ } + return null +} + +const toFiniteOrNull = (v: unknown): number | null => { + const n = Number(v) + return Number.isFinite(n) ? n : null +} export default function GameBannerController() { const { data: session } = useSession() @@ -153,44 +210,45 @@ export default function GameBannerController() { } ws.onmessage = (ev) => { - let msg: any = null - try { msg = JSON.parse(String(ev.data ?? '')) } catch {} + const msg = parseWsData(ev.data) if (!msg) return - if (msg.type === 'players' && Array.isArray(msg.players)) { - const ids = msg.players.map(sidOf).filter(Boolean) + if ('type' in msg && msg.type === 'players' && Array.isArray((msg as PlayersMsg).players)) { + const ids = (msg as PlayersMsg).players.map(sidOf).filter(Boolean) setTelemetrySet(toSet(ids)) return } - if (msg.type === 'player_join' && msg.player) { - const sid = sidOf(msg.player) + if ('type' in msg && msg.type === 'player_join' && 'player' in (msg as PlayerJoinMsg)) { + const sid = sidOf((msg as PlayerJoinMsg).player) if (!sid) return setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next }) return } - if (msg.type === 'player_leave') { - const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '') + if ('type' in msg && msg.type === 'player_leave') { + const m = msg as PlayerLeaveMsg + const sid = sidOf({ steamId: m.steamId, steam_id: m.steam_id, id: m.id }) if (!sid) return setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next }) return } - if (msg.type === 'score') { - const a = Number(msg.team1 ?? msg.ct) - const b = Number(msg.team2 ?? msg.t) - setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null }) - return - } - if (msg.score) { - const a = Number(msg.score.team1 ?? msg.score.ct) - const b = Number(msg.score.team2 ?? msg.score.t) - setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null }) + if ('type' in msg && msg.type === 'score') { + const m = msg as ScoreMsg + const a = toFiniteOrNull(m.team1 ?? m.ct) + const b = toFiniteOrNull(m.team2 ?? m.t) + setScore({ a, b }) return } - // Phase wird absichtlich ignoriert + if ('score' in msg) { + const m = (msg as ScoreWrapMsg).score + const a = toFiniteOrNull(m?.team1 ?? m?.ct) + const b = toFiniteOrNull(m?.team2 ?? m?.t) + setScore({ a, b }) + return + } } } diff --git a/src/app/[locale]/components/InvitePlayersModal.tsx b/src/app/[locale]/components/InvitePlayersModal.tsx index b3f59fa..acab2f5 100644 --- a/src/app/[locale]/components/InvitePlayersModal.tsx +++ b/src/app/[locale]/components/InvitePlayersModal.tsx @@ -1,4 +1,4 @@ -// InvitePlayersModal.tsx +// /src/app/[locale]/components/InvitePlayersModal.tsx 'use client' @@ -22,6 +22,50 @@ type Props = { directAdd?: boolean } +type UnknownRec = Record; + +type ApiResultItem = { steamId: string; ok: boolean }; + +function parseResultsFromJson(json: unknown): ApiResultItem[] | null { + if (!isRecord(json)) return null; + if (Array.isArray(json.results)) { + const out: ApiResultItem[] = []; + for (const r of json.results) { + const steamIdVal = isRecord(r) ? r.steamId : undefined; + const okVal = isRecord(r) ? r.ok : undefined; + const steamId = + typeof steamIdVal === 'string' ? steamIdVal + : typeof steamIdVal === 'number' ? String(steamIdVal) + : null; + const ok = typeof okVal === 'boolean' ? okVal : false; + if (steamId) out.push({ steamId, ok }); + } + return out; + } + if (Array.isArray(json.invitationIds)) { + const ids: string[] = (json.invitationIds as unknown[]).map(v => + typeof v === 'string' ? v : String(v) + ); + return ids.map(steamId => ({ steamId, ok: true })); + } + return null; +} + +function isRecord(v: unknown): v is UnknownRec { + return !!v && typeof v === 'object'; +} + +function getTeamLeaderSteamId(team: Team | null | undefined): string | null { + if (!team) return null; + const leaderUnknown = (team as unknown as { leader?: unknown }).leader; + if (typeof leaderUnknown === 'string') return leaderUnknown; + if (isRecord(leaderUnknown) && typeof leaderUnknown.steamId === 'string') { + return leaderUnknown.steamId; + } + return null; +} + + export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) { const { data: session } = useSession() const steamId = session?.user?.steamId @@ -39,7 +83,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir const [onlyFree, setOnlyFree] = useState(false) // Sanftes UI-Update - const [isPending, startTransition] = useTransition() + const [, startTransition] = useTransition() const [isFetching, setIsFetching] = useState(false) const abortRef = useRef(null) @@ -100,48 +144,50 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir async function fetchUsers(opts: { resetLayout: boolean }) { try { - abortRef.current?.abort() - const ctrl = new AbortController() - abortRef.current = ctrl + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; - // Höhe einfrieren - if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight) + if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight); - // Start Fetch - setIsFetching(true) + setIsFetching(true); - // 🔽 Spinner NICHT sofort zeigen – erst nach kurzer Verzögerung - if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current) + if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current); spinnerShowTimer.current = window.setTimeout(() => { - setSpinnerVisible(true) - spinnerShownAt.current = Date.now() - }, SPINNER_DELAY_MS) + setSpinnerVisible(true); + spinnerShownAt.current = Date.now(); + }, SPINNER_DELAY_MS); - const qs = new URLSearchParams({ teamId: team.id }) - if (onlyFree) qs.set('onlyFree', 'true') + const qs = new URLSearchParams({ teamId: team.id }); + if (onlyFree) qs.set('onlyFree', 'true'); const res = await fetch(`/api/team/available-users?${qs.toString()}`, { signal: ctrl.signal, cache: 'no-store', - }) - if (!res.ok) throw new Error('load failed') + }); + if (!res.ok) throw new Error('load failed'); + + const dataUnknown: unknown = await res.json(); + const users = (isRecord(dataUnknown) && Array.isArray(dataUnknown.users)) + ? (dataUnknown.users as Player[]) + : []; - const data = await res.json() startTransition(() => { - setAllUsers(data.users || []) + setAllUsers(users); setKnownUsers(prev => { - const next = { ...prev } - for (const u of (data.users || [])) next[u.steamId] = u - return next - }) + const next = { ...prev }; + for (const u of users) next[u.steamId] = u; + return next; + }); if (opts.resetLayout) { - setSelectedIds([]) - setInvitedIds([]) - setIsSuccess(false) + setSelectedIds([]); + setInvitedIds([]); + setIsSuccess(false); } - }) - } catch (e: any) { - if (e?.name !== 'AbortError') console.error('Fehler beim Laden der Benutzer:', e) + }); + } catch (e: unknown) { + if (isRecord(e) && e.name === 'AbortError') return; + console.error('Fehler beim Laden der Benutzer:', e); } finally { setIsFetching(false) abortRef.current = null @@ -194,67 +240,60 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir } const handleInvite = async () => { - if (isInviting) return - if (selectedIds.length === 0 || !steamId) return - const ids = [...selectedIds] + if (isInviting) return; + if (selectedIds.length === 0 || !steamId) return; + const ids = [...selectedIds]; try { - setIsInviting(true) - const url = directAdd ? '/api/team/add-players' : '/api/team/invite' + setIsInviting(true); + const url = directAdd ? '/api/team/add-players' : '/api/team/invite'; const body = directAdd ? { teamId: team.id, steamIds: ids } - : { teamId: team.id, userIds: ids, invitedBy: steamId } + : { teamId: team.id, userIds: ids, invitedBy: steamId }; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }) + }); - let json: any = null - try { json = await res.clone().json() } catch {} + let jsonUnknown: unknown = null; + try { jsonUnknown = await res.clone().json(); } catch { /* ignore */ } - let results: { steamId: string; ok: boolean }[] = [] + let results: ApiResultItem[] | null = parseResultsFromJson(jsonUnknown); - if (directAdd) { - if (json?.results && Array.isArray(json.results)) { - results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok })) - } else { - results = ids.map(id => ({ steamId: id, ok: res.ok })) - } - } else if (json?.results && Array.isArray(json.results)) { - results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok })) - } else if (Array.isArray(json?.invitationIds)) { - const okSet = new Set(json.invitationIds) - results = ids.map(id => ({ steamId: id, ok: okSet.has(id) })) - } else { - results = ids.map(id => ({ steamId: id, ok: false })) + // Fallback, falls API keine detailierten results liefert + if (!results) { + results = ids.map(id => ({ steamId: id, ok: res.ok })); } - const nextStatus: Record = {} - let okCount = 0 + const nextStatus: Record = {}; + let okCount = 0; for (const r of results) { - const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed' - nextStatus[r.steamId] = st - if (r.ok) okCount++ + const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed'; + nextStatus[r.steamId] = st; + if (r.ok) okCount++; } - setInvitedStatus(prev => ({ ...prev, ...nextStatus })) - setInvitedIds(ids) - setSentCount(okCount) - setIsSuccess(true) - setSelectedIds([]) - if (okCount > 0) onSuccess() - } catch (err) { - console.error('Fehler beim Einladen:', err) - setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(selectedIds.map(id => [id, 'failed'])) })) - setInvitedIds(selectedIds) - setSentCount(0) - setIsSuccess(true) + setInvitedStatus(prev => ({ ...prev, ...nextStatus })); + setInvitedIds(ids); + setSentCount(okCount); + setIsSuccess(true); + setSelectedIds([]); + if (okCount > 0) onSuccess(); + } catch (err: unknown) { + console.error('Fehler beim Einladen:', err); + setInvitedStatus(prev => ({ + ...prev, + ...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus])), + })); + setInvitedIds(selectedIds); + setSentCount(0); + setIsSuccess(true); } finally { - setIsInviting(false) + setIsInviting(false); } - } + }; useEffect(() => { setCurrentPage(1) }, [searchTerm]) @@ -407,7 +446,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir onSelect={handleSelect} draggable={false} currentUserSteamId={steamId!} - teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader} + teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined} hideActions rank={user.premierRank} /> @@ -480,12 +519,13 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir avatar={user.avatar} location={user.location} selected={false} - onSelect={handleSelect} draggable={false} + onSelect={handleSelect} currentUserSteamId={steamId!} - teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader} + teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined} hideActions rank={user.premierRank} + invitedStatus={invitedStatus[user.steamId]} /> ))} @@ -511,7 +551,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir selected={false} draggable={false} currentUserSteamId={steamId!} - teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader} + teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined} hideActions rank={user.premierRank} invitedStatus={invitedStatus[user.steamId]} diff --git a/src/app/[locale]/components/LeaveTeamModal.tsx b/src/app/[locale]/components/LeaveTeamModal.tsx index 52ea391..533e510 100644 --- a/src/app/[locale]/components/LeaveTeamModal.tsx +++ b/src/app/[locale]/components/LeaveTeamModal.tsx @@ -19,7 +19,7 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props const steamId = session?.user?.steamId const [newLeaderId, setNewLeaderId] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) + const [, setIsSubmitting] = useState(false) useEffect(() => { if (show && team.leader?.steamId) { diff --git a/src/app/[locale]/components/MapVoteBanner.tsx b/src/app/[locale]/components/MapVoteBanner.tsx index 808f0db..d8cfa8d 100644 --- a/src/app/[locale]/components/MapVoteBanner.tsx +++ b/src/app/[locale]/components/MapVoteBanner.tsx @@ -1,4 +1,4 @@ -// /src/app/components/MapVoteBanner.tsx +// /src/app/[locale]/components/MapVoteBanner.tsx 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -8,12 +8,52 @@ import { useSSEStore } from '@/lib/useSSEStore' import type { MapVoteState } from '../../../types/mapvote' import { useTranslations } from 'next-intl' +type TeamLite = { id?: string | null; name?: string | null; leader?: { steamId?: string | null } | null }; +type MatchLite = { id: string; bestOf?: number | null; matchDate?: string | null; demoDate?: string | null; teamA?: TeamLite | null; teamB?: TeamLite | null }; + type Props = { - match: any - initialNow: number - matchBaseTs: number | null - sseOpensAtTs?: number | null - sseLeadMinutes?: number | null + match: MatchLite; + initialNow: number; + matchBaseTs: number | null; + sseOpensAtTs?: number | null; + sseLeadMinutes?: number | null; +}; + +type ReloadEventType = + | 'map-vote-updated' | 'map-vote-reset' | 'map-vote-locked' | 'map-vote-unlocked' + | 'match-updated' | 'match-lineup-updated'; + +type SSEPayload = { + matchId?: string; + leadMinutes?: number; + opensAt?: string | number | Date; +}; + +type TMapvote = ReturnType>; + +const RELOAD_TYPES = new Set([ + 'map-vote-updated','map-vote-reset','map-vote-locked','map-vote-unlocked', + 'match-updated','match-lineup-updated', +]); + +type UnknownRec = Record; + +function isObject(v: unknown): v is UnknownRec { + return !!v && typeof v === 'object'; +} + +function unwrapEvent(e: unknown): { type?: string; payload: SSEPayload } { + const type = + isObject(e) && typeof (e as UnknownRec)['type'] === 'string' + ? ((e as UnknownRec)['type'] as string) + : undefined; + + const rawPayload = + isObject(e) && 'payload' in e ? (e as UnknownRec)['payload'] : e; + + const payload = isObject(rawPayload) ? (rawPayload as SSEPayload) : ({} as SSEPayload); + + return { type, payload }; } function formatCountdown(ms: number) { @@ -25,18 +65,28 @@ function formatCountdown(ms: number) { const pad = (n:number)=>String(n).padStart(2,'0') return `${h}:${pad(m)}:${pad(s)}` } -function formatLead(minutes: number) { - if (!Number.isFinite(minutes) || minutes <= 0) return 'zum Matchbeginn' - const h = Math.floor(minutes / 60) - const m = minutes % 60 - if (h > 0 && m > 0) return `${h}h ${m}min` - if (h > 0) return `${h}h` - return `${m}min` + +function formatLead(minutes: number, tMapvote: TMapvote) { + if (!Number.isFinite(minutes) || minutes <= 0) return tMapvote('to-match-start'); + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h > 0 && m > 0) return `${h}h ${m}min`; + if (h > 0) return `${h}h`; + return `${m}min`; +} + +function isAbortError(err: unknown): boolean { + if (typeof DOMException !== 'undefined' && err instanceof DOMException) { + return err.name === 'AbortError'; + } + return isObject(err) && typeof err.name === 'string' && err.name === 'AbortError'; } export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes, }: Props) { + const tMapvote = useTranslations<'mapvote'>('mapvote'); + const router = useRouter() const { data: session } = useSession() const { lastEvent } = useSSEStore() @@ -51,26 +101,30 @@ export default function MapVoteBanner({ const [now, setNow] = useState(initialNow) useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, []) - - // Übersetzungen - const tCommon = useTranslations('common') const load = useCallback(async () => { + const ac = new AbortController(); try { - setError(null) - const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' }) + setError(null); + const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store', signal: ac.signal }); if (!r.ok) { - const j = await r.json().catch(() => ({})) - throw new Error(j?.message || 'Laden fehlgeschlagen') + let message = 'Laden fehlgeschlagen'; + const parsed = (await r.json().catch(() => null)) as unknown; + if (isObject(parsed) && typeof parsed.message === 'string') { + message = parsed.message; + } + throw new Error(message); } - const json = await r.json() - if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)') - setState(json) - } catch (e: any) { - setState(null) - setError(e?.message ?? 'Unbekannter Fehler') + const json: MapVoteState = await r.json(); + if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)'); + setState(json); + } catch (e: unknown) { + if (isAbortError(e)) return; + setState(null); + setError(e instanceof Error ? e.message : 'Unbekannter Fehler'); } - }, [match.id]) + return () => ac.abort(); + }, [match.id]); useEffect(() => { load() }, [load]) useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load]) @@ -78,60 +132,70 @@ export default function MapVoteBanner({ const matchDateTs = useMemo(() => (typeof matchBaseTs === 'number' ? matchBaseTs : null), [matchBaseTs]) useEffect(() => { - if (!lastEvent) return - const { type } = lastEvent as any - const evt = (lastEvent as any).payload ?? lastEvent - if (evt?.matchId !== match.id) return + if (!lastEvent) return; + const { type, payload } = unwrapEvent(lastEvent); + if (payload.matchId !== match.id) return; + if (!type || !RELOAD_TYPES.has(type as ReloadEventType)) return; - const RELOAD_TYPES = new Set([ - 'map-vote-updated','map-vote-reset','map-vote-locked','map-vote-unlocked', - 'match-updated','match-lineup-updated', - ]) - if (!RELOAD_TYPES.has(type)) return - - const rawLead = evt?.leadMinutes - const parsedLead = (rawLead !== undefined && rawLead !== null) ? Number(rawLead) : undefined - const nextOpensAtISO = - evt?.opensAt - ? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString()) - : undefined + const rawLead = payload.leadMinutes; + const parsedLead = Number.isFinite(rawLead) ? Number(rawLead) : undefined; + const nextOpensAtISO = payload.opensAt + ? (typeof payload.opensAt === 'string' + ? payload.opensAt + : new Date(payload.opensAt).toISOString()) + : undefined; if (nextOpensAtISO) { - setOpensAtOverride(new Date(nextOpensAtISO).getTime()) - } else if (Number.isFinite(parsedLead) && matchDateTs != null) { - setOpensAtOverride(matchDateTs - (parsedLead as number) * 60_000) + setOpensAtOverride(new Date(nextOpensAtISO).getTime()); + } else if (parsedLead !== undefined && matchDateTs != null) { + setOpensAtOverride(matchDateTs - parsedLead * 60_000); } - if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number) + if (parsedLead !== undefined) setLeadOverride(parsedLead); - if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) { - setState(prev => ({ - ...(prev ?? {} as any), - ...(nextOpensAtISO !== undefined ? { opensAt: nextOpensAtISO } : {}), - ...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}), - }) as any) + if (nextOpensAtISO !== undefined || parsedLead !== undefined) { + setState(prev => { + if (!prev) return prev; // bleibt null, bis ein kompletter State geladen ist + const patch: Partial = {}; + if (nextOpensAtISO !== undefined) patch.opensAt = nextOpensAtISO; + if (parsedLead !== undefined) patch.leadMinutes = parsedLead; + return { ...prev, ...patch }; // => MapVoteState + }); } else { - load() + load(); } - }, [lastEvent, match.id, matchDateTs, load]) + }, [lastEvent, match.id, matchDateTs, load]); + + const stateOpensAt = state?.opensAt; + const stateLeadMinutes = state?.leadMinutes; const opensAt = useMemo(() => { - if (typeof sseOpensAtTs === 'number') return sseOpensAtTs - if (opensAtOverride != null) return opensAtOverride - if (state?.opensAt) return new Date(state.opensAt).getTime() - if (matchDateTs == null) return new Date(initialNow).getTime() - const lead = (typeof sseLeadMinutes === 'number') - ? sseLeadMinutes - : (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? (state!.leadMinutes as number) : 60)) - return matchDateTs - lead * 60_000 - }, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes]) + if (typeof sseOpensAtTs === 'number') return sseOpensAtTs; + if (opensAtOverride != null) return opensAtOverride; + if (stateOpensAt) return new Date(stateOpensAt).getTime(); + if (matchDateTs == null) return new Date(initialNow).getTime(); + const lead = + typeof sseLeadMinutes === 'number' + ? sseLeadMinutes + : (leadOverride ?? (Number.isFinite(stateLeadMinutes) ? (stateLeadMinutes as number) : 60)); + return matchDateTs - lead * 60_000; + }, [ + sseOpensAtTs, + opensAtOverride, + stateOpensAt, + matchDateTs, + initialNow, + sseLeadMinutes, + leadOverride, + stateLeadMinutes, + ]); const leadMinutes = useMemo(() => { - if (matchDateTs != null && opensAt != null) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000)) - if (typeof sseLeadMinutes === 'number') return sseLeadMinutes - if (leadOverride != null) return leadOverride - if (Number.isFinite(state?.leadMinutes)) return state!.leadMinutes as number - return 60 - }, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes]) + if (matchDateTs != null && opensAt != null) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000)); + if (typeof sseLeadMinutes === 'number') return sseLeadMinutes; + if (leadOverride != null) return leadOverride; + if (Number.isFinite(stateLeadMinutes)) return stateLeadMinutes as number; + return 60; + }, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, stateLeadMinutes]); const isOpen = mounted && now >= opensAt const msToOpen = Math.max(opensAt - now, 0) @@ -185,7 +249,7 @@ export default function MapVoteBanner({ onClick={gotoFullPage} onKeyDown={(e) => e.key === 'Enter' && gotoFullPage()} className={`group relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ${ringClass}`} - aria-label="Map-Vote öffnen" + aria-label={`Mapvote ${tMapvote("open-small")}`} > {(isVotingOpen || isLocked) && ( <> @@ -205,12 +269,12 @@ export default function MapVoteBanner({
Map-Vote
- Modus: BO{match.bestOf ?? state?.bestOf ?? 3} + {tMapvote("mode")}: BO{match.bestOf ?? state?.bestOf ?? 3} {isEnded ? ' • Auswahl fixiert' : isLive ? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft') - : ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`} + : ` • startet ${formatLead(leadMinutes, tMapvote)} vor Matchbeginn`}
{error &&
{error}
}
@@ -219,16 +283,16 @@ export default function MapVoteBanner({
{isEnded ? ( - Voting abgeschlossen + {tMapvote("completed")} ) : isLive ? ( - {iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'} + {iCanAct ? tMapvote("vote-now") : `Mapvote ${tMapvote("open")}`} ) : ( - Öffnet in {mounted ? formatCountdown(msToOpen) : '–:–:–'} + {tMapvote('opens-in')} {mounted ? formatCountdown(msToOpen) : '–:–:–'} • {formatLead(leadMinutes, tMapvote)} )}
diff --git a/src/app/[locale]/components/MapVotePanel.tsx b/src/app/[locale]/components/MapVotePanel.tsx index eb129ea..731314e 100644 --- a/src/app/[locale]/components/MapVotePanel.tsx +++ b/src/app/[locale]/components/MapVotePanel.tsx @@ -1,4 +1,4 @@ -// MapVotePanel.tsx +// /src/app/[locale]/components/MapVotePanel.tsx 'use client' @@ -19,11 +19,47 @@ import { MAP_OPTIONS } from '@/lib/mapOptions' import { Tabs } from './Tabs' import Chart from './Chart' import { MAPVOTE_REFRESH } from '@/lib/sseEvents' +import Image from 'next/image' /* =================== Utilities & constants =================== */ type Props = { match: Match } +// --- Safe type guards (no-any helpers) -------------------------------------- +function isRecord(v: unknown): v is Record { + 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 ? 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 { + if (!isRecord(e)) return {}; + const p = (e as Record).payload; + if (isRecord(p) && isRecord((p as Record).payload)) return (p as Record).payload as Record; + if (isRecord(p)) return p as Record; + return e as Record; +} + const HOLD_MS = 1200 const COMPLETE_THRESHOLD = 1.0 @@ -47,6 +83,10 @@ const winrateCache = new Map>() type BatchByPlayer = Record> // steamId -> { mapKey -> pct 0..100 } +type WinrateApi = { + byPlayer?: Record }>; +}; + /** Liest /api/user/winrate und normiert auf 0..100 (Float), NICHT ×10 */ async function fetchWinratesBatch( steamIds: string[], @@ -56,36 +96,43 @@ async function fetchWinratesBatch( if (!ids.length) return {}; const q = new URLSearchParams(); - - // steamIds als CSV in EINEM Param q.set('steamIds', ids.join(',')); - - // types als wiederholte Parameter (opts?.types ?? []).forEach(t => q.append('types', t)); - if (opts?.onlyActive === false) q.append('onlyActive', 'false'); const r = await fetch(`/api/user/winrate?${q.toString()}`, { cache: 'no-store' }); if (!r.ok) return {}; - const json = await r.json().catch(() => null); - const out: BatchByPlayer = {}; + const json = (await r.json().catch(() => null)) as WinrateApi | null; const byPlayer = json?.byPlayer ?? {}; - for (const [steamId, block] of Object.entries(byPlayer)) { + const out: BatchByPlayer = {}; + + for (const [steamId, block] of Object.entries(byPlayer)) { const maps = block?.byMap ?? {}; const normalized: Record = {}; - for (const [mapKey, agg] of Object.entries(maps)) { + for (const [mapKey, agg] of Object.entries(maps)) { const pctX10 = Number(agg?.pct); if (Number.isFinite(pctX10)) normalized[mapKey] = pctX10 / 10; } out[steamId] = normalized; } - for (const id of Object.keys(out)) winrateCache.set(id, out[id]); + Object.keys(out).forEach(id => winrateCache.set(id, out[id])); return out; } /* =================== Component =================== */ +// Ergänze Match um optionale Felder, die hier verwendet werden +type MatchLike = Match & { + players?: MatchPlayer[]; + teamA?: { id?: string; name?: string; logo?: string | null; leader?: { steamId?: string | null }; players?: MatchPlayer[] }; + teamB?: { id?: string; name?: string; logo?: string | null; leader?: { steamId?: string | null }; players?: MatchPlayer[] }; + teamAUsers?: { steamId: string }[]; + teamBUsers?: { steamId: string }[]; +}; + +type StepAction = 'ban' | 'pick' | 'decider'; + export default function MapVotePanel({ match }: Props) { /* -------- External stores / env -------- */ const router = useRouter() @@ -100,7 +147,6 @@ export default function MapVotePanel({ match }: Props) { const [adminEditMode, setAdminEditMode] = useState(false) const [overlayShownOnce, setOverlayShownOnce] = useState(false) const [opensAtOverrideTs, setOpensAtOverrideTs] = useState(null) - const [tab, setTab] = useState<'pool' | 'winrate'>('pool') /* -------- Timers / open window -------- */ @@ -154,18 +200,18 @@ export default function MapVotePanel({ match }: Props) { }, [overlayOpen, overlayIsForThisMatch]) useEffect(() => { - if (!lastEvent) return - if (lastEvent.type !== 'match-ready') return - if (lastEvent.payload?.matchId !== match.id) return + if (!isMatchReadyEvent(lastEvent)) return; + if (lastEvent.payload.matchId !== match.id) return; + + const fm = lastEvent.payload.firstMap; // FirstMap | undefined - const fm = lastEvent.payload?.firstMap ?? {} showWithDelay({ matchId: match.id, mapLabel: fm?.label ?? 'Erste Map', mapBg: fm?.bg ?? '/assets/img/maps/lobby_mapveto_png.webp', nextHref: `/match-details/${match.id}/radar`, - }, 3000) - }, [lastEvent, match.id, showWithDelay]) + }, 3000); + }, [lastEvent, match.id, showWithDelay]); /* -------- Data load (initial + SSE refresh) -------- */ const load = useCallback(async () => { @@ -174,15 +220,15 @@ export default function MapVotePanel({ match }: Props) { try { const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' }) if (!r.ok) { - const j = await r.json().catch(() => ({})) + const j = await r.json().catch(() => ({} as { message?: string })) throw new Error(j?.message || 'Laden fehlgeschlagen') } const json = await r.json() if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)') setState(json) - } catch (e: any) { + } catch (e: unknown) { setState(null) - setError(e?.message ?? 'Unbekannter Fehler') + setError(e instanceof Error ? e.message : 'Unbekannter Fehler') } finally { setIsLoading(false) } @@ -226,9 +272,14 @@ export default function MapVotePanel({ match }: Props) { ) const decisionByMap = useMemo(() => { - const map = new Map() + const map = new Map() for (const s of state?.steps ?? []) { - if (s.map) map.set(s.map, { action: s.action as any, teamId: s.teamId ?? null }) + if (s.map) { + const a = s.action + if (a === 'ban' || a === 'pick' || a === 'decider') { + map.set(s.map, { action: a, teamId: s.teamId ?? null }) + } + } } return map }, [state?.steps]) @@ -249,24 +300,24 @@ export default function MapVotePanel({ match }: Props) { body: JSON.stringify({ adminEdit: enabled }), }) if (!r.ok) { - const j = await r.json().catch(() => ({})) + const j = await r.json().catch(() => ({} as { message?: string })) throw new Error(j?.message || 'Konnte Admin-Edit nicht setzen') } return r.json() } - const handlePickOrBan = async (map: string) => { - if (!isMyTurn || !currentStep) return + const handlePickOrBan = useCallback(async (map: string) => { + if (!isMyTurn || !currentStep) return; try { const r = await fetch(`/api/matches/${match.id}/mapvote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ map }), - }) + }); if (!r.ok) { - const j = await r.json().catch(() => ({})) - alert(j.message ?? 'Aktion fehlgeschlagen') - return + const j = await r.json().catch(() => ({} as { message?: string })); + alert(j.message ?? 'Aktion fehlgeschlagen'); + return; } setState(prev => prev @@ -277,11 +328,11 @@ export default function MapVotePanel({ match }: Props) { ), } : prev - ) + ); } catch { - alert('Netzwerkfehler') + alert('Netzwerkfehler'); } - } + }, [isMyTurn, currentStep, match.id, setState]); /* -------- Press-and-hold logic -------- */ const rafRef = useRef(null) @@ -369,17 +420,22 @@ export default function MapVotePanel({ match }: Props) { }, [state?.steps]) /* -------- Players & ranks -------- */ + const m = match as MatchLike + + // Hilfstyp für optionale Team-Id an MatchPlayer + type MaybeTeam = { team?: { id?: string | null } }; + const playersA = useMemo(() => { - const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined + const teamPlayers = m.teamA?.players if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers - const all = (match as any).players as MatchPlayer[] | undefined - const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined + const all = m.players + const teamAUsers = m.teamAUsers if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) { const setA = new Set(teamAUsers.map(u => u.steamId)) return all.filter(p => setA.has(p.user.steamId)) } - if (Array.isArray(all) && match.teamA?.id) { - return all.filter(p => (p as any).team?.id === match.teamA?.id) + if (Array.isArray(all) && m.teamA?.id) { + return all.filter(p => (p as unknown as MaybeTeam).team?.id === m.teamA?.id) } const votePlayers = state?.teams?.teamA?.players as | Array<{ steamId: string; name?: string | null; avatar?: string | null }> @@ -395,19 +451,19 @@ export default function MapVotePanel({ match }: Props) { })) } return [] - }, [match, state?.teams?.teamA?.players]) + }, [m, state?.teams?.teamA?.players]) const playersB = useMemo(() => { - const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined + const teamPlayers = m.teamB?.players if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers - const all = (match as any).players as MatchPlayer[] | undefined - const teamBUsers = (match as any).teamBUsers as { steamId: string }[] | undefined + const all = m.players + const teamBUsers = m.teamBUsers if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) { const setB = new Set(teamBUsers.map(u => u.steamId)) return all.filter(p => setB.has(p.user.steamId)) } - if (Array.isArray(all) && match.teamB?.id) { - return all.filter(p => (p as any).team?.id === match.teamB?.id) + if (Array.isArray(all) && m.teamB?.id) { + return all.filter(p => (p as unknown as MaybeTeam).team?.id === m.teamB?.id) } const votePlayers = state?.teams?.teamB?.players as | Array<{ steamId: string; name?: string | null; avatar?: string | null }> @@ -423,14 +479,14 @@ export default function MapVotePanel({ match }: Props) { })) } return [] - }, [match, state?.teams?.teamB?.players]) + }, [m, state?.teams?.teamB?.players]) const teamAPlayersForRank = useMemo( - () => playersA.map(p => ({ premierRank: p.user.premierRank ?? 0 })) as any, + () => playersA.map(p => ({ premierRank: p.user.premierRank ?? 0 })), [playersA] ) const teamBPlayersForRank = useMemo( - () => playersB.map(p => ({ premierRank: p.user.premierRank ?? 0 })) as any, + () => playersB.map(p => ({ premierRank: p.user.premierRank ?? 0 })), [playersB] ) @@ -490,10 +546,6 @@ export default function MapVotePanel({ match }: Props) { [state, playersA, playersB, setState] ); - const amInTeamA = !!mySteamId && playersA.some(p => p.user?.steamId === mySteamId) - const amInTeamB = !!mySteamId && playersB.some(p => p.user?.steamId === mySteamId) - const myTeamId = amInTeamA ? match.teamA?.id : amInTeamB ? match.teamB?.id : null - // Links/Rechts anhand des eigenen Teams let teamLeftKey: 'teamA' | 'teamB' = 'teamA' let teamRightKey: 'teamA' | 'teamB' = 'teamB' @@ -501,23 +553,16 @@ export default function MapVotePanel({ match }: Props) { teamLeftKey = 'teamB' teamRightKey = 'teamA' } - - // Farben an Radar anlehnen - const LEFT_RING = 'ring-2 ring-green-500/70 shadow-[0_10px_30px_rgba(34,197,94,0.25)]'; - const RIGHT_RING = 'ring-2 ring-red-500/70 shadow-[0_10px_30px_rgba(239,68,68,0.25)]'; - const BASE_PANEL = 'relative rounded-lg p-2 transition-all duration-300 ease-out'; - const INACTIVE_FADE = 'opacity-75 grayscale-[5%]'; - - const teamLeft = (match as any)[teamLeftKey] - const teamRight = (match as any)[teamRightKey] + const teamLeft = teamLeftKey === 'teamA' ? m.teamA : m.teamB + const teamRight = teamRightKey === 'teamA' ? m.teamA : m.teamB const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB const playersRight = teamRightKey === 'teamA' ? playersA : playersB const rankLeft = teamLeftKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank const rankRight = teamRightKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank - const leftTeamId = state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id - const rightTeamId = state?.teams?.[teamRightKey]?.id ?? teamRight?.id + const leftTeamId = state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id ?? null + const rightTeamId = state?.teams?.[teamRightKey]?.id ?? teamRight?.id ?? null const leftIsActiveTurn = !!currentStep?.teamId && @@ -568,28 +613,15 @@ export default function MapVotePanel({ match }: Props) { // 4) exakt diese Liste fürs Radar verwenden const activeMapKeys = sortedMapPool; - const activeMapLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf]); - // Helper: Durchschnitt (nur finite Werte) - function avg(values: number[]) { - const valid = values.filter(v => Number.isFinite(v)) - if (!valid.length) return 0 - return valid.reduce((a, b) => a + b, 0) / valid.length - } + // Labels nur aus keys ableiten → stabil solange keys gleich bleiben + const radarLabels = useMemo(() => activeMapKeys.map(labelOf), [activeMapKeys, labelOf]) - // 2) State für Radar-Daten je Team + Team-Ø + // 2) State für Radar-Daten je Team const [teamRadarLeft, setTeamRadarLeft] = useState(activeMapKeys.map(() => 0)) const [teamRadarRight, setTeamRadarRight] = useState(activeMapKeys.map(() => 0)) const lastFetchSigRef = useRef(''); - // Hilfs-Memos: eindeutige Id-Liste + Schlüssel - const allSteamIds = useMemo(() => { - const ids = new Set(); - playersLeft.forEach(p => ids.add(p.user.steamId)); - playersRight.forEach(p => ids.add(p.user.steamId)); - return Array.from(ids); - }, [playersLeft, playersRight]); - // stabile Id-Menge über beide Teams (reihenfolgeunabhängig) const idsKey = useMemo(() => { const s = new Set(); @@ -604,12 +636,6 @@ export default function MapVotePanel({ match }: Props) { [activeMapKeys] ) - // Labels nur aus keys ableiten → stabil solange mapsKey gleich bleibt - const radarLabels = useMemo(() => { - // nur MAP_OPTIONS / state.mapVisuals lesen, Ergebnis aber von mapsKey abhängig - return activeMapKeys.map(labelOf) - }, [mapsKey, labelOf, activeMapKeys]) - // Datasets-Array: nur neu, wenn sich Werte oder Namen ändern const radarDatasets = useMemo(() => ([ { @@ -631,98 +657,63 @@ export default function MapVotePanel({ match }: Props) { // Icons ebenfalls stabilisieren const radarIcons = useMemo( () => activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`), - [mapsKey] // reicht, solange keys unverändert + [activeMapKeys] ) - - useEffect(() => { - if (!lastEvent) return + if (!lastEvent) return; - const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e - const evt = unwrap(lastEvent) - const type = lastEvent.type ?? evt?.type + const evt = unwrapEvent(lastEvent); + const type = (isRecord(lastEvent) && isString((lastEvent as Record).type)) + ? (lastEvent as Record<'type', string>).type + : (isString((evt as Record).type) ? (evt as Record<'type', string>).type : undefined); - const evtMatchId: string | null = evt?.matchId ?? null - const evtTeamId : string | null = evt?.teamId ?? null - const actionType: string | null = evt?.actionType ?? null - const actionData: string | null = evt?.actionData ?? null // z.B. newLeaderSteamId + const evtMatchId = isString((evt as Record).matchId) ? (evt as Record<'matchId', string>).matchId : null; + const evtTeamId = isString((evt as Record).teamId) ? (evt as Record<'teamId', string>).teamId : null; + const actionType = isString((evt as Record).actionType) ? (evt as Record<'actionType', string>).actionType : null; + const actionData = isString((evt as Record).actionData) ? (evt as Record<'actionData', string>).actionData : null; - // 1) Relevanz wie bisher - const isForThisMatchByMatchId = - !!evtMatchId && evtMatchId === match.id + const isForThisMatchByMatchId = !!evtMatchId && evtMatchId === match.id; + const isForThisMatchByTeamId = !!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id); - const isForThisMatchByTeamId = - !!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id) - - // 2) Notifications abdecken (changed + self) const isLeaderChangeNotification = - type === 'notification' && (actionType === 'team-leader-changed' || actionType === 'team-leader-self') + type === 'notification' && (actionType === 'team-leader-changed' || actionType === 'team-leader-self'); - // 3) Gehört der neue Leader zu unseren Teams? - const byNewLeaderId = - isLeaderChangeNotification && !!actionData && teamSteamIds.has(actionData) + const byNewLeaderId = isLeaderChangeNotification && !!actionData && teamSteamIds.has(actionData); + const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId; - // 4) Relevanz - const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId - - // 5) Offensichtliche Leader-Änderungen -> hart refreshen, auch ohne Relevanzbeweis const forceRefresh = - type === 'team-leader-changed' || - type === 'team-updated' || - isLeaderChangeNotification + type === 'team-leader-changed' || type === 'team-updated' || isLeaderChangeNotification; - if (!isRelevant && !forceRefresh) return - - // map-vote-updated: opensAt-Override wie gehabt ... - if (type === 'map-vote-updated') { - const { opensAt, leadMinutes } = evt ?? {} - if (opensAt) { - const ts = new Date(opensAt).getTime() - if (Number.isFinite(ts)) setOpensAtOverrideTs(ts) - setState(prev => (prev ? { ...prev, opensAt } : prev)) - } else if (Number.isFinite(leadMinutes) && matchBaseTs != null) { - const ts = matchBaseTs - Number(leadMinutes) * 60_000 - setOpensAtOverrideTs(ts) - setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev)) - } - } - - // --- Leader-Events zuerst lokal anwenden (ohne Reload) ---------------------- - const isLeaderChangeEvent = - type === 'team-leader-changed' || - (type === 'notification' && - (actionType === 'team-leader-changed' || actionType === 'team-leader-self')); - - if (isLeaderChangeEvent) { - applyLeaderChange(evtTeamId ?? null, actionData ?? null); - return; // kein load() - } - - // --- map-vote-/sonstige Events wie gehabt ----------------------------------- - let shouldRefresh = MAPVOTE_REFRESH.has(type); - - // Optional: harte Reloads weiter einschränken, falls gewünscht - // (z.B. kein Reload für "team-updated", wenn dich nur Leader interessiert) - // if (type === 'team-updated') shouldRefresh = false; + if (!isRelevant && !forceRefresh) return; if (type === 'map-vote-updated') { - const { opensAt, leadMinutes } = evt ?? {}; + const opensAt = isString((evt as Record).opensAt) ? (evt as Record<'opensAt', string>).opensAt : undefined; + const leadMinRaw = typeof (evt as Record).leadMinutes === 'number' ? (evt as Record<'leadMinutes', number>).leadMinutes : undefined; if (opensAt) { const ts = new Date(opensAt).getTime(); if (Number.isFinite(ts)) setOpensAtOverrideTs(ts); setState(prev => (prev ? { ...prev, opensAt } : prev)); - } else if (Number.isFinite(leadMinutes) && matchBaseTs != null) { - const ts = matchBaseTs - Number(leadMinutes) * 60_000; + } else if (Number.isFinite(leadMinRaw ?? NaN) && matchBaseTs != null) { + const ts = matchBaseTs - (leadMinRaw as number) * 60_000; setOpensAtOverrideTs(ts); setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev)); } } + const isLeaderChangeEvent = + type === 'team-leader-changed' || isLeaderChangeNotification; + + if (isLeaderChangeEvent) { + applyLeaderChange(evtTeamId ?? null, actionData ?? null); + return; + } + + const shouldRefresh = isRefreshEvent(type); if (shouldRefresh) { load(); } - }, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, load, matchBaseTs, teamSteamIds]) + }, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, load, matchBaseTs, teamSteamIds, applyLeaderChange]); // Effect NUR an stabile Keys + Tab hängen useEffect(() => { @@ -766,7 +757,7 @@ export default function MapVotePanel({ match }: Props) { })(); return () => { aborted = true; }; - }, [tab, idsKey, mapsKey]); + }, [tab, idsKey, mapsKey, activeMapKeys, playersLeft, playersRight]); /* =================== Render =================== */ @@ -805,9 +796,9 @@ export default function MapVotePanel({ match }: Props) { try { await postAdminEdit(next) await load() - } catch (e: any) { + } catch (e: unknown) { setAdminEditMode(v => !v) - alert(e?.message ?? 'Fehler beim Umschalten des Admin-Edits') + alert(e instanceof Error ? e.message : 'Fehler beim Umschalten des Admin-Edits') } }} > @@ -825,7 +816,7 @@ export default function MapVotePanel({ match }: Props) { try { const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' }) if (!r.ok) { - const j = await r.json().catch(() => ({})) + const j = await r.json().catch(() => ({} as { message?: string })) alert(j.message ?? 'Reset fehlgeschlagen') return } @@ -866,7 +857,9 @@ export default function MapVotePanel({ match }: Props) { 🔒 Admin-Edit aktiv – Voting pausiert {(() => { const all: Array<{ steamId: string; name?: string | null }> = [] - const pushMaybe = (x: any) => { if (x?.steamId) all.push({ steamId: x.steamId, name: x.name }) } + const pushMaybe = (x: { steamId?: string | null; name?: string | null } | null | undefined) => { + if (x?.steamId) all.push({ steamId: x.steamId, name: x.name }) + } pushMaybe(state?.teams?.teamA?.leader) pushMaybe(state?.teams?.teamB?.leader) ;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe) @@ -967,7 +960,7 @@ export default function MapVotePanel({ match }: Props) { ].join(' ')} >
- {teamLeft?.name router.push(`/profile/${p.user.steamId}`)} isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId} - isActiveTurn={leftIsActiveTurn} /> ))}
@@ -1027,17 +1019,6 @@ export default function MapVotePanel({ match }: Props) { const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}` const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled - // DECIDER-Chooser (letztes Ban davor) - const steps = state?.steps ?? [] - const decIdx = steps.findIndex(s => s.action === 'decider') - let deciderChooserTeamId: string | null = null - if (decIdx >= 0) { - for (let i = decIdx - 1; i >= 0; i--) { - const s = steps[i] - if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break } - } - } - const effectiveTeamId = status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null @@ -1056,7 +1037,7 @@ export default function MapVotePanel({ match }: Props) { return (
  • {pickedByLeft ? ( - {teamLeft?.name onHoldStart(map, isAvailable)} onMouseUp={() => cancelOrSubmitIfComplete(map)} onMouseLeave={() => cancelOrSubmitIfComplete(map)} - onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }} - onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }} - onTouchCancel={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }} + onTouchStart={onTouchStart(map, isAvailable)} + onTouchEnd={onTouchEnd(map)} + onTouchCancel={onTouchEnd(map)} >
    {showProgress && ( @@ -1117,7 +1098,7 @@ export default function MapVotePanel({ match }: Props) { {pickedByRight ? ( - {teamRight?.name
    {teamRight?.name ?? 'Team'}
    - {teamRight?.name router.push(`/profile/${p.user.steamId}`)} isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId} - isActiveTurn={rightIsActiveTurn} /> ))}
  • @@ -1279,7 +1259,7 @@ export default function MapVotePanel({ match }: Props) { > {/* Hintergrundbild */} {bg && ( - {label} {mapLogo ? ( <> - {label} void } @@ -25,7 +24,6 @@ export default function MapVoteProfileCard({ avatar, rank = 0, isLeader = false, - isActiveTurn = false, onClick, }: Props) { const isRight = side === 'B' diff --git a/src/app/[locale]/components/MatchDetails.tsx b/src/app/[locale]/components/MatchDetails.tsx index 8d32de0..2088f91 100644 --- a/src/app/[locale]/components/MatchDetails.tsx +++ b/src/app/[locale]/components/MatchDetails.tsx @@ -5,8 +5,6 @@ import { useState, useEffect, useMemo, useRef } from 'react' import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' -import { format } from 'date-fns' -import { de } from 'date-fns/locale' import Table from './Table' import PremierRankBadge from './PremierRankBadge' import CompRankBadge from './CompRankBadge' @@ -34,17 +32,6 @@ const KPR_CAP = 1.2 const APR_CAP = 0.6 const clamp01 = (v: number) => Math.max(0, Math.min(1, v)) -type ApiStats = { - stats: Array<{ - date: string - kills: number - deaths: number - assists?: number | null - totalDamage?: number | null - rounds?: number | null - }> -} - type PrefetchedFaceit = { level: number|null elo: number|null @@ -214,21 +201,22 @@ function hasVacBan(p: MatchPlayer): boolean { return !!(b?.vacBanned || (b?.numberOfVACBans ?? 0) > 0) } -type VoteAction = 'BAN' | 'PICK' | 'DECIDER' -type VoteStep = { order: number; action: VoteAction; map?: string | null } - // 1) Normalisieren: String → "de_mirage" const norm = (m?: unknown): string => { if (!m) return '' - if (typeof m === 'string') return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '') - if (typeof m === 'object') { + if (typeof m === 'string') { + return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '') + } + if (typeof m === 'object' && m) { const o = m as Record - // häufige Felder, die den Mapkey tragen: - const cand = - o.key ?? o.map ?? o.name ?? o.id ?? (o as any)?.value ?? (o as any)?.slug - return typeof cand === 'string' - ? cand.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '') - : '' + const candidates: unknown[] = [ + o['key'], o['map'], o['name'], o['id'], o['value'], o['slug'] + ] + for (const c of candidates) { + if (typeof c === 'string') { + return c.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '') + } + } } return '' } @@ -246,27 +234,33 @@ const mapLabelFromKey = (key?: string) => { // 3) Final-Maps extrahieren – deckt Strings/Objekte & verschiedene Felder ab function extractSeriesMaps(match: Match, bestOf: number): string[] { const n = Math.max(1, bestOf) + + // a) Steps (nur PICK/DECIDER) + const stepsRaw = Array.isArray(match.mapVote?.steps) ? match.mapVote!.steps as unknown[] : [] + type StepRec = { action?: unknown; order?: unknown; map?: unknown } + const steps = stepsRaw + .map(s => (typeof s === 'object' && s ? s as StepRec : {})) + .filter(s => s.action === 'PICK' || s.action === 'DECIDER') + .sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0)) + .map(s => s.map) - // a) klassische Steps (nur PICK/DECIDER) - const fromSteps: unknown[] = - (match.mapVote?.steps ?? []) - .filter((s: any) => s && (s.action === 'PICK' || s.action === 'DECIDER')) - .sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0)) - .map((s: any) => s.map) + // b) Ergebnis-Felder + const mv = match.mapVote as unknown as Record | undefined + const mvResult = (mv?.['result'] && typeof mv['result'] === 'object') ? mv['result'] as Record : undefined + const mvFinal = (mv?.['final'] && typeof mv['final'] === 'object') ? mv['final'] as Record : undefined - // b) häufige Ergebnis-Felder - const mv: any = match.mapVote ?? {} - const candidates: unknown[] = [ - mv.result?.maps, - mv.result?.picks, // [{map: '...'}] / [{key:'...'}] - mv.result?.series, // ['de_mirage', ...] oder [{key:...}] - mv.final?.maps, - (match as any)?.series?.maps, - (match as any)?.maps, - ].flat().filter(Boolean) + const getArr = (v: unknown): unknown[] => Array.isArray(v) ? v : [] + const picks = getArr(mvResult?.['picks']).map(x => (typeof x === 'object' && x ? (x as Record)['map'] ?? (x as Record)['key'] : x)) + const series = getArr(mvResult?.['series']) + const resMaps = getArr(mvResult?.['maps']) + const finMaps = getArr(mvFinal?.['maps']) + + const more: unknown[] = [ + ...resMaps, ...picks, ...series, ...finMaps + ] // c) flach ziehen + normalisieren + entduplizieren (stabile Reihenfolge) - const chain = [...fromSteps, ...candidates] + const chain = [...steps, ...more] .map(norm) .filter(Boolean) @@ -388,14 +382,14 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu const [playerFaceits, setPlayerFaceits] = useState>({}) // ⬇️ bestOf nur im State halten: community → 3 (oder was du magst), sonst 1 - const [bestOf, setBestOf] = useState<1 | 3 | 5>(() => + const [bestOf] = useState<1 | 3 | 5>(() => match.matchType === 'community' ? 3 : 1 ) // Alle Maps der Serie (BO3/BO5) – abhängig von bestOf-State const allMaps = useMemo( () => extractSeriesMaps(match, bestOf), - [match.mapVote?.steps, (match as any)?.mapVote?.result?.maps, match.map, bestOf] + [match, bestOf] ) const [activeMapIdx, setActiveMapIdx] = useState(0) @@ -427,6 +421,15 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? [] const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? [] + const isRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null + + const getString = (v: unknown): string | undefined => + typeof v === 'string' ? v : undefined + + const getNumber = (v: unknown): number | undefined => + typeof v === 'number' ? v : undefined + // → Welche Seite ist "mein Team"? const mySteamId = session?.user?.steamId const mySide: 'A' | 'B' | null = mySteamId @@ -472,8 +475,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu })() return () => { cancelled = true } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [match.teamA?.players, match.teamB?.players]) + }, [match.teamA?.players, match.teamB?.players, playerFaceits, playerSummaries]) // beim mount user-tz aus DB laden useEffect(() => { @@ -487,7 +489,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu const sp = new URLSearchParams(window.location.search) const m = Number(sp.get('m')) if (Number.isFinite(m) && m >= 0 && m < allMaps.length) setActiveMapIdx(m) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [allMaps.length]) const setActive = (idx: number) => { @@ -519,7 +520,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu const fromVote = extractSeriesMaps(match, bestOf) const n = Math.max(1, bestOf) return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({length: n - fromVote.length}, () => '')] - }, [bestOf, match.mapVote?.steps?.length]) + }, [match, bestOf]) // Ticker für Mapvote-Zeitfenster useEffect(() => { @@ -541,7 +542,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu ? leadOverride : (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60) return matchBaseTs - lead * 60_000 - }, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride]) + }, [opensAtOverride, match.mapVote, matchBaseTs, leadOverride]) const sseOpensAtTs = voteOpensAtTs const sseLeadMinutes = leadOverride @@ -554,26 +555,41 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu // SSE-Listener (nur relevante Events) useEffect(() => { if (!lastEvent) return - const outer = lastEvent as any - const maybeInner = outer?.payload - const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner) - ? maybeInner - : outer - const type = base?.type - const evt = base?.payload ?? base - if (!evt?.matchId || evt.matchId !== match.id) return + const outer = lastEvent as unknown - const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}` + // evtl. „doppelt“ verschachteltes Event-Format abrollen + const maybeInner = isRecord(outer) && 'payload' in outer ? (outer as Record).payload : undefined + const base = isRecord(maybeInner) && 'type' in maybeInner && 'payload' in maybeInner + ? (maybeInner as Record) + : (isRecord(outer) ? (outer as Record) : {}) + + // Typ & Payload sicher lesen + const type = getString(base.type) + const payloadRaw = 'payload' in base ? (base as Record).payload : base + const evt = isRecord(payloadRaw) ? payloadRaw : {} + + const evtMatchId = getString(evt.matchId) + if (!evtMatchId || evtMatchId !== match.id) return + + const opensAtRaw = evt.opensAt + const leadMinutesRaw = evt.leadMinutes + + const key = `${type ?? ''}|${evtMatchId}|${opensAtRaw ?? ''}|${ + Number.isFinite(leadMinutesRaw as number) ? leadMinutesRaw : '' + }` if (key === lastHandledKeyRef.current) return lastHandledKeyRef.current = key if (type === 'map-vote-updated') { - if (evt?.opensAt) setOpensAtOverride(new Date(evt.opensAt).getTime()) - if (Number.isFinite(evt?.leadMinutes)) { - const lead = Number(evt.leadMinutes) + const opensAt = opensAtRaw + if (opensAt) setOpensAtOverride(new Date(opensAt as string | number | Date).getTime()) + + const leadNum = getNumber(leadMinutesRaw) + if (Number.isFinite(leadNum)) { + const lead = leadNum as number setLeadOverride(lead) - if (!evt?.opensAt) { + if (!opensAt) { const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() setOpensAtOverride(baseTs - lead * 60_000) } @@ -583,7 +599,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu } const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated']) - if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) router.refresh() + if (type && REFRESH_TYPES.has(type)) router.refresh() }, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow]) useEffect(() => { @@ -600,7 +616,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu const url = `${window.location.pathname}?${sp.toString()}${window.location.hash}` window.history.replaceState(null, '', url) } - }, [currentMapKey, allMaps, bestOf, isPickBanPhase]) // ← Dependencies + }, [currentMapKey, allMaps, bestOf, isPickBanPhase, activeMapIdx]) // Löschen const handleDelete = async () => { @@ -896,7 +912,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
    {isCommunity && match.teamA?.logo && ( - {match.teamA.name
    {isCommunity && match.teamB?.logo && ( - {match.teamB.name { // Nicht schließen, wenn entweder Anchor ODER die Card selbst gerade gehovert ist const anchorHovered = !!anchorEl?.matches(':hover') diff --git a/src/app/[locale]/components/MatchPlayerCard.tsx b/src/app/[locale]/components/MatchPlayerCard.tsx index 0dc2484..89e98f4 100644 --- a/src/app/[locale]/components/MatchPlayerCard.tsx +++ b/src/app/[locale]/components/MatchPlayerCard.tsx @@ -1,3 +1,5 @@ +// /src/app/[locale]/components/MatchPlayerCard.tsx + import Table from './Table' import Image from 'next/image' import { MatchPlayer } from '../../../types/match' @@ -6,7 +8,32 @@ type Props = { player: MatchPlayer } +/** Safeties für optionale/lockere Stats-Felder */ +type StatsShape = { + adr?: unknown + hsPercent?: unknown +} + +function getAdr(stats: unknown): number | null { + if (stats && typeof stats === 'object') { + const v = (stats as StatsShape).adr + return typeof v === 'number' ? v : null + } + return null +} + +function getHsPercent(stats: unknown): number | null { + if (stats && typeof stats === 'object') { + const v = (stats as StatsShape).hsPercent + return typeof v === 'number' ? v : null + } + return null +} + export default function MatchPlayerCard({ player }: Props) { + const adr = getAdr(player.stats) + const hsPercent = getHsPercent(player.stats) + return ( @@ -26,8 +53,9 @@ export default function MatchPlayerCard({ player }: Props) { {player.stats?.kills ?? '-'} {player.stats?.deaths ?? '-'} {player.stats?.assists ?? '-'} - {(player.stats as any)?.adr ?? '-'} - {(player.stats as any)?.adr ?? '-'} + {adr ?? '-'} + {/* Falls du kein HS% hast, kannst du diese Spalte auch entfernen oder nochmals ADR zeigen */} + {hsPercent ?? '-'}
    {typeof onPromote === 'function' && (
    @@ -166,14 +186,6 @@ export default function MiniCard({ ) : ( )} - - { /* - {location ? ( - - ) : ( - 🌐 - )} - */ }
    ) diff --git a/src/app/[locale]/components/MiniCardDummy.tsx b/src/app/[locale]/components/MiniCardDummy.tsx index 4a8cd04..114e938 100644 --- a/src/app/[locale]/components/MiniCardDummy.tsx +++ b/src/app/[locale]/components/MiniCardDummy.tsx @@ -2,6 +2,7 @@ 'use client' import { useDroppable } from "@dnd-kit/core" +import Image from "next/image" type MiniCardDummyProps = { title: string @@ -11,7 +12,7 @@ type MiniCardDummyProps = { } export default function MiniCardDummy({ title, onClick, children, zoneId }: MiniCardDummyProps) { - const { setNodeRef, isOver } = useDroppable({ + const { setNodeRef } = useDroppable({ id: `${zoneId ?? 'dummy'}-drop`, data: { containerId: zoneId, role: 'dummy' }, // ⬅️ wichtig für Zone-Highlight }) @@ -32,7 +33,7 @@ export default function MiniCardDummy({ title, onClick, children, zoneId }: Mini {children ? ( children ) : ( - Dummy Avatar void prefetchedSummary?: PlayerSummary | null - prefetchedFaceit?: { level: number|null; elo: number|null; nickname: string|null; url: string|null } | null + prefetchedFaceit?: { + level: number | null + elo: number | null + nickname: string | null + url: string | null + } | null anchorEl?: HTMLElement | null onCardMount?: (el: HTMLDivElement | null) => void } +type BanStatus = { + vacBanned?: boolean | null + numberOfVACBans?: number | null + numberOfGameBans?: number | null + communityBanned?: boolean | null + economyBan?: string | null + daysSinceLastBan?: number | null +} + type UserWithFaceit = { steamId?: string | null name?: string | null avatar?: string | null premierRank?: number | null + // flache Ban-Felder (manche APIs liefern das so) vacBanned?: boolean | null numberOfVACBans?: number | null numberOfGameBans?: number | null communityBanned?: boolean | null economyBan?: string | null daysSinceLastBan?: number | null + // oder geschachtelt: + banStatus?: BanStatus + // Faceit faceitNickname?: string | null faceitUrl?: string | null faceitLevel?: number | null @@ -56,10 +81,17 @@ export type PlayerSummary = { } function Sparkline({ values }: { values: number[] }) { - const W = 200, H = 40, pad = 6, n = Math.max(1, values.length) - const max = Math.max(...values, 1), min = Math.min(...values, 0), range = Math.max(0.05, max - min) + const W = 200, + H = 40, + pad = 6, + n = Math.max(1, values.length) + const max = Math.max(...values, 1), + min = Math.min(...values, 0), + range = Math.max(0.05, max - min) const step = (W - pad * 2) / Math.max(1, n - 1) - const pts = values.map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`).join(' ') + const pts = values + .map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`) + .join(' ') return ( @@ -68,12 +100,22 @@ function Sparkline({ values }: { values: number[] }) { } export default function MiniPlayerCard({ - open, player, anchor, onClose, prefetchedSummary, prefetchedFaceit, anchorEl, onCardMount + open, + player, + onClose, + prefetchedSummary, + prefetchedFaceit, + anchorEl, + onCardMount, }: MiniPlayerCardProps) { const router = useRouter() const cardRef = useRef(null) - const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({ top: 0, left: 0, side: 'right' }) + const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({ + top: 0, + left: 0, + side: 'right', + }) const [measured, setMeasured] = useState(false) // Hover-Intent @@ -88,14 +130,19 @@ export default function MiniPlayerCard({ // Summary nur aus Prefetch (kein Fetch) const [summary, setSummary] = useState(prefetchedSummary ?? null) - useEffect(() => { setSummary(prefetchedSummary ?? null) }, [prefetchedSummary, player.user?.steamId]) + useEffect(() => { + setSummary(prefetchedSummary ?? null) + }, [prefetchedSummary, player.user?.steamId]) // FACEIT aus Prefetch / user-Fallback const faceit = useMemo(() => { const url = - prefetchedFaceit?.url - ?? (u.faceitUrl ? u.faceitUrl.replace('{lang}', 'en') - : (u.faceitNickname ? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}` : null)) + prefetchedFaceit?.url ?? + (u.faceitUrl + ? u.faceitUrl.replace('{lang}', 'en') + : u.faceitNickname + ? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}` + : null) return { level: prefetchedFaceit?.level ?? u.faceitLevel ?? null, @@ -111,11 +158,12 @@ export default function MiniPlayerCard({ } // Positionierung - const doPosition = () => { + const doPosition = useCallback(() => { if (!cardRef.current || !anchorEl) return const a = anchorEl.getBoundingClientRect() const cardEl = cardRef.current - const vw = window.innerWidth, vh = window.innerHeight + const vw = window.innerWidth, + vh = window.innerHeight const { width: cw, height: ch } = cardEl.getBoundingClientRect() const rightLeft = a.right @@ -130,13 +178,18 @@ export default function MiniPlayerCard({ setPos({ top: Math.round(top), left: Math.round(left), side }) setMeasured(true) - } + }, [anchorEl]) - const schedule = () => requestAnimationFrame(() => requestAnimationFrame(doPosition)) + const schedule = useCallback(() => { + requestAnimationFrame(() => requestAnimationFrame(doPosition)) + }, [doPosition]) useLayoutEffect(() => { - if (open) { setMeasured(false); schedule() } - }, [open, anchorEl]) + if (open) { + setMeasured(false) + schedule() + } + }, [open, anchorEl, schedule]) useEffect(() => { if (!open) return @@ -155,7 +208,7 @@ export default function MiniPlayerCard({ window.removeEventListener('resize', onScrollOrResize) ro?.disconnect() } - }, [open, anchorEl]) + }, [open, anchorEl, schedule]) // Bei Spielerwechsel sanft neu einmessen useEffect(() => { @@ -166,7 +219,7 @@ export default function MiniPlayerCard({ } setMeasured(false) schedule() - }, [open, anchorEl, player.user?.steamId]) + }, [open, anchorEl, player.user?.steamId, schedule]) // Anchor-Hover steuert Open/Close useEffect(() => { @@ -174,21 +227,32 @@ export default function MiniPlayerCard({ const armClose = () => { if (!closeT.current) { - closeT.current = window.setTimeout(() => { closeT.current = null; onClose?.() }, CLOSE_DELAY) + closeT.current = window.setTimeout(() => { + closeT.current = null + onClose?.() + }, CLOSE_DELAY) } } const disarmClose = () => { - if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null } + if (closeT.current) { + window.clearTimeout(closeT.current) + closeT.current = null + } } const onEnter = () => { disarmClose() if (!open && !openT.current) { - openT.current = window.setTimeout(() => { openT.current = null }, OPEN_DELAY) + openT.current = window.setTimeout(() => { + openT.current = null + }, OPEN_DELAY) } } const onLeave = () => { - if (openT.current) { window.clearTimeout(openT.current); openT.current = null } + if (openT.current) { + window.clearTimeout(openT.current) + openT.current = null + } armClose() } @@ -205,24 +269,31 @@ export default function MiniPlayerCard({ } }, [anchorEl, open, onClose]) - // BAN-Badges - const nestedBan = (player.user as any)?.banStatus + // BAN-Badges (verschachtelt oder flach) + const nestedBan: BanStatus | undefined = + (player.user as { banStatus?: BanStatus } | undefined)?.banStatus const flat = u const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0) - const isBannedNested = - !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 || - (nestedBan?.numberOfGameBans ?? 0) > 0 || nestedBan?.communityBanned || - (nestedBan?.economyBan && nestedBan.economyBan !== 'none')) + const isBannedNested = !!( + nestedBan?.vacBanned || + (nestedBan?.numberOfVACBans ?? 0) > 0 || + (nestedBan?.numberOfGameBans ?? 0) > 0 || + nestedBan?.communityBanned || + (nestedBan?.economyBan && nestedBan.economyBan !== 'none') + ) const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 const isBannedFlat = - !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 || (flat.numberOfGameBans ?? 0) > 0 || - !!flat.communityBanned || (!!flat.economyBan && flat.economyBan !== 'none') + !!flat.vacBanned || + (flat.numberOfVACBans ?? 0) > 0 || + (flat.numberOfGameBans ?? 0) > 0 || + !!flat.communityBanned || + (!!flat.economyBan && flat.economyBan !== 'none') const hasVac = nestedBan ? hasVacNested : hasVacFlat const isBanned = nestedBan ? isBannedNested : isBannedFlat const banTooltip = useMemo(() => { const parts: string[] = [] - const src = nestedBan ?? flat + const src = (nestedBan ?? flat) as Required if (src.vacBanned) parts.push('VAC-Ban aktiv') if ((src.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${src.numberOfVACBans}`) if ((src.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${src.numberOfGameBans}`) @@ -244,10 +315,18 @@ export default function MiniPlayerCard({ tabIndex={-1} className="pointer-events-auto fixed z-[10000] w-[320px] rounded-lg border border-white/10 bg-neutral-900/95 p-3 text-white shadow-2xl backdrop-blur transition-opacity duration-100" style={{ top: pos.top, left: pos.left, opacity: measured ? 1 : 0 }} - onMouseEnter={() => { if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null } }} + onMouseEnter={() => { + if (closeT.current) { + window.clearTimeout(closeT.current) + closeT.current = null + } + }} onMouseLeave={() => { if (!closeT.current) { - closeT.current = window.setTimeout(() => { closeT.current = null; onClose?.() }, CLOSE_DELAY) + closeT.current = window.setTimeout(() => { + closeT.current = null + onClose?.() + }, CLOSE_DELAY) } }} > @@ -263,7 +342,9 @@ export default function MiniPlayerCard({ {/* Header mit Links rechts */}
    { steam64 ? router.push(`/profile/${steam64}`) : null }} + onClick={() => { + if (steam64) router.push(`/profile/${steam64}`) + }} className="flex cursor-pointer transition bg-white dark:bg-neutral-800 dark:border-neutral-700 hover:bg-neutral-200 hover:dark:bg-neutral-700 items-center justify-between mb-2 rounded-md bg-white/5 ring-1 ring-white/10 px-2 py-2" > {/* Links: Avatar + Name + Badges */} @@ -276,11 +357,9 @@ export default function MiniPlayerCard({ />
    - {/* Name + BAN/VAC direkt daneben (wie MatchDetails) */} + {/* Name + BAN/VAC direkt daneben */}
    - - {u.name ?? 'Unbekannt'} - + {u.name ?? 'Unbekannt'} {isBanned && ( - {/* darunter: Premier + Faceit (unverändert) */} + {/* darunter: Premier + Faceit */}
    {faceit.nickname && } @@ -323,7 +402,7 @@ export default function MiniPlayerCard({ title={`Faceit-Profil${faceit.nickname ? ` von ${faceit.nickname}` : ''}`} className="inline-flex size-8 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10" > - + )}
    @@ -346,10 +425,16 @@ export default function MiniPlayerCard({
    0 ? 'text-emerald-300' : summary.perfDelta < 0 ? 'text-rose-300' : 'text-neutral-300', + summary.perfDelta > 0 + ? 'text-emerald-300' + : summary.perfDelta < 0 + ? 'text-rose-300' + : 'text-neutral-300', ].join(' ')} > - {summary.perfDelta === 0 ? '±0.00' : `${summary.perfDelta > 0 ? '+' : ''}${summary.perfDelta.toFixed(2)}`} + {summary.perfDelta === 0 + ? '±0.00' + : `${summary.perfDelta > 0 ? '+' : ''}${summary.perfDelta.toFixed(2)}`}
    diff --git a/src/app/[locale]/components/Modal.tsx b/src/app/[locale]/components/Modal.tsx index 61b4889..2e14b0d 100644 --- a/src/app/[locale]/components/Modal.tsx +++ b/src/app/[locale]/components/Modal.tsx @@ -1,4 +1,4 @@ -// /src/app/components/Modal.tsx +// /src/app/[locale]/components/Modal.tsx 'use client' import { useEffect } from 'react' @@ -29,6 +29,19 @@ type ModalProps = { scrollBody?: boolean } +/* ───────── HSOverlay-Hilfstypen (nur lokal verwendet) ───────── */ +type HSOverlayInstance = { + element: HTMLElement + destroy?: () => void +} + +type HSOverlayAPI = { + collection?: HSOverlayInstance[] + autoInit?: () => void + open?: (el: HTMLElement) => void + close?: (el: HTMLElement) => void +} + export default function Modal({ id, title, @@ -44,11 +57,16 @@ export default function Modal({ scrollBody = true, }: ModalProps) { useEffect(() => { - const modalEl = document.getElementById(id) - const hs = (window as any).HSOverlay + const modalEl = document.getElementById(id) as HTMLElement | null + + // Kollisionsfrei zum globalen Typ: wir lesen Window.HSOverlay und erweitern ihn lokal + type HSOverlayBase = { open?: (el: HTMLElement) => void; close?: (el: HTMLElement) => void } + const hs = + (window as unknown as { HSOverlay?: HSOverlayBase & HSOverlayAPI }).HSOverlay + if (!modalEl || !hs) return - const getCollection = (): any[] => + const getCollection = (): HSOverlayInstance[] => Array.isArray(hs.collection) ? hs.collection : [] const destroyIfExists = () => { @@ -60,7 +78,7 @@ export default function Modal({ } const handleClose = () => onClose?.() - modalEl.addEventListener('hsOverlay:close', handleClose) + modalEl.addEventListener('hsOverlay:close', handleClose as EventListener) try { if (show) { @@ -71,14 +89,16 @@ export default function Modal({ hs.close?.(modalEl) destroyIfExists() } - } catch (err) {} + } catch { + // noop + } return () => { - modalEl.removeEventListener('hsOverlay:close', handleClose) + modalEl.removeEventListener('hsOverlay:close', handleClose as EventListener) destroyIfExists() // Fallback: Globale Backdrops wegräumen, falls die Lib zickt - document.querySelectorAll('.hs-overlay-backdrop')?.forEach(el => el.remove()) - document.body.classList.remove('overflow-hidden','[&.hs-overlay-open]') // je nach Lib-Version + document.querySelectorAll('.hs-overlay-backdrop')?.forEach((el) => el.remove()) + document.body.classList.remove('overflow-hidden', '[&.hs-overlay-open]') } }, [show, id, onClose]) @@ -93,8 +113,8 @@ export default function Modal({ if (e.target === e.currentTarget) onClose?.() }} className={ - "hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden " + - (show ? "" : "pointer-events-none") + 'hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden ' + + (show ? '' : 'pointer-events-none') } > {/* Backdrop */} @@ -140,7 +160,7 @@ export default function Modal({
    {!hideCloseButton && (
    diff --git a/src/app/[locale]/components/settings/account/AuthCodeSettings.tsx b/src/app/[locale]/components/settings/account/AuthCodeSettings.tsx index 400694d..bb1533e 100644 --- a/src/app/[locale]/components/settings/account/AuthCodeSettings.tsx +++ b/src/app/[locale]/components/settings/account/AuthCodeSettings.tsx @@ -2,11 +2,11 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import Link from 'next/link' import Popover from '../../Popover' import Button from '../../Button' -import { useTranslations, useLocale } from 'next-intl' +import { useTranslations } from 'next-intl' export default function AuthCodeSettings() { const [authCode, setAuthCode] = useState('') diff --git a/src/app/[locale]/components/settings/account/ShareCodeSettings.tsx b/src/app/[locale]/components/settings/account/ShareCodeSettings.tsx index 36f0921..96434b7 100644 --- a/src/app/[locale]/components/settings/account/ShareCodeSettings.tsx +++ b/src/app/[locale]/components/settings/account/ShareCodeSettings.tsx @@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react' import Link from 'next/link' import Popover from '../../Popover' import Button from '../../Button' -import { useTranslations, useLocale } from 'next-intl' +import { useTranslations } from 'next-intl' export default function LatestKnownCodeSettings() { const [lastKnownShareCode, setLastKnownShareCode] = useState('') diff --git a/src/app/[locale]/components/settings/account/UserSettings.tsx b/src/app/[locale]/components/settings/account/UserSettings.tsx index 651eb47..558aae8 100644 --- a/src/app/[locale]/components/settings/account/UserSettings.tsx +++ b/src/app/[locale]/components/settings/account/UserSettings.tsx @@ -1,10 +1,9 @@ 'use client' -import {useEffect, useMemo, useRef, useState} from 'react' -import Popover from '../../Popover' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslations } from 'next-intl' -// Versuche, native Liste zu nehmen; fallback auf gängige Auswahl. +// Fallback-Liste für Umgebungen ohne Intl.supportedValuesOf const FALLBACK_TIMEZONES = [ 'UTC', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Zurich', @@ -14,16 +13,21 @@ const FALLBACK_TIMEZONES = [ 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Sao_Paulo', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Kolkata', - 'Australia/Sydney' + 'Australia/Sydney', ] function getTimeZones(): string[] { - // @ts-ignore – Node/Browser, je nach Runtime verfügbar - if (typeof Intl.supportedValuesOf === 'function') { + // typ-sicher prüfen, ohne ts-ignore + const maybeSupportedValuesOf = (Intl as unknown as { supportedValuesOf?: (k: string) => unknown[] }).supportedValuesOf + if (typeof maybeSupportedValuesOf === 'function') { try { - // @ts-ignore - return Intl.supportedValuesOf('timeZone') as string[] - } catch {} + const list = maybeSupportedValuesOf('timeZone') + if (Array.isArray(list) && list.every((z) => typeof z === 'string')) { + return list as string[] + } + } catch { + // ignore and fall back + } } return FALLBACK_TIMEZONES } @@ -50,7 +54,7 @@ export default function UserSettings() { try { const res = await fetch('/api/user/timezone', { cache: 'no-store' }) const data = await res.json().catch(() => ({})) - const tz = data?.timeZone ?? null + const tz = (data as { timeZone?: string })?.timeZone ?? null setTimeZone(tz) setInitialTz(tz) } catch (e) { @@ -61,9 +65,8 @@ export default function UserSettings() { })() }, []) - // Helper: Speichern (mit Abort bei schnellen Wechseln) + // Speichern (mit Abort bei schnellen Wechseln) const persist = async (tz: string | null) => { - // laufende Anfrage abbrechen inFlight.current?.abort() const ctrl = new AbortController() inFlight.current = ctrl @@ -77,22 +80,23 @@ export default function UserSettings() { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timeZone: tz }), - signal: ctrl.signal + signal: ctrl.signal, }) if (!res.ok) { const j = await res.json().catch(() => ({})) - throw new Error(j?.message || `HTTP ${res.status}`) + throw new Error((j as { message?: string })?.message || `HTTP ${res.status}`) } setInitialTz(tz) setSavedOk(true) - setTouched(false) // <- hinzu - // kleines Auto-Reset des „Gespeichert“-Hinweises + setTouched(false) window.setTimeout(() => setSavedOk(null), 2000) - } catch (e: any) { - if (e?.name === 'AbortError') return + } catch (e: unknown) { + // Abort sauber behandeln + if (e instanceof DOMException && e.name === 'AbortError') return + const msg = e instanceof Error ? e.message : 'Save failed' console.error('[UserSettings] Speichern fehlgeschlagen:', e) setSavedOk(false) - setErrorMsg(e?.message ?? 'Save failed') + setErrorMsg(msg) } finally { setSaving(false) } @@ -105,7 +109,7 @@ export default function UserSettings() { if (debounceTimer.current) window.clearTimeout(debounceTimer.current) debounceTimer.current = window.setTimeout(() => { - persist(timeZone ?? null) + void persist(timeZone ?? null) }, 400) as unknown as number return () => { @@ -116,34 +120,29 @@ export default function UserSettings() { return (
    - {/* Label + Hilfe */}
    - {/* Eingabe */}
    - {/* Live-Status rechts (optional) */} {saving && {tCommon('saving')}…} {savedOk === true && ✓ {tCommon('saved')}} diff --git a/src/app/[locale]/components/settings/privacy/PrivacySettings.tsx b/src/app/[locale]/components/settings/privacy/PrivacySettings.tsx index 4ec0598..2b16c7a 100644 --- a/src/app/[locale]/components/settings/privacy/PrivacySettings.tsx +++ b/src/app/[locale]/components/settings/privacy/PrivacySettings.tsx @@ -1,7 +1,7 @@ -// app/[locale]/settings/_components/PrivacySettings.tsx +// /src/app/[locale]/components/settings/privacy/PrivacySettings.tsx 'use client' -import {useEffect, useRef, useState} from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslations } from 'next-intl' export default function PrivacySettings() { @@ -26,7 +26,10 @@ export default function PrivacySettings() { try { const res = await fetch('/api/user/privacy', { cache: 'no-store' }) const data = await res.json().catch(() => ({})) - const value = typeof data?.canBeInvited === 'boolean' ? data.canBeInvited : true + const value = + typeof (data as { canBeInvited?: unknown })?.canBeInvited === 'boolean' + ? (data as { canBeInvited: boolean }).canBeInvited + : true setCanBeInvited(value) setInitial(value) } catch (e) { @@ -52,20 +55,24 @@ export default function PrivacySettings() { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ canBeInvited: value }), - signal: ctrl.signal + signal: ctrl.signal, }) if (!res.ok) { const j = await res.json().catch(() => ({})) - throw new Error(j?.message || `HTTP ${res.status}`) + const msg = + (j as { message?: string })?.message || `HTTP ${res.status}` + throw new Error(msg) } setInitial(value) setSavedOk(true) window.setTimeout(() => setSavedOk(null), 2000) - } catch (e: any) { - if (e?.name === 'AbortError') return + } catch (e: unknown) { + // Abort sauber behandeln + if (e instanceof DOMException && e.name === 'AbortError') return + const msg = e instanceof Error ? e.message : 'Save failed' console.error('[PrivacySettings] save failed:', e) setSavedOk(false) - setErrorMsg(e?.message ?? 'Save failed') + setErrorMsg(msg) } finally { setSaving(false) } @@ -76,7 +83,7 @@ export default function PrivacySettings() { if (canBeInvited === initial) return if (debounceTimer.current) window.clearTimeout(debounceTimer.current) debounceTimer.current = window.setTimeout(() => { - persist(canBeInvited) + void persist(canBeInvited) }, 400) as unknown as number return () => { if (debounceTimer.current) window.clearTimeout(debounceTimer.current) @@ -85,28 +92,23 @@ export default function PrivacySettings() { return (
    - {/* Zeile: alles vertikal mittig */}
    - {/* Label-Spalte */}
    {tSettings('sections.privacy.invites.label')}
    - {/* Inhalt-Spalte */}
    - {/* Switch + Hilfstext rechts → vertikal mittig */}
    - {/* Toggle */} - {/* Rechts: Hilfs-Text + Status NEBENeinander */}
    - {/* Hilfstext links, darf umbrechen */}

    {tSettings('sections.privacy.invites.help')}

    - {/* Status rechts daneben, bleibt in einer Zeile */}
    {loading && ( @@ -140,9 +139,7 @@ export default function PrivacySettings() { )} {savedOk === true && ( - - ✓ {tCommon('saved') ?? 'Gespeichert'} - + ✓ {tCommon('saved') ?? 'Gespeichert'} )} {savedOk === false && ( @@ -155,7 +152,7 @@ export default function PrivacySettings() {
    -
    +
    ) } diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx deleted file mode 100644 index 0cadc83..0000000 --- a/src/app/[locale]/dashboard/page.tsx +++ /dev/null @@ -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([]) - 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 ( - <> -

    - {tDashboard('title')} -

    - - {/* Beispiel: Teams anzeigen (optional) */} - {/*
    {JSON.stringify(teams, null, 2)}
    */} - - ) -} diff --git a/src/app/[locale]/match-details/[matchId]/layout.tsx b/src/app/[locale]/match-details/[matchId]/layout.tsx index 3372fbf..17e60a4 100644 --- a/src/app/[locale]/match-details/[matchId]/layout.tsx +++ b/src/app/[locale]/match-details/[matchId]/layout.tsx @@ -15,7 +15,7 @@ async function loadMatch(matchId: string): Promise { const base = host ? `${proto}://${host}` : (process.env.NEXTAUTH_URL ?? 'http://localhost:3000') const insecure = new Agent({ connect: { rejectUnauthorized: false } }) - const init: any = { cache: 'no-store' } + const init: RequestInit & { dispatcher?: Agent } = { cache: 'no-store' } if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') { init.dispatcher = insecure } diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index f0c0eb8..0d4825a 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,7 +1,242 @@ -export default function Page() { - return ( - <> -

    Home

    - - ); +// /src/app/[locale]/page.tsx +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import Image from 'next/image' +import Link from 'next/link' + +// ---- minimal types (no any) +type TeamLike = { id?: string; name?: string; teamname?: string; logo?: string | null } +type TeamsJson = { teams?: TeamLike[] } | { data?: TeamLike[] } | TeamLike[] | unknown + +function parseTeams(json: TeamsJson): TeamLike[] { + if (Array.isArray(json)) return json as TeamLike[] + if (typeof json === 'object' && json !== null) { + const o = json as Record + 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([]) + + 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 ( +
    + {/* HERO */} +
    +
    +
    +
    +
    + +
    +
    +

    + {t('title')} +

    +

    + Organisiere Matches, lade Mitspieler ein und analysiere Demos – alles an einem Ort. +

    + + {/* CTAs */} +
    + + + + + Team erstellen + + + + + + + Team finden & beitreten + + + + + + + Match planen + +
    +
    + + {/* small visual on the right */} +
    +
    + CS2 +
    +
    +
    +
    + + {/* GRID: Teams + Activity + Promo */} +
    + {/* Your Teams */} +
    +
    +
    +

    Deine Teams

    + + Alle ansehen → + +
    + + {loading ? ( +
    + {Array.from({ length: 6 }).map((_, i) => ( +
    +
    +
    +
    +
    + ))} +
    + ) : teams.length === 0 ? ( +
    + Du hast noch kein Team.{' '} + + Jetzt erstellen + {' '} + oder{' '} + + beitreten + . +
    + ) : ( +
      + {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 ( +
    • +
      +
      + {label} +
      +
      +
      {label}
      +
      Team
      +
      +
      +
      + + Team öffnen → + +
      +
    • + ) + })} +
    + )} +
    + + {/* Recent Activity */} +
    +
    +

    Aktivität

    + + Matches ansehen → + +
    +
    + Hier erscheinen demnächst Match-Updates, Map-Votes & Einladungen in Echtzeit. +
    +
    +
    + + {/* Promo / Feature highlight */} + +
    +
    + ) } diff --git a/src/app/[locale]/profile/[steamId]/ProfileHeader.tsx b/src/app/[locale]/profile/[steamId]/ProfileHeader.tsx index 7cbb58f..c074ef8 100644 --- a/src/app/[locale]/profile/[steamId]/ProfileHeader.tsx +++ b/src/app/[locale]/profile/[steamId]/ProfileHeader.tsx @@ -35,7 +35,6 @@ export default function ProfileHeader({ user: u }: Props) { const showGameBan = (u.numberOfGameBans ?? 0) > 0 const showComm = !!u.communityBanned const showEcon = !!u.economyBan && u.economyBan !== 'none' - const showLastBan = typeof u.daysSinceLastBan === 'number' const hasAnyBan = showVac || showGameBan || showComm || showEcon const hasFaceit = !!u.faceitUrl @@ -60,7 +59,7 @@ export default function ProfileHeader({ user: u }: Props) { aria-label="Faceit-Profil öffnen" title={`Faceit-Profil von ${u.faceitNickname ?? u.name ?? ''}`} className="inline-flex size-9 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10" > - + )}
    diff --git a/src/app/[locale]/profile/page.tsx b/src/app/[locale]/profile/page.tsx index cccde10..bb1b253 100644 --- a/src/app/[locale]/profile/page.tsx +++ b/src/app/[locale]/profile/page.tsx @@ -1,7 +1,6 @@ import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' import { redirect } from 'next/navigation' -import { NextRequest } from 'next/server' export default async function ProfileRedirectPage() { const session = await getServerSession(sessionAuthOptions) diff --git a/src/app/[locale]/schedule/page.tsx b/src/app/[locale]/schedule/page.tsx index fd15328..cc80a3c 100644 --- a/src/app/[locale]/schedule/page.tsx +++ b/src/app/[locale]/schedule/page.tsx @@ -1,11 +1,6 @@ 'use client' -import Link from 'next/link' -import Image from 'next/image' import { useEffect, useState } from 'react' -import { useSession } from 'next-auth/react' -import Switch from '../components/Switch' -import Button from '../components/Button' import CommunityMatchList from '../components/CommunityMatchList' import Card from '../components/Card' @@ -18,9 +13,7 @@ type Match = { } export default function MatchesPage() { - const { data: session } = useSession() - const [matches, setMatches] = useState([]) - const [onlyOwnTeam, setOnlyOwnTeam] = useState(false) + const [, setMatches] = useState([]) useEffect(() => { fetch('/api/schedule') @@ -32,12 +25,6 @@ export default function MatchesPage() { }) }, []) - const filteredMatches = onlyOwnTeam && session?.user?.team - ? matches.filter( - (m) => m.teamA.id === session.user.team || m.teamB.id === session.user.team - ) - : matches - return ( diff --git a/src/app/[locale]/settings/_sections/AccountSection.tsx b/src/app/[locale]/settings/_sections/AccountSection.tsx index a084ffd..e72f856 100644 --- a/src/app/[locale]/settings/_sections/AccountSection.tsx +++ b/src/app/[locale]/settings/_sections/AccountSection.tsx @@ -1,7 +1,5 @@ -// app/[locale]/settings/_sections/AccountSection.tsx +// /src/app/[locale]/settings/_sections/AccountSection.tsx -import { prisma } from '@/lib/prisma' -import { getServerSession } from 'next-auth' import { getTranslations } from 'next-intl/server' import AuthCodeSettings from '../../components/settings/account/AuthCodeSettings' import LatestKnownCodeSettings from '../../components/settings/account/ShareCodeSettings' @@ -9,17 +7,6 @@ import LatestKnownCodeSettings from '../../components/settings/account/ShareCode export default async function AccountSection() { const tSettings = await getTranslations('settings') - // Session laden (passe das an deine authOptions an) - const session = await getServerSession(/* authOptions */) - const steamId = (session as any)?.user?.id ?? null - - const user = steamId - ? await prisma.user.findUnique({ - where: { steamId }, - select: { faceitId: true, faceitNickname: true, faceitAvatar: true }, - }) - : null - return (

    diff --git a/src/app/api/cs2/authcode/route.ts b/src/app/api/cs2/authcode/route.ts index 2e3a21f..709a45f 100644 --- a/src/app/api/cs2/authcode/route.ts +++ b/src/app/api/cs2/authcode/route.ts @@ -6,7 +6,7 @@ import { sessionAuthOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { decrypt, encrypt } from '@/lib/crypto' -export async function GET(req: NextRequest) { +export async function GET() { const session = await getServerSession(sessionAuthOptions) const steamId = session?.user?.steamId diff --git a/src/app/api/cs2/sharecode/route.ts b/src/app/api/cs2/sharecode/route.ts index b5c58e3..fcd6063 100644 --- a/src/app/api/cs2/sharecode/route.ts +++ b/src/app/api/cs2/sharecode/route.ts @@ -7,7 +7,7 @@ import { prisma } from '@/lib/prisma' const EXPIRY_DAYS = 30 -export async function GET(req: NextRequest) { +export async function GET() { const session = await getServerSession(sessionAuthOptions) const steamId = session?.user?.steamId diff --git a/src/app/api/faceit/callback/route.ts b/src/app/api/faceit/callback/route.ts index d055baf..2ebd134 100644 --- a/src/app/api/faceit/callback/route.ts +++ b/src/app/api/faceit/callback/route.ts @@ -50,18 +50,12 @@ export async function GET(req: NextRequest) { const steamId = /* session?.user?.id o.ä. */ null if (!steamId) return NextResponse.redirect('/settings?faceit=no_user') - const expires = token.expires_in ? new Date(Date.now() + token.expires_in * 1000) : null - await prisma.user.update({ where: { steamId }, data: { faceitId: me.guid ?? null, faceitNickname: me.nickname ?? null, faceitAvatar: me.avatar ?? null, - // Tokens nur speichern, wenn nötig: - // faceitAccessToken: token.access_token, - // faceitRefreshToken: token.refresh_token ?? null, - // faceitTokenExpiresAt: expires, }, }) diff --git a/src/app/api/matches/current/route.ts b/src/app/api/matches/current/route.ts index 6605152..0da59f0 100644 --- a/src/app/api/matches/current/route.ts +++ b/src/app/api/matches/current/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' export const runtime = 'nodejs' @@ -42,7 +42,7 @@ async function findCurrentMatch() { return upcoming } -export async function GET(_req: NextRequest) { +export async function GET() { const match = await findCurrentMatch() if (!match) { return NextResponse.json({ matchId: null, steamIds: [], total: 0 }, { headers: { 'Cache-Control': 'no-store' } }) diff --git a/src/app/api/notifications/create/route.ts b/src/app/api/notifications/create/route.ts index 3b8332e..20ea43a 100644 --- a/src/app/api/notifications/create/route.ts +++ b/src/app/api/notifications/create/route.ts @@ -19,7 +19,7 @@ export async function POST(req: NextRequest) { } // ✅ Notifications für aktuellen User laden -export async function GET(_req: NextRequest) { +export async function GET() { const session = await getServerSession(sessionAuthOptions) const meSteamId = (session?.user as { steamId?: string })?.steamId @@ -36,7 +36,7 @@ export async function GET(_req: NextRequest) { } // ✅ Alle Notifications auf "gelesen" setzen -export async function PUT(_req: NextRequest) { +export async function PUT() { const session = await getServerSession(sessionAuthOptions) const meSteamId = (session?.user as { steamId?: string })?.steamId diff --git a/src/app/api/notifications/mark-all-read/route.ts b/src/app/api/notifications/mark-all-read/route.ts index 1473ef3..5440612 100644 --- a/src/app/api/notifications/mark-all-read/route.ts +++ b/src/app/api/notifications/mark-all-read/route.ts @@ -1,13 +1,13 @@ -// app/api/notifications/mark-all-read/route.ts +// /src/app/api/notifications/mark-all-read/route.ts import { prisma } from '@/lib/prisma' import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' -import { NextResponse, type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { sendServerSSEMessage } from '@/lib/sse-server-client' export const dynamic = 'force-dynamic' -export async function POST(req: NextRequest) { +export async function POST() { try { const session = await getServerSession(sessionAuthOptions) const steamId = session?.user?.steamId diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 20d8c64..ac88d51 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -2,9 +2,9 @@ import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' -export async function GET(req: NextRequest) { +export async function GET() { const session = await getServerSession(sessionAuthOptions) if (!session?.user?.steamId) { diff --git a/src/app/api/schedule/route.ts b/src/app/api/schedule/route.ts index ab33e63..9e6e0ca 100644 --- a/src/app/api/schedule/route.ts +++ b/src/app/api/schedule/route.ts @@ -46,7 +46,6 @@ export async function GET() { const formatted = matches.map(m => { const matchDate = m.demoDate ?? - // @ts-ignore – falls du optional noch ein „date“-Feld hast (m as any).date ?? m.createdAt diff --git a/src/app/api/stats/[steamId]/route.ts b/src/app/api/stats/[steamId]/route.ts index 5bdaeeb..9bc37b0 100644 --- a/src/app/api/stats/[steamId]/route.ts +++ b/src/app/api/stats/[steamId]/route.ts @@ -1,13 +1,13 @@ // /src/app/api/stats/[steamId]/route.ts import { NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -import type { AsyncParams } from '@/types/next' // ← nutzt deinen Typ +import type { AsyncParams } from '@/types/next' export async function GET( _req: Request, ctx: AsyncParams<{ steamId: string }> ) { - const { steamId } = await ctx.params // ← params auflösen + const { steamId } = await ctx.params if (!steamId) { return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 }) @@ -59,13 +59,15 @@ export async function GET( orderBy: { match: { demoDate: 'asc' } }, }) + // kleiner Helper ohne `any` + const readRounds = (v: unknown): number | null => { + const r = (v as { rounds?: unknown } | null | undefined)?.rounds + return typeof r === 'number' && Number.isFinite(r) ? r : null + } + const FallbackDate = new Date(0).toISOString().split('T')[0] // "1970-01-01" const stats = matches.map((entry) => { - const rounds = - (entry.stats as any)?.rounds ?? - // (entry.match as any)?.roundCount ?? - null - + const rounds = readRounds(entry.stats /* | { roundCount?: unknown } */) return { date: entry.match?.demoDate?.toISOString().split('T')[0] ?? FallbackDate, kills: entry.stats?.kills ?? 0, diff --git a/src/app/api/steam/profile/route.ts b/src/app/api/steam/profile/route.ts index fbf4b65..39bfe67 100644 --- a/src/app/api/steam/profile/route.ts +++ b/src/app/api/steam/profile/route.ts @@ -1,8 +1,8 @@ import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' -import { NextResponse, type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' -export async function GET(req: NextRequest) { +export async function GET() { const session = await getServerSession(sessionAuthOptions) if (!session || !session.user?.steamId) { diff --git a/src/app/api/team/[teamId]/route.ts b/src/app/api/team/[teamId]/route.ts index bd1ce67..e08a806 100644 --- a/src/app/api/team/[teamId]/route.ts +++ b/src/app/api/team/[teamId]/route.ts @@ -18,8 +18,30 @@ export async function GET( const team = await prisma.team.findUnique({ where: { id: teamId }, include: { - leader: true, - invites: { include: { user: true } }, + leader: { + select: { + steamId: true, + name: true, + avatar: true, + location: true, + premierRank: true, + isAdmin: true, + }, + }, + invites: { + include: { + user: { + select: { + steamId: true, + name: true, + avatar: true, + location: true, + premierRank: true, + isAdmin: true, + }, + }, + }, + }, }, }) @@ -44,7 +66,18 @@ export async function GET( }) : [] - const toPlayer = (u: any): Player => ({ + // 1) Gemeinsamer Typ für alle "User"-Objekte, die wir in Player mappen + type UserLike = { + steamId: string + name: string | null + avatar: string | null + location: string | null + premierRank: number | null + isAdmin: boolean | null + } + + // 2) Ein Helper: Prisma -> Player + const toPlayer = (u: UserLike): Player => ({ steamId: u.steamId, name: u.name ?? 'Unbekannt', avatar: u.avatar ?? '/assets/img/avatars/default.png', diff --git a/src/app/api/team/add-players/route.ts b/src/app/api/team/add-players/route.ts index 3c33a43..7884dbf 100644 --- a/src/app/api/team/add-players/route.ts +++ b/src/app/api/team/add-players/route.ts @@ -30,12 +30,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 }) } - /* ▸ Alle Teammitglieder (alt + neu) als Zielgruppe ------------------------- */ - const allPlayers = [ - ...team.activePlayers, - ...team.inactivePlayers, - ] - /* ▸ SSE-Push --------------------------------------------------------------- */ await sendServerSSEMessage({ type : 'team-member-joined', diff --git a/src/app/api/team/create/route.ts b/src/app/api/team/create/route.ts index 6496619..571c792 100644 --- a/src/app/api/team/create/route.ts +++ b/src/app/api/team/create/route.ts @@ -5,6 +5,12 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client' export const dynamic = 'force-dynamic' +// Minimaler Type-Guard für Prisma KnownRequestError +type KnownPrismaError = { code: string; meta?: Record } +function isKnownPrismaError(e: unknown): e is KnownPrismaError { + return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string' +} + export async function POST(req: NextRequest) { try { const { teamname, leader }: { teamname?: string; leader?: string } = await req.json() @@ -33,13 +39,11 @@ export async function POST(req: NextRequest) { return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 }) } - // user dem Team zuordnen await prisma.user.update({ where: { steamId: leader }, data: { teamId: newTeam.id }, }) - // 🔔 (optional) persistente Notification const note = await prisma.notification.create({ data: { steamId: leader, @@ -50,7 +54,6 @@ export async function POST(req: NextRequest) { }, }) - // ➜ Sofort an Notification-Center await sendServerSSEMessage({ type: 'notification', targetUserIds: [leader], @@ -61,14 +64,12 @@ export async function POST(req: NextRequest) { createdAt: note.createdAt.toISOString(), }) - // ✅ ➜ HIER: Self-Refresh für den Ersteller await sendServerSSEMessage({ - type: 'self-updated', // <— stelle sicher, dass dein Client darauf hört + type: 'self-updated', targetUserIds: [leader], }) } - // (Optional) Broadcasts await sendServerSSEMessage({ type: 'team-created', title: 'Team erstellt', @@ -85,8 +86,8 @@ export async function POST(req: NextRequest) { { message: 'Team erstellt', team: newTeam }, { headers: { 'Cache-Control': 'no-store' } }, ) - } catch (error: any) { - if (error?.code === 'P2002') { + } catch (error: unknown) { + if (isKnownPrismaError(error) && error.code === 'P2002') { return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 }) } console.error('❌ Fehler beim Team erstellen:', error) diff --git a/src/app/api/team/rename/route.ts b/src/app/api/team/rename/route.ts index 9275bf8..11c9f6c 100644 --- a/src/app/api/team/rename/route.ts +++ b/src/app/api/team/rename/route.ts @@ -5,9 +5,26 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client' export const dynamic = 'force-dynamic' +// kleiner Body-Parser für sichere Typen +function parseBody(v: unknown): { teamId?: string; newName?: string } { + if (!v || typeof v !== 'object') return {} + const r = v as Record + 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 } +function isKnownPrismaError(e: unknown): e is KnownPrismaError { + return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string' +} + export async function POST(req: NextRequest) { try { - const { teamId, newName } = await req.json() + const raw = await req.json().catch(() => null) + const { teamId, newName } = parseBody(raw) const name = (newName ?? '').trim() if (!teamId || !name) { @@ -24,19 +41,19 @@ export async function POST(req: NextRequest) { } // Umbenennen (Unique-Constraint beachten) - let updated - try { - updated = await prisma.team.update({ - where: { id: teamId }, - data: { name }, - select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true }, - }) - } catch (e: any) { - if (e?.code === 'P2002') { + const updated = await prisma.team.update({ + where: { id: teamId }, + data: { name }, + select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true }, + }).catch((e: unknown) => { + if (isKnownPrismaError(e) && e.code === 'P2002') { return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 }) } throw e - } + }) + + // Wenn wir oben bereits mit NextResponse zurückgekehrt sind, ist updated ein Response + if (updated instanceof NextResponse) return updated // Zielnutzer (Leader + aktive + inaktive) für persistente Notifications const targets = Array.from(new Set( @@ -49,7 +66,7 @@ export async function POST(req: NextRequest) { const text = `Team wurde umbenannt in "${updated.name}".` - // Persistente Notifications an Team-Mitglieder + Live-Zustellung (nur an diese Nutzer) + // Persistente Notifications + Live-Zustellung if (targets.length) { const created = await Promise.all( targets.map(steamId => @@ -80,25 +97,24 @@ export async function POST(req: NextRequest) { ) } - // ✅ Globale Team-Events (Broadcast, KEIN targetUserIds) für alle Clients + // Broadcast-Events await sendServerSSEMessage({ type: 'team-renamed', teamId, message: text, newName: updated.name, }) - - // Optionaler Failsafe-Reload als Broadcast - await sendServerSSEMessage({ - type: 'team-updated', - teamId, - }) + await sendServerSSEMessage({ type: 'team-updated', teamId }) return NextResponse.json( { success: true, team: { id: updated.id, name: updated.name } }, { headers: { 'Cache-Control': 'no-store' } }, ) - } catch (err) { + } catch (err: unknown) { + // Fallback: P2002 nochmals abfangen, falls es außerhalb des catch-Blocks auftritt + if (isKnownPrismaError(err) && err.code === 'P2002') { + return NextResponse.json({ error: 'Name bereits vergeben' }, { status: 409 }) + } console.error('Fehler beim Umbenennen:', err) return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 }) } diff --git a/src/app/api/team/transfer-leader/route.ts b/src/app/api/team/transfer-leader/route.ts index 74dd657..b46199d 100644 --- a/src/app/api/team/transfer-leader/route.ts +++ b/src/app/api/team/transfer-leader/route.ts @@ -5,9 +5,31 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client' export const dynamic = 'force-dynamic' +type KnownPrismaErrorShape = { code: string; meta?: Record }; + +// kleines Body-Parser-Helper +function parseBody(v: unknown): { teamId?: string; newLeaderSteamId?: string } { + if (!v || typeof v !== 'object') return {} + const r = v as Record + return { + teamId: typeof r.teamId === 'string' ? r.teamId : undefined, + newLeaderSteamId: typeof r.newLeaderSteamId === 'string' ? r.newLeaderSteamId : undefined, + } +} + +// Type Guard für PrismaClientKnownRequestError +function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape { + return !!e + && typeof e === 'object' + && 'code' in e + && typeof (e as { code: unknown }).code === 'string'; +} + export async function POST(req: NextRequest) { try { - const { teamId, newLeaderSteamId } = await req.json() + const raw = await req.json().catch(() => null) + const { teamId, newLeaderSteamId } = parseBody(raw) + if (!teamId || !newLeaderSteamId) { return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 }) } @@ -18,7 +40,7 @@ export async function POST(req: NextRequest) { }) if (!team) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 }) - // ❗ Bereits Leader eines anderen Teams? + // Bereits Leader eines anderen Teams? const otherLedTeam = await prisma.team.findFirst({ where: { leaderId: newLeaderSteamId, NOT: { id: teamId } }, select: { id: true, name: true } @@ -45,7 +67,7 @@ export async function POST(req: NextRequest) { data : { leaderId: newLeaderSteamId }, }) - // --- Benachrichtigung & SSE unverändert --- + // Benachrichtigungen & SSE const newLeader = await prisma.user.findUnique({ where : { steamId: newLeaderSteamId }, select: { name: true }, @@ -109,15 +131,20 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ message: 'Leader erfolgreich übertragen.' }) - } catch (error: any) { - // Falls du zusätzlich im Prisma-Schema @@unique([leaderId]) gesetzt hast: - if (error?.code === 'P2002' && error?.meta?.target?.includes('leaderId')) { - return NextResponse.json( - { message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' }, - { status: 400 } - ) + } catch (err: unknown) { + // unique-Constraint auf leaderId behandeln (falls im Schema vorhanden) + if (isKnownPrismaError(err) && err.code === 'P2002') { + const target = err.meta?.target; // type: unknown + + if (Array.isArray(target) && target.includes('leaderId')) { + return NextResponse.json( + { message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' }, + { status: 400 } + ); + } } - console.error('Fehler beim Leaderwechsel:', error) - return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 }) + + console.error('Fehler beim Leaderwechsel:', err); + return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 }); } } diff --git a/src/app/api/team/update-join-policy/route.ts b/src/app/api/team/update-join-policy/route.ts index 0ac8923..2a3e72d 100644 --- a/src/app/api/team/update-join-policy/route.ts +++ b/src/app/api/team/update-join-policy/route.ts @@ -2,19 +2,29 @@ import { NextResponse, type NextRequest } from 'next/server' import { prisma } from '@/lib/prisma' import { getServerSession } from 'next-auth' -import { sessionAuthOptions } from '@/lib/auth' // ⬅️ hier umstellen +import { sessionAuthOptions } from '@/lib/auth' import { sendServerSSEMessage } from '@/lib/sse-server-client' import type { TeamJoinPolicy } from '@/types/team' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -const ALLOWED = ['REQUEST', 'INVITE_ONLY'] as const -type AllowedPolicy = (typeof ALLOWED)[number] +// Einmal zentral definieren und später benutzen +const ALLOWED: readonly TeamJoinPolicy[] = ['REQUEST', 'INVITE_ONLY'] as const + +type Body = { teamId?: string; joinPolicy?: TeamJoinPolicy } + +function parseBody(v: unknown): Body { + if (!v || typeof v !== 'object') return {} + const r = v as Record + return { + teamId: typeof r.teamId === 'string' ? r.teamId : undefined, + joinPolicy: typeof r.joinPolicy === 'string' ? (r.joinPolicy as TeamJoinPolicy) : undefined, + } +} export async function POST(req: NextRequest) { try { - // ⬇️ statt getServerSession(authOptions(req)) const session = await getServerSession(sessionAuthOptions) const meId = session?.user?.steamId @@ -22,14 +32,14 @@ export async function POST(req: NextRequest) { return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 }) } - const body = await req.json().catch(() => ({} as any)) - const teamId: string | undefined = body?.teamId - const joinPolicy: TeamJoinPolicy | undefined = body?.joinPolicy + const raw: unknown = await req.json().catch(() => null) + const { teamId, joinPolicy } = parseBody(raw) if (!teamId) { return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 }) } - if (!joinPolicy || !ALLOWED.includes(joinPolicy as AllowedPolicy)) { + // Nutzung des zentralen ALLOWED, kein Alias nötig + if (!joinPolicy || !ALLOWED.includes(joinPolicy)) { return NextResponse.json({ message: 'Ungültige joinPolicy' }, { status: 400 }) } @@ -64,7 +74,6 @@ export async function POST(req: NextRequest) { select: { id: true, joinPolicy: true }, }) - // Fire-and-forget SSE Promise.resolve().then(() => sendServerSSEMessage({ type: 'team-updated', diff --git a/src/app/api/user/activity/route.ts b/src/app/api/user/activity/route.ts index 5f7b4bf..27143c8 100644 --- a/src/app/api/user/activity/route.ts +++ b/src/app/api/user/activity/route.ts @@ -1,11 +1,11 @@ // /src/app/api/user/activity/route.ts -import { NextResponse, NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { sendServerSSEMessage } from '@/lib/sse-server-client' -export async function POST(req: NextRequest) { +export async function POST() { const session = await getServerSession(sessionAuthOptions) const steamId = session?.user?.steamId // <-- hier definieren diff --git a/src/app/api/user/away/route.ts b/src/app/api/user/away/route.ts index 66ef1cf..1e9143c 100644 --- a/src/app/api/user/away/route.ts +++ b/src/app/api/user/away/route.ts @@ -1,11 +1,11 @@ // /src/app/api/user/away/route.ts -import { NextResponse, NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { sendServerSSEMessage } from '@/lib/sse-server-client' -export async function POST(req: NextRequest) { +export async function POST() { const session = await getServerSession(sessionAuthOptions) const steamId = session?.user?.steamId diff --git a/src/app/api/user/invitations/[action]/route.ts b/src/app/api/user/invitations/[action]/route.ts index d3fefc7..132f78b 100644 --- a/src/app/api/user/invitations/[action]/route.ts +++ b/src/app/api/user/invitations/[action]/route.ts @@ -13,12 +13,15 @@ export async function POST( const { action } = await ctx.params try { - const body = await req.json().catch(() => ({} as any)) + const body = (await req.json().catch(() => ({}))) as Partial<{ + invitationId: string + teamId: string + steamId: string + }> - // NEU: neben invitationId auch teamId+steamId als Fallback akzeptieren - const incomingInvitationId: string | undefined = body.invitationId - const fallbackTeamId: string | undefined = body.teamId - const fallbackSteamId: string | undefined = body.steamId + const incomingInvitationId = body.invitationId?.trim() || undefined + const fallbackTeamId = body.teamId?.trim() || undefined + const fallbackSteamId = body.steamId?.trim() || undefined // Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId) const invitation = @@ -281,8 +284,9 @@ export async function POST( } return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 }) - } catch (error) { - console.error('Fehler bei Einladung:', error) + } catch (error: unknown) { + const err = error instanceof Error ? error : new Error(String(error)) + console.error('Fehler bei Einladung:', err) return NextResponse.json({ message: 'Serverfehler' }, { status: 500 }) } } diff --git a/src/app/api/user/invitations/route.ts b/src/app/api/user/invitations/route.ts index 5a05829..8a8801f 100644 --- a/src/app/api/user/invitations/route.ts +++ b/src/app/api/user/invitations/route.ts @@ -1,10 +1,10 @@ // /src/app/api/user/invitations/route.ts -import { NextResponse, type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -export async function GET(req: NextRequest) { +export async function GET() { try { const session = await getServerSession(sessionAuthOptions) const steamId = session?.user?.steamId diff --git a/src/app/api/user/offline/route.ts b/src/app/api/user/offline/route.ts index c360539..e3c899c 100644 --- a/src/app/api/user/offline/route.ts +++ b/src/app/api/user/offline/route.ts @@ -1,11 +1,11 @@ // /src/app/api/user/offline/route.ts -import { NextResponse, NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { sendServerSSEMessage } from '@/lib/sse-server-client' -export async function POST(req: NextRequest) { +export async function POST() { const session = await getServerSession(sessionAuthOptions) const steamId = session?.user?.steamId diff --git a/src/app/api/user/privacy/route.ts b/src/app/api/user/privacy/route.ts index 698f153..2002289 100644 --- a/src/app/api/user/privacy/route.ts +++ b/src/app/api/user/privacy/route.ts @@ -4,7 +4,7 @@ import { getServerSession } from 'next-auth' import { sessionAuthOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -export async function GET(req: NextRequest) { +export async function GET() { const session = await getServerSession(sessionAuthOptions) if (!session?.user?.steamId) { return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 }) diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 9ebdfc8..fb6372c 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -1,5 +1,5 @@ // /src/app/api/user/route.ts -import { NextResponse, type NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { sessionAuthOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; @@ -17,7 +17,7 @@ type SlimPlayer = { // Hilfstyp für Session, damit TS weiß, dass es user?.steamId gibt type SessionShape = { user?: { steamId?: string } } | null; -export async function GET(_req: NextRequest) { +export async function GET() { const session = (await getServerSession(sessionAuthOptions)) as SessionShape; const steamId = session?.user?.steamId; diff --git a/src/app/api/user/timezone/route.ts b/src/app/api/user/timezone/route.ts index 0870a21..7c0ec16 100644 --- a/src/app/api/user/timezone/route.ts +++ b/src/app/api/user/timezone/route.ts @@ -10,15 +10,13 @@ function isValidIanaOrNull(v: unknown): v is string | null { if (v === null) return true if (typeof v !== 'string' || v.trim() === '') return false // Validate via Intl.supportedValuesOf if available - // @ts-ignore const list: string[] | undefined = typeof Intl.supportedValuesOf === 'function' - // @ts-ignore ? Intl.supportedValuesOf('timeZone') : undefined return list ? list.includes(v) : true // wenn kein Support: großzügig erlauben } -export async function GET(req: NextRequest) { +export async function GET() { try { const session = await getServerSession(sessionAuthOptions) if (!session?.user?.steamId) { @@ -29,8 +27,9 @@ export async function GET(req: NextRequest) { select: { timeZone: true } }) return NextResponse.json({ timeZone: user?.timeZone ?? null }) - } catch (e: any) { - console.error('[TZ][GET] failed', e) + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)) + console.error('[TZ][GET] failed:', err.message, err) return NextResponse.json({ message: 'Internal error' }, { status: 500 }) } } @@ -55,8 +54,9 @@ export async function PUT(req: NextRequest) { }) return NextResponse.json({ ok: true }) - } catch (e: any) { - console.error('[TZ][PUT] failed', e) + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)) + console.error('[TZ][PUT] failed:', err.message, err) return NextResponse.json({ message: 'Internal error' }, { status: 500 }) } } diff --git a/src/app/api/user/winrate/route.ts b/src/app/api/user/winrate/route.ts index fd40a4e..ee343d7 100644 --- a/src/app/api/user/winrate/route.ts +++ b/src/app/api/user/winrate/route.ts @@ -1,5 +1,6 @@ // /src/app/api/user/winrate/route.ts -import { NextResponse, type NextRequest } from 'next/server' + +import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { MAP_OPTIONS } from '@/lib/mapOptions' @@ -202,5 +203,6 @@ export async function POST(req: NextRequest) { if (typeof body.onlyActive === 'boolean') { params.searchParams.set('onlyActive', String(body.onlyActive)) } - return GET(new Request(params.toString()) as any) + const nextReq = new NextRequest(params.toString(), { method: 'GET' }) + return GET(nextReq) } diff --git a/src/generated/prisma/edge.js b/src/generated/prisma/edge.js index 6327f9a..8f1b750 100644 --- a/src/generated/prisma/edge.js +++ b/src/generated/prisma/edge.js @@ -417,7 +417,7 @@ const config = { "value": "prisma-client-js" }, "output": { - "value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", + "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma", "fromEnvVar": null }, "config": { @@ -431,7 +431,7 @@ const config = { } ], "previewFeatures": [], - "sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", + "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma", "isCustomOutput": true }, "relativeEnvPaths": { diff --git a/src/generated/prisma/index.js b/src/generated/prisma/index.js index c30f85a..c34b6d7 100644 --- a/src/generated/prisma/index.js +++ b/src/generated/prisma/index.js @@ -418,7 +418,7 @@ const config = { "value": "prisma-client-js" }, "output": { - "value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", + "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma", "fromEnvVar": null }, "config": { @@ -432,7 +432,7 @@ const config = { } ], "previewFeatures": [], - "sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", + "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma", "isCustomOutput": true }, "relativeEnvPaths": { diff --git a/src/generated/prisma/wasm.js b/src/generated/prisma/wasm.js index 2f7d2d6..2842bf2 100644 --- a/src/generated/prisma/wasm.js +++ b/src/generated/prisma/wasm.js @@ -417,7 +417,7 @@ const config = { "value": "prisma-client-js" }, "output": { - "value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", + "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma", "fromEnvVar": null }, "config": { @@ -431,7 +431,7 @@ const config = { } ], "previewFeatures": [], - "sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", + "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma", "isCustomOutput": true }, "relativeEnvPaths": { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index fd066cf..5498a33 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -142,7 +142,7 @@ export const buildAuthOptions = (req: NextRequest): NextAuthOptions => ({ const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`) const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`) if (isSignOut) return `${baseUrl}/` - if (isSignIn || url === baseUrl) return `${baseUrl}/dashboard` + if (isSignIn || url === baseUrl) return `${baseUrl}/` return url.startsWith(baseUrl) ? url : baseUrl }, }, diff --git a/src/messages/de.json b/src/messages/de.json index db346f0..614b2be 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -125,8 +125,13 @@ "day": "Tag" }, "mapvote": { - "open": "offen", - "opens-in": "öffnet in" + "mode": "Modus", + "open": "Offen", + "open-small": "offen", + "opens-in": "Öffnet in", + "completed": "Voting abgeschlossen!", + "vote-now": "vote", + "to-match-start": "zum Matchbeginn" }, "notifications": { "title": "Benachrichtigungen", diff --git a/src/messages/en.json b/src/messages/en.json index c3268b9..27e7196 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -125,8 +125,13 @@ "day": "Day" }, "mapvote": { - "open": "open", - "opens-in": "opens in" + "mode": "Mode", + "open": "Open", + "open-small": "open", + "opens-in": "Opens in", + "completed": "Voting completed!", + "vote-now": "vote", + "to-match-start": "to match start" }, "notifications": { "title": "Notifications", diff --git a/src/middleware.ts b/src/middleware.ts index 6acaabe..3cd806b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -28,7 +28,7 @@ function stripLeadingLocale(pathname: string, locales: readonly string[]) { } function isProtectedPath(pathnameNoLocale: string) { return ( - pathnameNoLocale.startsWith('/dashboard') || + pathnameNoLocale.startsWith('/') || pathnameNoLocale.startsWith('/settings') || pathnameNoLocale.startsWith('/matches') || pathnameNoLocale.startsWith('/team') || @@ -73,7 +73,7 @@ export default async function middleware(req: NextRequest) { if (!isAdmin) { const currentLocale = getCurrentLocaleFromPath(pathname, locales, defaultLocale); const redirectUrl = url.clone(); - redirectUrl.pathname = `/${currentLocale}/dashboard`; + redirectUrl.pathname = `/${currentLocale}/`; return NextResponse.redirect(redirectUrl); } }