This commit is contained in:
Linrador 2025-10-15 14:58:10 +02:00
parent 99ad158526
commit 19bf9f7c9e
24 changed files with 906 additions and 523 deletions

View File

@ -109,6 +109,9 @@ type BaseProps<TType extends ChartJSType = ChartJSType> = {
radarAddRingOffset?: boolean;
};
type SafeRadialTicks = NonNullable<RadialLinearScaleOptions['ticks']>;
type SafePointLabels = NonNullable<RadialLinearScaleOptions['pointLabels']>;
const RADAR_OFFSET = 20;
const imgCache = new Map<string, HTMLImageElement>();
@ -193,10 +196,7 @@ function ChartInner<TType extends ChartJSType = ChartJSType>(
const radarScaleOpts = useMemo(() => {
if (type !== 'radar') return undefined;
const gridColor = 'rgba(255,255,255,0.10)';
const angleColor = 'rgba(255,255,255,0.12)';
const ticks: NonNullable<RadialLinearScaleOptions['ticks']> = {
const ticksObj = {
display: true,
color: 'rgba(255,255,255,0.6)',
font: { size: 12 },
@ -209,10 +209,10 @@ function ChartInner<TType extends ChartJSType = ChartJSType>(
z: 0,
major: { enabled: false },
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
callback: (value) => String(value),
};
callback: (value: unknown) => String(value),
} satisfies Partial<SafeRadialTicks>;
const pointLabels: NonNullable<RadialLinearScaleOptions['pointLabels']> = {
const pointLabelsObj = {
display: false,
color: '#ffffff',
font: { size: 12 },
@ -221,34 +221,34 @@ function ChartInner<TType extends ChartJSType = ChartJSType>(
backdropPadding: 0,
borderRadius: 0,
centerPointLabels: false,
};
callback: (label: string) => label,
} satisfies Partial<SafePointLabels>;
// ⬇︎ HIER: r als RadialLinearScaleOptions typisieren
const r: RadialLinearScaleOptions = {
const r: Partial<RadialLinearScaleOptions> = {
display: true,
alignToPixels: false,
backgroundColor: 'transparent',
reverse: false,
ticks,
grid: { color: gridColor, lineWidth: 1 },
ticks: ticksObj as unknown as SafeRadialTicks,
pointLabels: pointLabelsObj as unknown as SafePointLabels,
grid: { color: 'rgba(255,255,255,0.10)', lineWidth: 1 },
angleLines: {
display: true,
color: angleColor,
color: 'rgba(255,255,255,0.12)',
lineWidth: 1,
borderDash: [],
borderDashOffset: 0,
},
pointLabels,
suggestedMin: 0,
};
// ⬇︎ ohne any cast
if (typeof radarMax === 'number') {
r.max = radarMax + RADAR_OFFSET;
r.suggestedMax = radarMax + RADAR_OFFSET;
}
return { r };
// Am Ende in die strikte Chart.js-Option gießen
return { r: r as unknown as RadialLinearScaleOptions };
}, [type, radarStepSize, radarMax]);
/* ---------- Radar Icons Plugin ---------- */

View File

@ -30,6 +30,7 @@ type GameBannerProps = {
visible?: boolean
zIndex?: number
inline?: boolean
serverLabel?: string
mapKey?: string
mapLabel?: string
bgUrl?: string
@ -146,6 +147,7 @@ export default function GameBanner(props: GameBannerProps = {}) {
visible: visibleProp,
zIndex: zIndexProp,
inline = false,
serverLabel,
mapKey: mapKeyProp,
mapLabel: mapLabelProp,
bgUrl: bgUrlProp,
@ -380,6 +382,9 @@ export default function GameBanner(props: GameBannerProps = {}) {
const InfoRow = () => (
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
{serverLabel && (
<span>Server: <span className="font-semibold">{serverLabel}</span></span>
)}
<span>Map: <span className="font-semibold">{pretty.map}</span></span>
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{shownConnected}</span> / {totalExpected}</span>

View File

@ -74,7 +74,6 @@ export default function GameBannerHost() {
score={banner?.score ?? ' : '}
connectedCount={banner?.connectedCount ?? 0}
totalExpected={banner?.totalExpected ?? (cfg?.activeParticipants?.length ?? 0)}
missingCount={banner?.missingCount ?? 0}
onReconnect={() => {/* optional: auf connectUri navigieren */}}
onDisconnect={() => setBanner(null)}
/>

View File

@ -243,8 +243,10 @@ export default function MapVotePanel({ match }: Props) {
const json = await r.json();
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)');
setState(json);
} catch (e) {
if ((e as any)?.name === 'AbortError') return; // wurde abgebrochen -> still
} catch (e: unknown) {
// Fetch-Abbruch sauber erkennen (läuft in allen Browsern)
if (e instanceof Error && e.name === 'AbortError') return;
setState(null);
setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
} finally {
@ -792,7 +794,7 @@ export default function MapVotePanel({ match }: Props) {
scheduleReload(); // <— statt load()
}
}, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, matchBaseTs, teamSteamIds, applyLeaderChange]);
}, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, matchBaseTs, teamSteamIds, applyLeaderChange, scheduleReload]);
// Effect NUR an stabile Keys + Tab hängen
useEffect(() => {

View File

@ -228,7 +228,7 @@ export default function TeamCardComponent({
} finally {
softReloadInFlight.current = false;
}
}, [pendingInvitations.length, selectedTeam?.id]);
}, [pendingInvitations.length]);
/* ------- SSE-gestützte Updates (dedupliziert) ------- */
useEffect(() => {
@ -293,7 +293,6 @@ export default function TeamCardComponent({
if (!selectedTeam) return;
if (Array.isArray(selectedTeam.invitedPlayers)) return;
// schon für diese ID vollgeladen?
if (fullLoadedFor.current.has(selectedTeam.id)) return;
fullLoadedFor.current.add(selectedTeam.id);
@ -306,7 +305,7 @@ export default function TeamCardComponent({
})();
return () => { cancelled = true; };
}, [selectedTeam?.id]);
}, [selectedTeam]);
/* ------- Render-Zweige ------- */

View File

@ -0,0 +1,122 @@
// /src/app/[locale]/components/TeamPlayerCard.tsx
'use client'
import { KeyboardEvent, MouseEvent } from 'react'
import PremierRankBadge from './PremierRankBadge'
import UserAvatarWithStatus from './UserAvatarWithStatus'
import FaceitLevelImage from './FaceitLevelBadge'
function cls(...xs: Array<string | false | null | undefined>) {
return xs.filter(Boolean).join(' ')
}
export type TeamPlayerCardProps = {
steamId: string
name: string
avatar: string
rank?: number | null
faceitNickname?: string | null
faceitUrl?: string | null
faceitLevel?: number | null
isLeader?: boolean
isActive?: boolean
onOpen: (steamId: string) => void
}
export default function TeamPlayerCard({
steamId,
name,
avatar,
rank = null,
faceitLevel = null,
isLeader = false,
isActive = false,
onOpen,
}: TeamPlayerCardProps) {
const onCardClick = (e: MouseEvent<HTMLDivElement>) => {
e.preventDefault()
onOpen(steamId)
}
const onCardKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpen(steamId)
}
}
return (
<div
role="button"
tabIndex={0}
aria-label={`${name} Profil öffnen`}
onClick={onCardClick}
onKeyDown={onCardKey}
className={cls(
'group flex items-center gap-4 p-3 rounded-lg border',
'border-gray-200 dark:border-neutral-700',
'bg-white dark:bg-neutral-800 shadow-sm',
'transition cursor-pointer focus:outline-none',
'hover:shadow-md hover:bg-gray-50 dark:hover:bg-neutral-700',
)}
>
<UserAvatarWithStatus
steamId={steamId}
src={avatar}
alt={name}
size={48}
showStatus
/>
<div className="min-w-0 flex-1">
{/* Zeile 1: Name + PremierRank + FACEIT + Leader */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 dark:text-neutral-100 truncate">
{name}
</span>
{typeof rank === 'number' && (
<span className="shrink-0">
<PremierRankBadge rank={rank} />
</span>
)}
{/* FACEIT Level/Icon direkt danach */}
{(faceitLevel) != null && (
<FaceitLevelImage
level={faceitLevel ?? undefined}
className="shrink-0"
/>
)}
{/* Leader-Badge direkt hinter Rank/Faceit */}
{isLeader && (
<span
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded
bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
>
Leader
</span>
)}
</div>
{/* Zeile 2: Status */}
<div className="mt-1 flex items-center gap-2 flex-wrap">
<span
className={cls(
'inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded',
isActive
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
)}
>
{isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
</div>
<svg aria-hidden viewBox="0 0 24 24" className="w-4 h-4 text-gray-400 group-hover:text-gray-500 transition">
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
</svg>
</div>
)
}

View File

@ -14,6 +14,12 @@ import { defaultLifeMs, SMOKE_LINGER_MS, GRENADE_LIFE_MS } from './lib/grenades'
/* ───────── small utils/types ───────── */
type UnknownRecord = Record<string, unknown>
type StoreAvatarUser = { avatar?: string | null };
type StoreNotFound = { notFound: true };
type AvatarStoreEntry = StoreAvatarUser | StoreNotFound | undefined;
type AvatarByIdMap = Record<string, AvatarStoreEntry>;
const isObj = (v: unknown): v is UnknownRecord => !!v && typeof v === 'object'
const pickFirst = (o: UnknownRecord, keys: string[]): unknown =>
@ -236,7 +242,7 @@ export default function LiveRadar() {
const [bomb, setBomb] = useState<BombState | null>(null)
const ensureTeamsLoaded = useAvatarDirectoryStore((s) => s.ensureTeamsLoaded)
const avatarById = useAvatarDirectoryStore((s) => s.byId)
const avatarById = useAvatarDirectoryStore((s) => s.byId) as AvatarByIdMap;
const [useAvatars, setUseAvatars] = useState(false)
useEffect(() => {
@ -482,7 +488,7 @@ export default function LiveRadar() {
// Upsert
playersRef.current.set(id, {
id,
name: str(pickFirst(e, ['name']), old?.name ?? null as unknown as string | null),
name: str(pickFirst(e, ['name']), old?.name ?? undefined),
team: mapTeam(pickFirst(e, ['team']) ?? old?.team),
x, y, z,
yaw: Number.isFinite(yaw ?? NaN) ? (yaw as number) : (old?.yaw ?? null),
@ -569,7 +575,7 @@ export default function LiveRadar() {
playersRef.current.set(id, {
id,
name: str((pRaw as UnknownRecord).name, old?.name ?? null as unknown as string | null),
name: str((pRaw as UnknownRecord).name, old?.name ?? undefined),
team: mapTeam((pRaw as UnknownRecord).team ?? old?.team),
x, y, z,
yaw: Number.isFinite(yawDeg) ? yawDeg : (old?.yaw ?? null),
@ -1148,16 +1154,27 @@ export default function LiveRadar() {
}
}, [isBeepActive, bomb])
if (!isAuthed) {
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center max-w-sm">
<h2 className="text-xl font-semibold mb-2">Live Radar</h2>
<p className="opacity-80">Bitte einloggen, um das Live-Radar zu sehen.</p>
</div>
</div>
)
}
const avatarByIdNormalized = useMemo(() => {
const out: Record<string, { avatar?: string; notFound?: boolean } | undefined> = {};
for (const [id, v] of Object.entries(avatarById)) {
if (!v) continue;
if ('notFound' in v && v.notFound === true) {
out[id] = { notFound: true };
continue;
}
if ('avatar' in v) {
const a = v.avatar;
if (typeof a === 'string' && a) {
out[id] = { avatar: a };
}
}
}
return out;
}, [avatarById]);
const teamOfPlayer = (sid?: string | null): 'T' | 'CT' | string | null => {
if (!sid) return null
@ -1179,141 +1196,147 @@ export default function LiveRadar() {
/* ───────── render ───────── */
return (
<div className="h-full min-h-0 w-full flex flex-col overflow-hidden">
<RadarHeader
useAvatars={useAvatars}
setUseAvatars={setUseAvatars}
radarWsStatus={radarWsStatus}
roundPhase={roundPhase}
roundSecLeft={secsLeft(roundEndsAtRef.current)}
/>
<GameSocket
url={gameUrl}
onStatus={setGameWsStatus}
onMap={(k) => setActiveMapKey(String(k).toLowerCase())}
onPlayerUpdate={(p) => { upsertPlayer(p); scheduleFlush() }}
onPlayersAll={(m) => { handlePlayersAll(m as UnknownRecord); scheduleFlush() }}
onGrenades={(g) => {
handleGrenades(g)
scheduleFlush()
}}
onRoundStart={() => {
clearRoundArtifacts(true)
}}
onRoundEnd={() => {
for (const [id, p] of playersRef.current) {
playersRef.current.set(id, { ...p, hasBomb: false })
}
if (bombRef.current?.status === 'planted') {
bombRef.current = { ...bombRef.current, status: 'defused' }
}
stopBeep()
deathMarkersRef.current = []
trailsRef.current.clear()
grenadesRef.current.clear()
projectileIdCache.clear()
projectileIdReverse.clear()
projectileSeq = 0
scheduleFlush()
}}
onBomb={(b) => {
const prev = bombRef.current
const nb = normalizeBomb(b as UnknownRecord)
if (!nb) return
const withPos = {
x: Number.isFinite(nb.x) ? nb.x : prev?.x ?? 0,
y: Number.isFinite(nb.y) ? nb.y : prev?.y ?? 0,
z: Number.isFinite(nb.z) ? nb.z : prev?.z ?? 0,
}
const sameStatus = prev && prev.status === nb.status
bombRef.current = {
...withPos,
status: nb.status,
changedAt: sameStatus ? prev!.changedAt : Date.now(),
}
scheduleFlush()
}}
/>
<div className="flex-1 min-h-0">
{!activeMapKey ? (
<div className="h-full grid place-items-center">
<div className="px-4 py-3 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
Keine Map erkannt.
</div>
{!isAuthed ? (
<div className="h-full w-full grid place-items-center">
<div className="text-center max-w-sm">
<h2 className="text-xl font-semibold mb-2">Live Radar</h2>
<p className="opacity-80">Bitte einloggen, um das Live-Radar zu sehen.</p>
</div>
) : (
<div className="h-full min-h-0 grid grid-cols-[minmax(180px,240px)_1fr_minmax(180px,240px)] gap-4">
{myTeam !== 'CT' && (
<TeamSidebar
team="T"
teamId={teamIdT}
players={players
.filter((p) => p.team === 'T' && (!myTeam || p.team === myTeam))
.map((p) => ({
id: p.id,
name: p.name,
hp: p.hp,
armor: p.armor,
helmet: p.helmet,
defuse: p.defuse,
hasBomb: p.hasBomb,
alive: p.alive,
}))}
onHoverPlayer={setHoveredPlayerId}
/>
)}
</div>
) : (
<>
<RadarHeader
useAvatars={useAvatars}
setUseAvatars={setUseAvatars}
radarWsStatus={radarWsStatus}
roundPhase={roundPhase}
roundSecLeft={secsLeft(roundEndsAtRef.current)}
/>
<RadarCanvas
activeMapKey={activeMapKey}
currentSrc={currentSrc}
onImgLoad={(img) =>
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
<GameSocket
url={gameUrl}
onStatus={setGameWsStatus}
onMap={(k) => setActiveMapKey(String(k).toLowerCase())}
onPlayerUpdate={(p) => { upsertPlayer(p); scheduleFlush() }}
onPlayersAll={(m) => { handlePlayersAll(m as UnknownRecord); scheduleFlush() }}
onGrenades={(g) => { handleGrenades(g); scheduleFlush() }}
onRoundStart={() => { clearRoundArtifacts(true) }}
onRoundEnd={() => {
for (const [id, p] of playersRef.current) {
playersRef.current.set(id, { ...p, hasBomb: false })
}
onImgError={() => {
if (srcIdx < imageCandidates.length - 1) setSrcIdx((i) => i + 1)
}}
imgSize={imgSize}
worldToPx={worldToPx}
unitsToPx={unitsToPx}
players={players}
grenades={grenades}
trails={trails}
deathMarkers={deathMarkers}
useAvatars={useAvatars}
avatarById={avatarById}
hoveredPlayerId={hoveredPlayerId}
setHoveredPlayerId={setHoveredPlayerId}
myTeam={myTeam}
beepState={beepState}
bombFinal10={bombFinal10}
bomb={bomb}
shouldShowGrenade={shouldShowGrenade}
/>
if (bombRef.current?.status === 'planted') {
bombRef.current = { ...bombRef.current, status: 'defused' }
}
stopBeep()
deathMarkersRef.current = []
trailsRef.current.clear()
grenadesRef.current.clear()
projectileIdCache.clear()
projectileIdReverse.clear()
projectileSeq = 0
scheduleFlush()
}}
onBomb={(b) => {
const prev = bombRef.current
const nb = normalizeBomb(b as UnknownRecord)
if (!nb) return
const withPos = {
x: Number.isFinite(nb.x) ? nb.x : prev?.x ?? 0,
y: Number.isFinite(nb.y) ? nb.y : prev?.y ?? 0,
z: Number.isFinite(nb.z) ? nb.z : prev?.z ?? 0,
}
const sameStatus = prev && prev.status === nb.status
bombRef.current = {
...withPos,
status: nb.status,
changedAt: sameStatus ? prev!.changedAt : Date.now(),
}
scheduleFlush()
}}
/>
{myTeam !== 'T' && (
<TeamSidebar
team="CT"
align="right"
teamId={teamIdCT}
players={players
.filter((p) => p.team === 'CT' && (!myTeam || p.team === myTeam))
.map((p) => ({
id: p.id,
name: p.name,
hp: p.hp,
armor: p.armor,
helmet: p.helmet,
defuse: p.defuse,
hasBomb: p.hasBomb,
alive: p.alive,
}))}
onHoverPlayer={setHoveredPlayerId}
/>
<div className="flex-1 min-h-0">
{!activeMapKey ? (
<div className="h-full grid place-items-center">
<div className="px-4 py-3 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
Keine Map erkannt.
</div>
</div>
) : (
<div className="h-full min-h-0 grid grid-cols-[minmax(180px,240px)_1fr_minmax(180px,240px)] gap-4">
{myTeam !== 'CT' && (
<TeamSidebar
team="T"
teamId={teamIdT}
players={players
.filter((p) => p.team === 'T' && (!myTeam || p.team === myTeam))
.map((p) => ({
id: p.id,
name: p.name,
hp: p.hp,
armor: p.armor,
helmet: p.helmet,
defuse: p.defuse,
hasBomb: p.hasBomb,
alive: p.alive,
}))}
onHoverPlayer={setHoveredPlayerId}
/>
)}
<RadarCanvas
activeMapKey={activeMapKey}
currentSrc={currentSrc}
onImgLoad={(img) =>
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
}
onImgError={() => {
if (srcIdx < imageCandidates.length - 1) setSrcIdx((i) => i + 1)
}}
imgSize={imgSize}
worldToPx={worldToPx}
unitsToPx={unitsToPx}
players={players}
grenades={grenades}
trails={trails}
deathMarkers={deathMarkers}
useAvatars={useAvatars}
avatarById={avatarByIdNormalized}
hoveredPlayerId={hoveredPlayerId}
myTeam={myTeam}
beepState={beepState}
bombFinal10={bombFinal10}
bomb={bomb}
shouldShowGrenade={shouldShowGrenade}
/>
{myTeam !== 'T' && (
<TeamSidebar
team="CT"
align="right"
teamId={teamIdCT}
players={players
.filter((p) => p.team === 'CT' && (!myTeam || p.team === myTeam))
.map((p) => ({
id: p.id,
name: p.name,
hp: p.hp,
armor: p.armor,
helmet: p.helmet,
defuse: p.defuse,
hasBomb: p.hasBomb,
alive: p.alive,
}))}
onHoverPlayer={setHoveredPlayerId}
/>
)}
</div>
)}
</div>
)}
</div>
</>
)
}
</div>
)
}

View File

@ -7,7 +7,7 @@ import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';
import { contrastStroke } from './lib/helpers';
import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types';
type AvatarEntry = { avatar?: string; notFound?: boolean } | undefined
type AvatarEntry = { avatar?: string | null; notFound?: boolean } | undefined
export default function RadarCanvas({
activeMapKey,

View File

@ -170,7 +170,11 @@ export function useRadarState(mySteamId: string | null) {
else if (nextAlive === false && (old?.alive !== false)) addDeathMarkerFor(id, x, y);
type WeaponRec = { name?: unknown; state?: unknown } | string;
// Rohdaten lesen
const weaponsArr = getArr(src.weapons) as WeaponRec[] | undefined;
// Aktive Waffe wie gehabt
const activeFromArr =
weaponsArr?.find((w) => isObj(w) && String(w.state ?? '').toLowerCase() === 'active') ?? null;
const activeWeaponName =
@ -179,6 +183,23 @@ export function useRadarState(mySteamId: string | null) {
(isObj(activeFromArr) ? (activeFromArr.name as string | undefined) : undefined) ||
null;
// ✅ Waffenliste auf Ziel-Typ mappen
type WeaponView = { name: string; state?: string | null };
const weaponsNorm: WeaponView[] | null | undefined = weaponsArr
?.map((w): WeaponView | null => {
if (typeof w === 'string') return { name: w };
if (isObj(w)) {
const nmRaw = (w as { name?: unknown }).name;
const nm = typeof nmRaw === 'string' ? nmRaw : (nmRaw != null ? String(nmRaw) : '');
if (!nm) return null;
const stRaw = (w as { state?: unknown }).state;
const st = stRaw == null ? null : String(stRaw);
return st == null ? { name: nm } : { name: nm, state: st };
}
return null;
})
.filter((x): x is WeaponView => !!x) ?? null;
const stateObj = getObj(src.state);
playersRef.current.set(id, {
id,
@ -202,7 +223,7 @@ export function useRadarState(mySteamId: string | null) {
(src as UnknownRecord)['hasDefuser'] ??
stateObj?.defusekit) as boolean | null ?? (old?.defuse ?? null),
activeWeapon: activeWeaponName ?? old?.activeWeapon ?? null,
weapons: Array.isArray(src.weapons) ? (src.weapons as unknown[]) : (old?.weapons ?? null),
weapons: weaponsNorm ?? old?.weapons ?? null,
nades: old?.nades ?? null,
});
}

View File

@ -1,4 +1,4 @@
// app/match-details/[matchId]/vote/VoteClient.tsx
// /src/app/[locale]/match-details/[matchId]/vote/VoteClient.tsx
'use client'
import MapVotePanel from '@/app/[locale]/components/MapVotePanel'

View File

@ -1,4 +1,4 @@
// app/match-details/[matchId]/vote/page.tsx
// /src/app/[locale]/match-details/[matchId]/vote/page.tsx
import Card from '@/app/[locale]/components/Card'
import VoteClient from './VoteClient' // Client-Komponente

View File

@ -1,16 +1,20 @@
// /src/app/[locale]/team/[teamId]/TeamDetailClient.tsx
'use client'
import { useEffect, useMemo, useState, KeyboardEvent, MouseEvent, ChangeEvent } from 'react'
import { useMemo, useEffect, useState, ChangeEvent } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import LoadingSpinner from '../../components/LoadingSpinner'
import Card from '../../components/Card'
import PremierRankBadge from '../../components/PremierRankBadge'
import { Player, Team } from '@/types/team'
import Button from '../../components/Button'
import TeamPlayerCard from '../../components/TeamPlayerCard'
import LeaveTeamModal from '../../components/LeaveTeamModal'
import { leaveTeam, reloadTeam } from '@/lib/sse-actions'
import type { Player, Team } from '@/types/team'
/* ---------- kleine Helfer ---------- */
type PlayerWithFaceitLevel = Player & { faceitLevel?: number | null }
/* ---------- Helfer ---------- */
function uniqBySteamId<T extends { steamId: string }>(list: T[]): T[] {
const seen = new Set<string>()
const out: T[] = []
@ -22,118 +26,193 @@ function uniqBySteamId<T extends { steamId: string }>(list: T[]): T[] {
}
return out
}
function byName<T extends { name: string }>(a: T, b: T) {
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' })
}
function classNames(...xs: Array<string | false | null | undefined>) {
return xs.filter(Boolean).join(' ')
}
/* ---------- Hauptseite ---------- */
type TeamDetailClientProps = {
teamId: string
/** Darf fehlen wird dann via /api/user ermittelt */
currentUserSteamId?: string | null
invitationId?: string | 'pending' | null
canRequestJoin?: boolean
}
export default function TeamDetailClient({ teamId }: { teamId: string }) {
export default function TeamDetailClient({
teamId,
currentUserSteamId = null,
invitationId: initialInvitationId = null,
canRequestJoin = true,
}: TeamDetailClientProps) {
const router = useRouter()
const [team, setTeam] = useState<Team | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Eigene SteamID (Prop oder via /api/user nachladen)
const [mySteamId, setMySteamId] = useState<string | null>(currentUserSteamId)
const [q, setQ] = useState('')
const [seg, setSeg] = useState<'active' | 'inactive' | 'invited'>('active')
const [seg, setSeg] = useState<'all' | 'active' | 'inactive'>('all')
const [invitationId, setInvitationId] = useState<string | 'pending' | null>(initialInvitationId)
const [joining, setJoining] = useState(false)
// Modal für Leader-Leave
const [showLeaveModal, setShowLeaveModal] = useState(false)
// Eigene SteamID nachladen, falls nicht per Prop übergeben
useEffect(() => {
const ac = new AbortController();
(async () => {
if (mySteamId) return
let alive = true
;(async () => {
try {
setLoading(true);
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store', signal: ac.signal });
if (res.status === 404) { router.replace('/404'); return; }
if (!res.ok) throw new Error('Team konnte nicht geladen werden');
const data: Team = await res.json();
setTeam(data);
} catch (e) {
if (!ac.signal.aborted) setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
} finally {
if (!ac.signal.aborted) setLoading(false);
}
})();
return () => ac.abort();
}, [teamId]); // ← router entfernt
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return
const data = (await res.json()) as { steamId?: string | null }
if (alive) setMySteamId(data.steamId ?? null)
} catch {}
})()
return () => { alive = false }
}, [mySteamId])
// Team laden
useEffect(() => {
const ac = new AbortController()
;(async () => {
try {
setLoading(true)
setError(null)
const res = await fetch(`/api/team/${teamId}`, { signal: ac.signal })
if (res.status === 404) { router.replace('/404'); return }
if (!res.ok) throw new Error('Team konnte nicht geladen werden')
const data: Team = await res.json()
setTeam(data)
} catch (e) {
if (!ac.signal.aborted)
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
if (!ac.signal.aborted) setLoading(false)
}
})()
return () => ac.abort()
}, [teamId, router])
/* ---------- Ableitungen ---------- */
const members = useMemo(() => {
if (!team) return []
const all = [
// Counts (ohne Eingeladene)
const counts = useMemo(() => {
if (!team) return { all: 0, active: 0, inactive: 0 }
const all = uniqBySteamId([
...(team.leader ? [team.leader] : []),
...team.activePlayers,
...team.inactivePlayers,
]
// uniq + Leader nach vorne
const uniq = uniqBySteamId(all).sort(byName)
if (team.leader) {
const i = uniq.findIndex((p) => p.steamId === team.leader!.steamId)
if (i > 0) {
const [lead] = uniq.splice(i, 1)
uniq.unshift(lead)
}
])
return {
all: all.length,
active: team.activePlayers.length,
inactive: team.inactivePlayers.length,
}
return uniq
}, [team])
const counts = useMemo(
() => ({
active: team?.activePlayers.length ?? 0,
inactive: team?.inactivePlayers.length ?? 0,
invited: team?.invitedPlayers.length ?? 0,
total: members.length,
}),
[team, members]
)
// Gefilterte Liste
const filteredList = useMemo(() => {
if (!team) return []
const norm = (s: string) => s.toLowerCase().normalize('NFKD')
const search = norm(q)
const matchQ = (p: { name: string; location?: string; steamId: string }) => {
if (!search) return true
return (
norm(p.name).includes(search) ||
norm(p.location ?? '').includes(search) ||
norm(p.steamId).includes(search)
)
}
const matchQ = (p: { name: string; location?: string; steamId: string }) =>
!search || norm(p.name).includes(search) || norm(p.location ?? '').includes(search) || norm(p.steamId).includes(search)
if (seg === 'active') {
return uniqBySteamId(team.activePlayers).filter(matchQ).sort(byName)
}
if (seg === 'inactive') {
return uniqBySteamId(team.inactivePlayers).filter(matchQ).sort(byName)
}
// invited
return uniqBySteamId(team.invitedPlayers).filter(matchQ).sort(byName)
if (seg === 'active') return uniqBySteamId(team.activePlayers).filter(matchQ).sort(byName)
if (seg === 'inactive') return uniqBySteamId(team.inactivePlayers).filter(matchQ).sort(byName)
const union = uniqBySteamId([
...(team.leader ? [team.leader] : []),
...team.activePlayers,
...team.inactivePlayers,
])
return union.filter(matchQ).sort(byName)
}, [team, q, seg])
/* ---------- Interaktionen ---------- */
const activeSet = useMemo(
() => new Set(team?.activePlayers.map(p => p.steamId) ?? []),
[team]
)
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
const onCardClick =
(steamId: string) =>
(e: MouseEvent) => {
e.preventDefault()
goToProfile(steamId)
// Mitgliedsstatus
const isMemberOfThisTeam = useMemo(() => {
if (!team || !mySteamId) return false
const inActive = (team.activePlayers ?? []).some(p => String(p.steamId) === String(mySteamId))
const inInactive = (team.inactivePlayers ?? []).some(p => String(p.steamId) === String(mySteamId))
const isLeader = team.leader?.steamId && String(team.leader.steamId) === String(mySteamId)
return Boolean(inActive || inInactive || isLeader)
}, [team, mySteamId])
const isLeader = !!(team?.leader?.steamId && mySteamId && String(team.leader.steamId) === String(mySteamId))
const isInviteOnly = team?.joinPolicy === 'INVITE_ONLY'
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
const hasPendingRequest = invitationId === 'pending'
/* ---------- Aktionen ---------- */
// Verlassen/Join (für Mitglieder: verlassen; für Leader: Modal öffnen)
const handleLeaveClick = async () => {
if (!team || joining) return
if (isLeader) { setShowLeaveModal(true); return }
if (!mySteamId) { alert('Deine SteamID konnte nicht bestimmt werden.'); return }
setJoining(true)
try {
await leaveTeam(mySteamId)
const updated = await reloadTeam(team.id)
if (updated) setTeam(updated)
} catch (err) {
console.error('[TeamDetailClient] Leave Fehler:', err)
alert('Aktion fehlgeschlagen.')
} finally {
setJoining(false)
}
const onCardKey =
(steamId: string) =>
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
goToProfile(steamId)
}
// Falls du später wieder Join zeigen willst: Handler bleibt nutzbar
const handleJoinBranches = async () => {
if (!team || joining) return
setJoining(true)
try {
if (hasRealInvitation) {
await fetch('/api/user/invitations/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId }),
})
setInvitationId(null)
} else if (hasPendingRequest) {
await fetch('/api/user/invitations/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team.id, type: 'team-join-request' }),
})
setInvitationId(null)
} else {
if (isInviteOnly) return
await fetch('/api/team/request-join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team.id }),
})
setInvitationId('pending')
}
const updated = await reloadTeam(team.id)
if (updated) setTeam(updated)
} catch (err) {
console.error('[TeamDetailClient] Join Fehler:', err)
alert('Aktion fehlgeschlagen.')
} finally {
setJoining(false)
}
}
/* ---------- Render ---------- */
@ -155,6 +234,8 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
}
if (!team) return null
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
return (
<Card maxWidth="auto">
{/* Header */}
@ -172,12 +253,34 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
{team.name}
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-400">
{counts.total} Mitglied{counts.total === 1 ? '' : 'er'}
{counts.all} Mitglied{counts.all === 1 ? '' : 'er'}
{team.leader?.name ? (
<span className="ml-2 text-gray-400 dark:text-neutral-500"> Leader: {team.leader.name}</span>
) : null}
</p>
</div>
{/* Button nur anzeigen, wenn Mitglied */}
{isMemberOfThisTeam && (
<div className="ml-auto">
<Button
size="sm"
color="red"
disabled={joining}
onClick={(e) => { e.preventDefault(); handleLeaveClick() }}
title="Team verlassen"
>
{joining ? (
<>
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
Lädt
</>
) : (
'Team verlassen'
)}
</Button>
</div>
)}
</div>
{/* Toolbar */}
@ -194,31 +297,24 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
dark:border-neutral-700 dark:bg-neutral-800 dark:text-white"
aria-label="Spieler suchen"
/>
<svg
aria-hidden
viewBox="0 0 24 24"
className="pointer-events-none absolute right-3 top-2.5 h-5 w-5 text-gray-400"
>
<path
fill="currentColor"
d="M21 20l-5.8-5.8A7 7 0 1 0 4 11a7 7 0 0 0 11.2 5.2L21 20zM6 11a5 5 0 1 1 10.001.001A5 5 0 0 1 6 11z"
/>
<svg aria-hidden viewBox="0 0 24 24" className="pointer-events-none absolute right-3 top-2.5 h-5 w-5 text-gray-400">
<path fill="currentColor" d="M21 20l-5.8-5.8A7 7 0 1 0 4 11a7 7 0 0 0 11.2 5.2L21 20zM6 11a5 5 0 1 1 10.001.001A5 5 0 0 1 6 11z" />
</svg>
</div>
{/* Segment */}
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
{([
{ key: 'active', label: `Aktiv (${counts.active})` },
{[
{ key: 'all', label: `Alle (${counts.all})` },
{ key: 'active', label: `Aktiv (${counts.active})` },
{ key: 'inactive', label: `Inaktiv (${counts.inactive})` },
{ key: 'invited', label: `Eingeladen (${counts.invited})` },
] as const).map((opt) => (
].map((opt) => (
<button
key={opt.key}
onClick={() => setSeg(opt.key)}
onClick={() => setSeg(opt.key as 'all' | 'active' | 'inactive')}
className={classNames(
'px-3 py-1.5 text-sm transition',
seg === opt.key
seg === (opt.key as 'all' | 'active' | 'inactive')
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800'
)}
@ -231,98 +327,51 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
{/* Grid */}
{filteredList.length === 0 ? (
<div
className="rounded-lg border border-dashed border-gray-200 p-8 text-center text-sm
text-gray-500 dark:border-neutral-700 dark:text-neutral-400"
>
<div className="rounded-lg border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-neutral-700 dark:text-neutral-400">
{q ? (
<>
Keine Treffer für <span className="font-medium">{q}</span>.
</>
<>Keine Treffer für <span className="font-medium">{q}</span>.</>
) : seg === 'active' ? (
'Keine aktiven Mitglieder.'
) : seg === 'inactive' ? (
'Keine inaktiven Mitglieder.'
) : (
'Keine eingeladenen Spieler.'
'Keine Mitglieder.'
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredList.map((m) => (
<div
key={m.steamId}
role="button"
tabIndex={0}
aria-label={`${m.name} Profil öffnen`}
onClick={onCardClick(m.steamId)}
onKeyDown={onCardKey(m.steamId)}
className="
group flex items-center gap-4 p-3 rounded-lg border
border-gray-200 dark:border-neutral-700
bg-white dark:bg-neutral-800 shadow-sm
transition cursor-pointer focus:outline-none
hover:shadow-md hover:bg-gray-50 dark:hover:bg-neutral-700
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
focus:ring-offset-white dark:focus:ring-offset-neutral-800
"
>
<Image
src={m.avatar}
alt={m.name}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
onClick={(e) => {
e.stopPropagation()
goToProfile(m.steamId)
}}
{filteredList.map((m) => {
const p = m as PlayerWithFaceitLevel
return (
<TeamPlayerCard
key={p.steamId}
steamId={p.steamId}
name={p.name}
avatar={p.avatar}
isLeader={team.leader?.steamId === p.steamId}
isActive={activeSet.has(p.steamId)}
rank={p.premierRank ?? 0}
faceitLevel={p.faceitLevel ?? null}
onOpen={(id) => router.push(`/profile/${id}`)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 dark:text-neutral-100 truncate">{m.name}</span>
{/* Leader-Badge (falls vorhanden) */}
{team.leader?.steamId === m.steamId && (
<span
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded
bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
>
Leader
</span>
)}
{/* Aktiv/Inaktiv */}
{seg !== 'invited' && (
<span
className={classNames(
'inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded',
team.activePlayers.some((a) => a.steamId === m.steamId)
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
)}
>
{team.activePlayers.some((a) => a.steamId === m.steamId) ? 'Aktiv' : 'Inaktiv'}
</span>
)}
{/* Rank-Badge (0 => unranked Style in deiner Badge) */}
<PremierRankBadge rank={(m as Player).premierRank ?? 0} />
</div>
{(m as Player).location && (
<div className="text-xs text-gray-500 dark:text-neutral-400">{(m as Player).location}</div>
)}
</div>
{/* Chevron */}
<svg aria-hidden viewBox="0 0 24 24" className="w-4 h-4 text-gray-400 group-hover:text-gray-500 transition">
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
</svg>
</div>
))}
)
})}
</div>
)}
{/* Leader-Leave-Modal (wie in TeamMemberView) */}
{isLeader && (
<LeaveTeamModal
show={showLeaveModal}
onClose={() => setShowLeaveModal(false)}
onSuccess={async () => {
setShowLeaveModal(false)
const updated = await reloadTeam(team.id)
if (updated) setTeam(updated)
}}
team={team}
/>
)}
</Card>
)
}

View File

@ -6,7 +6,11 @@ import { buildAuthOptions } from '@/lib/auth'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const handler = (req: NextRequest, ctx: any) =>
type NextAuthRouteContext = {
params: Promise<{ nextauth: string[] }>
}
const handler = (req: NextRequest, ctx: NextAuthRouteContext) =>
NextAuth(req, ctx, buildAuthOptions(req))
export { handler as GET, handler as POST }

View File

@ -10,7 +10,7 @@ export const dynamic = 'force-dynamic'
function buildPanelUrl(base: string, serverId: string) {
const u = new URL(base.includes('://') ? base : `https://${base}`)
const cleaned = (u.pathname || '').replace(/\/+$/,'')
const cleaned = (u.pathname || '').replace(/\/+$/, '')
// Client-API Endpoint
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
return u.toString()
@ -69,7 +69,7 @@ export async function POST(req: NextRequest) {
}
const text = await res.text().catch(() => '')
let payload: any
let payload: unknown
try { payload = JSON.parse(text) } catch { payload = text }
if (res.status === 401) {
@ -92,7 +92,11 @@ export async function POST(req: NextRequest) {
// In seltenen Fällen kommt JSON zurück
return NextResponse.json({ ok: true, status: res.status, response: payload }, { headers: { 'Cache-Control': 'no-store' } })
} catch (e: any) {
return NextResponse.json({ error: e?.message ?? 'request_failed' }, { status: 500 })
} catch (e: unknown) {
const message =
e instanceof Error ? e.message :
typeof e === 'string' ? e :
'request_failed'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@ -46,6 +46,28 @@ function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
] as const
}
/* ---------- leichte lokale DB-Shapes ---------- */
type VoteStepDb = {
id: string
order: number
action: MapVoteAction
teamId: string | null
map: string | null
chosenAt: Date | null
chosenBy: string | null
}
type VoteDb = {
id: string
bestOf: number
mapPool: string[]
currentIdx: number
locked: boolean
opensAt: Date | null
adminEditingBy: string | null
adminEditingSince: Date | null
steps: VoteStepDb[]
}
async function ensureVote(matchId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId },
@ -58,7 +80,7 @@ async function ensureVote(matchId: string) {
if (!match) return { match: null, vote: null }
// Wenn schon vorhanden → direkt zurück
if (match.mapVote) return { match, vote: match.mapVote }
if (match.mapVote) return { match, vote: match.mapVote as unknown as VoteDb }
// Default-Werte für neue Votes
const DEFAULT_BEST_OF = 3
@ -94,35 +116,41 @@ async function ensureVote(matchId: string) {
include: { steps: true },
})
return { match, vote: created }
return { match, vote: created as unknown as VoteDb }
}
/* ---------- Shapes ohne any ---------- */
function shapeAdminEdit(vote: any) {
function shapeAdminEdit(
vote: Pick<VoteDb, 'adminEditingBy' | 'adminEditingSince'>
) {
return vote.adminEditingBy
? {
enabled: true,
by: vote.adminEditingBy as string,
by: vote.adminEditingBy,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
}
: { enabled: false as const, by: null, since: null }
}
function shapeStateSlim(vote: any) {
function shapeStateSlim(vote: VoteDb) {
const sorted: VoteStepDb[] = [...vote.steps].sort((a, b) => a.order - b.order)
const steps = sorted.map((s) => ({
order : s.order,
action : mapActionToApi(s.action),
teamId : s.teamId,
map : s.map ?? null,
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
chosenBy: s.chosenBy ?? null,
}))
return {
bestOf: vote.bestOf as number,
mapPool: vote.mapPool as string[],
currentIndex: vote.currentIdx as number,
locked: vote.locked as boolean,
bestOf: vote.bestOf,
mapPool: vote.mapPool,
currentIndex: vote.currentIdx,
locked: vote.locked,
adminEdit: shapeAdminEdit(vote),
steps: [...vote.steps].sort((a: any, b: any) => a.order - b.order).map((s: any) => ({
order : s.order,
action : mapActionToApi(s.action),
teamId : s.teamId,
map : s.map ?? null,
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
chosenBy: s.chosenBy ?? null,
})),
steps,
}
}
@ -164,7 +192,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<Params> }) {
? { adminEditingBy: null, adminEditingSince: null }
: {}),
include: { steps: true },
})
}) as unknown as VoteDb
await sendServerSSEMessage({
type: 'map-vote-admin-edit',

View File

@ -6,36 +6,63 @@ import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import { createHash } from 'crypto'
import { MapVoteAction } from '@/generated/prisma'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
type Ctx = { params: Promise<{ matchId: string }> }
function shapeState(vote: any) {
const ACTION_MAP = { BAN: 'ban', PICK: 'pick', DECIDER: 'decider' } as const
// ---- DB Shapes (leichtgewichtige lokale Typen) ----
type VoteStepDb = {
id: string
order: number
action: MapVoteAction
teamId: string | null
map: string | null
chosenAt: Date | null
chosenBy: string | null
}
type VoteDb = {
id: string
bestOf: number
mapPool: string[]
currentIdx: number
locked: boolean
opensAt: Date | null
adminEditingBy: string | null
adminEditingSince: Date | null
steps: VoteStepDb[]
}
function shapeState(vote: VoteDb) {
const ACTION_MAP: Record<MapVoteAction, 'ban' | 'pick' | 'decider'> = {
BAN: 'ban',
PICK: 'pick',
DECIDER: 'decider',
}
const steps = [...vote.steps]
.sort((a, b) => a.order - b.order)
.map((s: any) => ({
order : s.order,
action : ACTION_MAP[s.action as keyof typeof ACTION_MAP],
teamId : s.teamId,
map : s.map,
.map((s: VoteStepDb) => ({
order: s.order,
action: ACTION_MAP[s.action],
teamId: s.teamId,
map: s.map,
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
chosenBy: s.chosenBy ?? null,
}))
return {
bestOf : vote.bestOf,
mapPool : vote.mapPool as string[],
bestOf: vote.bestOf,
mapPool: vote.mapPool,
currentIndex: vote.currentIdx,
locked : vote.locked as boolean,
opensAt : vote.opensAt ? new Date(vote.opensAt).toISOString() : null,
locked: vote.locked,
opensAt: vote.opensAt ? new Date(vote.opensAt).toISOString() : null,
steps,
adminEdit: vote.adminEditingBy
? {
enabled: true,
by: vote.adminEditingBy as string,
by: vote.adminEditingBy,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
}
: { enabled: false, by: null, since: null },
@ -84,14 +111,14 @@ export async function POST(req: NextRequest, ctx: Ctx) {
vote.steps.map(s =>
tx.mapVoteStep.update({
where: { id: s.id },
data : { map: null, chosenAt: null, chosenBy: null },
data: { map: null, chosenAt: null, chosenBy: null },
})
)
)
await tx.mapVote.update({
where: { id: vote.id },
data : {
data: {
currentIdx: 0,
locked: false,
adminEditingBy: null,
@ -102,10 +129,11 @@ export async function POST(req: NextRequest, ctx: Ctx) {
await tx.matchReady.deleteMany({ where: { matchId } })
})
const updated = await prisma.mapVote.findUnique({
const updated = (await prisma.mapVote.findUnique({
where: { id: vote.id },
include: { steps: true },
})
})) as VoteDb | null
if (!updated) {
return NextResponse.json({ message: 'Reset fehlgeschlagen' }, { status: 500 })
}
@ -123,4 +151,4 @@ export async function POST(req: NextRequest, ctx: Ctx) {
console.error('[map-vote-reset][POST] error', e)
return NextResponse.json({ message: 'Reset fehlgeschlagen' }, { status: 500 })
}
}
}

View File

@ -674,8 +674,8 @@ export async function POST(req: NextRequest, ctx: Ctx) {
if (vote.locked) return NextResponse.json({ message: 'Voting bereits abgeschlossen' }, { status: 409 })
// Aktuellen Schritt bestimmen
const stepsSorted = [...vote.steps].sort((a: any, b: any) => a.order - b.order)
const current = stepsSorted.find((s: any) => s.order === vote.currentIdx)
const stepsSorted: VoteStepDb[] = [...vote.steps].sort(byOrder)
const current = stepsSorted.find(s => s.order === vote.currentIdx)
if (!current) {
// Kein Schritt mehr -> Vote abschließen
@ -796,8 +796,8 @@ export async function POST(req: NextRequest, ctx: Ctx) {
}
// Rechte prüfen (Admin oder Leader des Teams am Zug)
const isLeaderA = !!(match as any).teamA?.leaderId && (match as any).teamA.leaderId === me.steamId
const isLeaderB = !!(match as any).teamB?.leaderId && (match as any).teamB.leaderId === me.steamId
const isLeaderA = match.teamA?.leaderId === me.steamId
const isLeaderB = match.teamB?.leaderId === me.steamId
const allowed = me.isAdmin || (current.teamId && (
(current.teamId === match.teamA?.id && isLeaderA) ||
(current.teamId === match.teamB?.id && isLeaderB)
@ -822,15 +822,15 @@ export async function POST(req: NextRequest, ctx: Ctx) {
const after = await tx.mapVote.findUnique({
where : { id: vote.id },
include: { steps: true },
})
}) as (Pick<VoteDb, 'id' | 'currentIdx' | 'steps' | 'mapPool'> | null)
if (!after) return
const stepsAfter = [...after.steps].sort((a: any, b: any) => a.order - b.order)
let idx = after.currentIdx + 1
let locked = false
// Falls nächster Schritt DECIDER und genau 1 Map übrig -> auto setzen & locken
const stepsAfter: VoteStepDb[] = [...after.steps].sort(byOrder)
let idx = after.currentIdx + 1
let lockedFlag = false
const next = stepsAfter.find(s => s.order === idx)
if (next?.action === 'DECIDER') {
const avail = computeAvailableMaps(after.mapPool, stepsAfter)
if (avail.length === 1) {
@ -839,21 +839,19 @@ export async function POST(req: NextRequest, ctx: Ctx) {
data : { map: avail[0], chosenAt: new Date(), chosenBy: me.steamId },
})
idx += 1
locked = true
lockedFlag = true
}
}
// Ende erreicht?
const maxOrder = Math.max(...stepsAfter.map(s => s.order))
if (idx > maxOrder) locked = true
if (idx > maxOrder) lockedFlag = true
await tx.mapVote.update({
where: { id: after.id },
data : {
currentIdx: idx,
locked,
// ➜ Nur wenn jetzt abgeschlossen: Admin-Edit beenden
...(locked ? { adminEditingBy: null, adminEditingSince: null } : {}),
locked: lockedFlag,
...(lockedFlag ? { adminEditingBy: null, adminEditingSince: null } : {}),
},
})
})

View File

@ -13,6 +13,19 @@ export const dynamic = 'force-dynamic'
type Ctx = { params: Promise<{ matchId: string }> }
// oberhalb der PUT-Funktion (z.B. bei den Helper-Types)
type RelConnect = { connect: { id: string } }
type RelDisconnect = { disconnect: true }
type MatchUpdateData = {
title?: string
matchType?: string
teamA?: RelConnect | RelDisconnect
teamB?: RelConnect | RelDisconnect
matchDate?: Date | null
demoDate?: Date | null
}
// Hilfsfunktion: akzeptiert Date | string | number | null | undefined
function parseDateOrNull(v: unknown): Date | null | undefined {
if (typeof v === 'undefined') return undefined
@ -126,7 +139,7 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
}
// 1) Match-Felder zusammenbauen
const updateData: any = {}
const updateData: MatchUpdateData = {}
if (typeof title === 'string') updateData.title = title
if (typeof matchType === 'string') updateData.matchType = matchType

View File

@ -11,29 +11,52 @@ export const dynamic = 'force-dynamic'
type Ctx = { params: Promise<{ matchId: string }> }
/** ---- Helpers: Spieler-Liste aus Match bauen ---- */
function shapeUser(u: any) {
if (!u) return null
return {
steamId: u.steamId,
name : u.name ?? '',
avatar : u.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
}
// ---- Mini-Typen (nur die Felder, die wir wirklich brauchen) ----
type UserLite = {
steamId?: string
name?: string | null
avatar?: string | null
}
function buildParticipants(match: any) {
const teamAUsers = (match.teamAUsers ?? []).map(shapeUser).filter(Boolean)
const teamBUsers = (match.teamBUsers ?? []).map(shapeUser).filter(Boolean)
type MatchForParticipants = {
teamAUsers?: UserLite[]
teamBUsers?: UserLite[]
players?: Array<{ user?: UserLite | null }>
}
type ShapedUser = { steamId: string; name: string; avatar: string }
type Participant = ShapedUser & { team: 'A' | 'B' | null }
// kleine Guard
const isObj = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object'
/** ---- Helpers: Spieler-Liste aus Match bauen ---- */
function shapeUser(u: unknown): ShapedUser | null {
if (!isObj(u)) return null
const steamId = typeof u.steamId === 'string' ? u.steamId : null
if (!steamId) return null
const name = typeof u.name === 'string' ? u.name : ''
const avatar =
typeof u.avatar === 'string'
? u.avatar
: '/assets/img/avatars/default_steam_avatar.jpg'
return { steamId, name, avatar }
}
function buildParticipants(match: MatchForParticipants): Participant[] {
const teamAUsers = (match.teamAUsers ?? []).map(shapeUser).filter(Boolean) as ShapedUser[]
const teamBUsers = (match.teamBUsers ?? []).map(shapeUser).filter(Boolean) as ShapedUser[]
// Fallback: aus match.players → user ziehen, falls teamA/B leer wären
const fromPlayers = (match.players ?? [])
.map((mp: any) => mp?.user)
.map((mp: { user?: UserLite | null }) => mp?.user)
.map(shapeUser)
.filter(Boolean)
.filter(Boolean) as ShapedUser[]
// deduplizieren Teamzuordnung merken
const seen = new Set<string>()
const out: Array<{steamId: string; name: string; avatar: string; team: 'A'|'B'|null}> = []
const out: Participant[] = []
for (const u of teamAUsers) {
if (!u || seen.has(u.steamId)) continue
@ -59,7 +82,7 @@ export async function GET(_req: NextRequest, ctx: Ctx) {
const { matchId } = await ctx.params
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
// ⬇️ Session holen, um myReady zu berechnen
// Session holen, um myReady zu berechnen
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId?: string } | undefined
const mySteamId = me?.steamId
@ -76,7 +99,7 @@ export async function GET(_req: NextRequest, ctx: Ctx) {
})
if (!match) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const participants = buildParticipants(match)
const participants = buildParticipants(match as unknown as MatchForParticipants)
const readyRows = await prisma.matchReady.findMany({
where: { matchId },
select: { steamId: true, acceptedAt: true },
@ -88,16 +111,19 @@ export async function GET(_req: NextRequest, ctx: Ctx) {
const total = participants.length
const countReady = participants.filter(p => !!readyMap[p.steamId]).length
// ⬇️ neu: eigener Ready-Status
// eigener Ready-Status
const myReady = !!(mySteamId && readyMap[mySteamId])
return NextResponse.json({
participants,
ready: readyMap,
total,
countReady,
myReady, // <-- neu
}, { headers: { 'Cache-Control': 'no-store' } })
return NextResponse.json(
{
participants,
ready: readyMap,
total,
countReady,
myReady,
},
{ headers: { 'Cache-Control': 'no-store' } }
)
} catch (e) {
console.error('[match-ready][GET] error', e)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
@ -116,8 +142,13 @@ export async function POST(req: NextRequest, ctx: Ctx) {
// --- Intent-Schutz: Header + Body müssen passen ---
const hdr = req.headers.get('X-Ready-Accept')
let body: any = null
try { body = await req.json() } catch {}
type ReadyBody = { intent?: 'accept' | string }
let body: ReadyBody | null = null
try {
body = (await req.json()) as ReadyBody
} catch {
body = null
}
if (hdr !== '1' || body?.intent !== 'accept') {
return NextResponse.json({ message: 'Invalid ready intent' }, { status: 400 })
}
@ -132,13 +163,13 @@ export async function POST(req: NextRequest, ctx: Ctx) {
const participants = new Set<string>([
...match.teamAUsers.map(u => u.steamId),
...match.teamBUsers.map(u => u.steamId),
...match.players.map(p => p.user?.steamId).filter(Boolean) as string[],
...match.players.map(p => p.user?.steamId).filter((x): x is string => typeof x === 'string'),
])
if (!participants.has(me.steamId)) {
return NextResponse.json({ message: 'Not a participant' }, { status: 403 })
}
// --- Idempotent upsert: nur dann „ready“, wenn Intent korrekt war ---
// --- Idempotent upsert ---
await prisma.matchReady.upsert({
where: { matchId_steamId: { matchId, steamId: me.steamId } },
create: { matchId, steamId: me.steamId },

View File

@ -10,12 +10,32 @@ import {
buildDefaultPayload,
} from './_builders'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import type { Player } from '@/types/team'
const isObj = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object'
type Ctx = { params: Promise<{ matchId: string }> }
// Exaktes Typ-Shape für das ausgewählte user-Objekt aus Prisma:
type DbUserSelected = {
steamId: string
name: string | null
avatar: string | null
location: string | null
premierRank: number | null
isAdmin: boolean
vacBanned: boolean | null
numberOfVACBans: number | null
numberOfGameBans: number | null
communityBanned: boolean | null
economyBan: string | null
daysSinceLastBan: number | null
lastBanCheck: Date | null
}
// prisma User -> schlanker FE-User inkl. banStatus
function packUser(u: any) {
function packUser(u: DbUserSelected): Player {
return {
steamId: u.steamId,
name: u.name ?? 'Unbekannt',
@ -35,6 +55,11 @@ function packUser(u: any) {
}
}
type BasePayload = {
mapVote?: unknown | null
teamA?: Record<string, unknown> | null
teamB?: Record<string, unknown> | null
} & Record<string, unknown>
/* ───────────────────────── GET ───────────────────────── */
export async function GET(_req: Request, ctx: Ctx) {
@ -76,9 +101,9 @@ export async function GET(_req: Request, ctx: Ctx) {
if (!m) return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
const payload =
m.matchType === 'community' && isFuture(m)
(m.matchType === 'community' && isFuture(m)
? await buildCommunityFuturePayload(m)
: buildDefaultPayload(m);
: buildDefaultPayload(m)) as BasePayload
// ⬇️ Zusatz: opensAt (und leadMinutes) an die Antwort hängen
const baseTs = (m.matchDate ?? m.demoDate)?.getTime?.() ?? null
@ -89,23 +114,23 @@ export async function GET(_req: Request, ctx: Ctx) {
: null
// Spieler für A/B aufteilen & mappen
const setA = new Set(m.teamAUsers.map(u => u.steamId));
const setB = new Set(m.teamBUsers.map(u => u.steamId));
const setA = new Set(m.teamAUsers.map(u => u.steamId))
const setB = new Set(m.teamBUsers.map(u => u.steamId))
const playersA = m.players
.filter(p => setA.has(p.steamId))
.map(p => ({ user: packUser(p.user), stats: p.stats ?? undefined }));
.map(p => ({ user: packUser(p.user as unknown as DbUserSelected), stats: p.stats ?? undefined }))
const playersB = m.players
.filter(p => setB.has(p.steamId))
.map(p => ({ user: packUser(p.user), stats: p.stats ?? undefined }));
.map(p => ({ user: packUser(p.user as unknown as DbUserSelected), stats: p.stats ?? undefined }))
// MapVote formen (opensAt als ISO-String!)
const mapVotePayload = m.mapVote
? {
locked: m.mapVote.locked,
isOpen: !m.mapVote.locked && (opensAt ? Date.now() >= opensAt.getTime() : false),
opensAt: opensAt ? opensAt.toISOString() : null, // <-- wichtig
opensAt: opensAt ? opensAt.toISOString() : null,
leadMinutes,
steps: m.mapVote.steps
.sort((a, b) => a.order - b.order)
@ -124,17 +149,31 @@ export async function GET(_req: Request, ctx: Ctx) {
.map(s => s.map as string),
},
}
: null;
: null
return NextResponse.json({
...payload,
mapVote: {
...(payload as any).mapVote,
...(mapVotePayload ?? {}),
// 🔧 any-freier Merge
const mergedMapVote = {
...(isObj(payload.mapVote) ? payload.mapVote : {}),
...(mapVotePayload ?? {}),
}
const mergedTeamA = {
...(isObj(payload.teamA) ? payload.teamA : {}),
players: playersA,
}
const mergedTeamB = {
...(isObj(payload.teamB) ? payload.teamB : {}),
players: playersB,
}
return NextResponse.json(
{
...payload,
mapVote: mergedMapVote,
teamA: mergedTeamA,
teamB: mergedTeamB,
},
teamA: { ...(payload as any).teamA, players: playersA },
teamB: { ...(payload as any).teamB, players: playersB },
}, { headers: { 'Cache-Control': 'no-store' } });
{ headers: { 'Cache-Control': 'no-store' } }
)
} catch (err) {
console.error(`GET /matches/${id} failed:`, err)
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
@ -217,25 +256,49 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
try {
await prisma.$transaction(async (tx) => {
// 1) teamAUsers / teamBUsers SETZEN (nur die Seite(n), die editiert werden)
const data: any = {}
const data: Record<string, unknown> = {}
if (editorSide === 'both' || editorSide === 'A') {
data.teamAUsers = {
set: [], // erst leeren
;(data as { teamAUsers?: unknown }).teamAUsers = {
set: [],
connect: aSteamIds.map(steamId => ({ steamId })),
}
}
if (editorSide === 'both' || editorSide === 'B') {
data.teamBUsers = {
;(data as { teamBUsers?: unknown }).teamBUsers = {
set: [],
connect: bSteamIds.map(steamId => ({ steamId })),
}
}
if (Object.keys(data).length) {
await tx.match.update({ where: { id }, data })
// 👇 sauber typbares Update-Objekt mit Conditional-Spreads
const dataForUpdate = {
...(editorSide === 'both' || editorSide === 'A'
? {
teamAUsers: {
set: [] as never[], // leeren
connect: aSteamIds.map((steamId) => ({ steamId })),
},
}
: {}),
...(editorSide === 'both' || editorSide === 'B'
? {
teamBUsers: {
set: [] as never[],
connect: bSteamIds.map((steamId) => ({ steamId })),
},
}
: {}),
};
if (Object.keys(dataForUpdate).length) {
await tx.match.update({
where: { id },
data: dataForUpdate, // <-- Prisma inferiert die korrekten Typen
});
}
// 2) (optional) matchPlayer für die geänderten Seiten synchron halten
// so bleiben Teilnehmer + spätere Stats konsistent.
// 2) matchPlayer je Seite synchron halten
if (editorSide === 'both' || editorSide === 'A') {
if (hasTeamIds) {
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamAId! } })
@ -246,7 +309,6 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
})
}
} else {
// Community ohne teamId → anhand Roster löschen
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterA) } } })
if (aSteamIds.length) {
await tx.matchPlayer.createMany({
@ -308,48 +370,47 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
},
})
// Trennung für Response (gleich wie bisher)
const setA = new Set(updated.teamAUsers.map(u => u.steamId))
const setB = new Set(updated.teamBUsers.map(u => u.steamId))
const playersA = updated.players
.filter(p => setA.has(p.steamId))
.map(p => ({ user: packUser(p.user), stats: p.stats ?? undefined }))
.map(p => ({ user: packUser(p.user as unknown as DbUserSelected), stats: p.stats ?? undefined }))
const playersB = updated.players
.filter(p => setB.has(p.steamId))
.map(p => ({ user: packUser(p.user), stats: p.stats ?? undefined }))
.map(p => ({ user: packUser(p.user as unknown as DbUserSelected), stats: p.stats ?? undefined }))
return NextResponse.json({
id: updated.id,
title: updated.title,
description: updated.description,
demoDate: updated.demoDate,
matchType: updated.matchType,
roundCount: updated.roundCount,
map: updated.map,
teamA: {
id: updated.teamAId ?? null,
name: updated.teamAUsers[0]?.team?.name ?? updated.teamA?.name,
logo: updated.teamAUsers[0]?.team?.logo ?? updated.teamA?.logo ?? null,
score: updated.scoreA,
players: playersA,
return NextResponse.json(
{
id: updated.id,
title: updated.title,
description: updated.description,
demoDate: updated.demoDate,
matchType: updated.matchType,
roundCount: updated.roundCount,
map: updated.map,
teamA: {
id: updated.teamAId ?? null,
name: updated.teamAUsers[0]?.team?.name ?? updated.teamA?.name,
logo: updated.teamAUsers[0]?.team?.logo ?? updated.teamA?.logo ?? null,
score: updated.scoreA,
players: playersA,
},
teamB: {
id: updated.teamBId ?? null,
name: updated.teamBUsers[0]?.team?.name ?? updated.teamB?.name,
logo: updated.teamBUsers[0]?.team?.logo ?? updated.teamB?.logo ?? null,
score: updated.scoreB,
players: playersB,
},
},
teamB: {
id: updated.teamBId ?? null,
name: updated.teamBUsers[0]?.team?.name ?? updated.teamB?.name,
logo: updated.teamBUsers[0]?.team?.logo ?? updated.teamB?.logo ?? null,
score: updated.scoreB,
players: playersB,
},
}, { headers: { 'Cache-Control': 'no-store' } })
{ headers: { 'Cache-Control': 'no-store' } }
)
} catch (err) {
console.error(`PUT /matches/${id} failed:`, err)
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
}
}
/* ───────────────────────── DELETE ───────────────────────── */
export async function DELETE(_req: NextRequest, ctx: Ctx) {
const { matchId: id } = await ctx.params

View File

@ -7,14 +7,23 @@ import type { AsyncParams } from '@/types/next'
export const dynamic = 'force-dynamic'
export const revalidate = 0
// Wiederverwendbares Select für Faceit (nur cs2, skill + elo)
const FACEIT_SELECT = {
faceitGames: {
where: { game: 'cs2' as const },
select: { skillLevel: true, elo: true },
take: 1,
},
}
export async function GET(
_req: NextRequest,
ctx: AsyncParams<{ teamId: string }>
) {
try {
const { teamId } = await ctx.params // ← Promise-Params auflösen
const { teamId } = await ctx.params
/* 1) Team + Leader + Invites (mit user) laden */
/* 1) Team + Leader + Invites laden (inkl. Faceit) */
const team = await prisma.team.findUnique({
where: { id: teamId },
include: {
@ -26,6 +35,7 @@ export async function GET(
location: true,
premierRank: true,
isAdmin: true,
...FACEIT_SELECT, // ⬅︎ Faceit für Leader
},
},
invites: {
@ -38,6 +48,7 @@ export async function GET(
location: true,
premierRank: true,
isAdmin: true,
...FACEIT_SELECT, // ⬅︎ Faceit für Eingeladene
},
},
},
@ -49,7 +60,7 @@ export async function GET(
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
}
/* 2) Aktive + Inaktive Spieler-Objekte bauen */
/* 2) Aktive + Inaktive Spieler laden (inkl. Faceit) */
const allIds = Array.from(new Set([...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]))
const users = allIds.length
@ -62,11 +73,12 @@ export async function GET(
location: true,
premierRank: true,
isAdmin: true,
...FACEIT_SELECT, // ⬅︎ Faceit für reguläre Mitglieder
},
})
: []
// 1) Gemeinsamer Typ für alle "User"-Objekte, die wir in Player mappen
// Typ-Helfer für den Mapper
type UserLike = {
steamId: string
name: string | null
@ -74,17 +86,23 @@ export async function GET(
location: string | null
premierRank: number | null
isAdmin: boolean | null
faceitGames?: { skillLevel: number | null; elo: number | null }[] // ⬅︎ neu
}
// 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',
location: u.location ?? undefined,
premierRank: u.premierRank ?? undefined,
isAdmin: u.isAdmin ?? undefined,
})
// Prisma -> Player
const toPlayer = (u: UserLike): Player => {
const fg = u.faceitGames?.[0] // nur cs2, siehe FACEIT_SELECT.take=1
return {
steamId: u.steamId,
name: u.name ?? 'Unbekannt',
avatar: u.avatar ?? '/assets/img/avatars/default.png',
location: u.location ?? undefined,
premierRank: u.premierRank ?? undefined,
isAdmin: u.isAdmin ?? undefined,
faceitLevel: fg?.skillLevel ?? null,
faceitElo: fg?.elo ?? null,
}
}
const byId: Record<string, Player> = Object.fromEntries(users.map(u => [u.steamId, toPlayer(u)]))
const safeSort = (a?: string, b?: string) => (a ?? '').localeCompare(b ?? '')
@ -102,21 +120,17 @@ export async function GET(
/* 3) Eingeladene Spieler inkl. invitationId */
const invitedPlayers: InvitedPlayer[] = (team.invites ?? [])
.map(inv => {
const u = inv.user
const u = inv.user as UserLike
const p = toPlayer(u)
return {
invitationId: inv.id,
steamId: u.steamId,
name: u.name ?? 'Unbekannt',
avatar: u.avatar ?? '/assets/img/avatars/default.png',
location: u.location ?? undefined,
premierRank: u.premierRank ?? undefined,
isAdmin: u.isAdmin ?? undefined,
...p,
}
})
.sort((a, b) => safeSort(a.name, b.name))
/* 4) Leader als Player-Objekt */
const leader: Player | undefined = team.leader ? toPlayer(team.leader) : undefined
const leader: Player | undefined = team.leader ? toPlayer(team.leader as UserLike) : undefined
/* 5) Antwort */
const result = {

View File

@ -44,26 +44,6 @@ export async function POST(req: NextRequest) {
data: { teamId: newTeam.id },
})
const note = await prisma.notification.create({
data: {
steamId: leader,
title: 'Team erstellt',
message: `Du hast erfolgreich das Team „${teamname}“ erstellt.`,
actionType: 'team-created',
actionData: newTeam.id,
},
})
await sendServerSSEMessage({
type: 'notification',
targetUserIds: [leader],
message: note.message,
id: note.id,
actionType: note.actionType ?? undefined,
actionData: note.actionData ?? undefined,
createdAt: note.createdAt.toISOString(),
})
await sendServerSSEMessage({
type: 'self-updated',
targetUserIds: [leader],

View File

@ -92,7 +92,7 @@ export async function POST(req: NextRequest) {
data: {
steamId : uid,
title : 'Team gelöscht',
message : `Das Team „${team.name}“ wurde gelöscht. Du bist nun in keinem Team mehr.`,
message : `Das Team „${team.name}“ wurde gelöscht.`,
actionType: 'user-team-cleared',
actionData: team.id,
},

View File

@ -20,6 +20,8 @@ export type Player = {
premierRank?: number
isAdmin?: boolean
banStatus?: BanStatus
faceitLevel?: number | null
faceitElo?: number | null
}
export type InvitedPlayer = Player & {