updated
This commit is contained in:
parent
99ad158526
commit
19bf9f7c9e
@ -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 ---------- */
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 ------- */
|
||||
|
||||
|
||||
122
src/app/[locale]/components/TeamPlayerCard.tsx
Normal file
122
src/app/[locale]/components/TeamPlayerCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -20,6 +20,8 @@ export type Player = {
|
||||
premierRank?: number
|
||||
isAdmin?: boolean
|
||||
banStatus?: BanStatus
|
||||
faceitLevel?: number | null
|
||||
faceitElo?: number | null
|
||||
}
|
||||
|
||||
export type InvitedPlayer = Player & {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user