updated
This commit is contained in:
parent
99ad158526
commit
19bf9f7c9e
@ -109,6 +109,9 @@ type BaseProps<TType extends ChartJSType = ChartJSType> = {
|
|||||||
radarAddRingOffset?: boolean;
|
radarAddRingOffset?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SafeRadialTicks = NonNullable<RadialLinearScaleOptions['ticks']>;
|
||||||
|
type SafePointLabels = NonNullable<RadialLinearScaleOptions['pointLabels']>;
|
||||||
|
|
||||||
const RADAR_OFFSET = 20;
|
const RADAR_OFFSET = 20;
|
||||||
|
|
||||||
const imgCache = new Map<string, HTMLImageElement>();
|
const imgCache = new Map<string, HTMLImageElement>();
|
||||||
@ -193,10 +196,7 @@ function ChartInner<TType extends ChartJSType = ChartJSType>(
|
|||||||
const radarScaleOpts = useMemo(() => {
|
const radarScaleOpts = useMemo(() => {
|
||||||
if (type !== 'radar') return undefined;
|
if (type !== 'radar') return undefined;
|
||||||
|
|
||||||
const gridColor = 'rgba(255,255,255,0.10)';
|
const ticksObj = {
|
||||||
const angleColor = 'rgba(255,255,255,0.12)';
|
|
||||||
|
|
||||||
const ticks: NonNullable<RadialLinearScaleOptions['ticks']> = {
|
|
||||||
display: true,
|
display: true,
|
||||||
color: 'rgba(255,255,255,0.6)',
|
color: 'rgba(255,255,255,0.6)',
|
||||||
font: { size: 12 },
|
font: { size: 12 },
|
||||||
@ -209,10 +209,10 @@ function ChartInner<TType extends ChartJSType = ChartJSType>(
|
|||||||
z: 0,
|
z: 0,
|
||||||
major: { enabled: false },
|
major: { enabled: false },
|
||||||
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
|
...(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,
|
display: false,
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
font: { size: 12 },
|
font: { size: 12 },
|
||||||
@ -221,34 +221,34 @@ function ChartInner<TType extends ChartJSType = ChartJSType>(
|
|||||||
backdropPadding: 0,
|
backdropPadding: 0,
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
centerPointLabels: false,
|
centerPointLabels: false,
|
||||||
};
|
callback: (label: string) => label,
|
||||||
|
} satisfies Partial<SafePointLabels>;
|
||||||
|
|
||||||
// ⬇︎ HIER: r als RadialLinearScaleOptions typisieren
|
const r: Partial<RadialLinearScaleOptions> = {
|
||||||
const r: RadialLinearScaleOptions = {
|
|
||||||
display: true,
|
display: true,
|
||||||
alignToPixels: false,
|
alignToPixels: false,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
reverse: false,
|
reverse: false,
|
||||||
ticks,
|
ticks: ticksObj as unknown as SafeRadialTicks,
|
||||||
grid: { color: gridColor, lineWidth: 1 },
|
pointLabels: pointLabelsObj as unknown as SafePointLabels,
|
||||||
|
grid: { color: 'rgba(255,255,255,0.10)', lineWidth: 1 },
|
||||||
angleLines: {
|
angleLines: {
|
||||||
display: true,
|
display: true,
|
||||||
color: angleColor,
|
color: 'rgba(255,255,255,0.12)',
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
borderDash: [],
|
borderDash: [],
|
||||||
borderDashOffset: 0,
|
borderDashOffset: 0,
|
||||||
},
|
},
|
||||||
pointLabels,
|
|
||||||
suggestedMin: 0,
|
suggestedMin: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ⬇︎ ohne any cast
|
|
||||||
if (typeof radarMax === 'number') {
|
if (typeof radarMax === 'number') {
|
||||||
r.max = radarMax + RADAR_OFFSET;
|
r.max = radarMax + RADAR_OFFSET;
|
||||||
r.suggestedMax = radarMax + RADAR_OFFSET;
|
r.suggestedMax = radarMax + RADAR_OFFSET;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { r };
|
// Am Ende in die strikte Chart.js-Option gießen
|
||||||
|
return { r: r as unknown as RadialLinearScaleOptions };
|
||||||
}, [type, radarStepSize, radarMax]);
|
}, [type, radarStepSize, radarMax]);
|
||||||
|
|
||||||
/* ---------- Radar Icons Plugin ---------- */
|
/* ---------- Radar Icons Plugin ---------- */
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type GameBannerProps = {
|
|||||||
visible?: boolean
|
visible?: boolean
|
||||||
zIndex?: number
|
zIndex?: number
|
||||||
inline?: boolean
|
inline?: boolean
|
||||||
|
serverLabel?: string
|
||||||
mapKey?: string
|
mapKey?: string
|
||||||
mapLabel?: string
|
mapLabel?: string
|
||||||
bgUrl?: string
|
bgUrl?: string
|
||||||
@ -146,6 +147,7 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
|||||||
visible: visibleProp,
|
visible: visibleProp,
|
||||||
zIndex: zIndexProp,
|
zIndex: zIndexProp,
|
||||||
inline = false,
|
inline = false,
|
||||||
|
serverLabel,
|
||||||
mapKey: mapKeyProp,
|
mapKey: mapKeyProp,
|
||||||
mapLabel: mapLabelProp,
|
mapLabel: mapLabelProp,
|
||||||
bgUrl: bgUrlProp,
|
bgUrl: bgUrlProp,
|
||||||
@ -380,6 +382,9 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
|||||||
|
|
||||||
const InfoRow = () => (
|
const InfoRow = () => (
|
||||||
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
|
<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>Map: <span className="font-semibold">{pretty.map}</span></span>
|
||||||
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
|
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
|
||||||
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{shownConnected}</span> / {totalExpected}</span>
|
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{shownConnected}</span> / {totalExpected}</span>
|
||||||
|
|||||||
@ -74,7 +74,6 @@ export default function GameBannerHost() {
|
|||||||
score={banner?.score ?? '– : –'}
|
score={banner?.score ?? '– : –'}
|
||||||
connectedCount={banner?.connectedCount ?? 0}
|
connectedCount={banner?.connectedCount ?? 0}
|
||||||
totalExpected={banner?.totalExpected ?? (cfg?.activeParticipants?.length ?? 0)}
|
totalExpected={banner?.totalExpected ?? (cfg?.activeParticipants?.length ?? 0)}
|
||||||
missingCount={banner?.missingCount ?? 0}
|
|
||||||
onReconnect={() => {/* optional: auf connectUri navigieren */}}
|
onReconnect={() => {/* optional: auf connectUri navigieren */}}
|
||||||
onDisconnect={() => setBanner(null)}
|
onDisconnect={() => setBanner(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -243,8 +243,10 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)');
|
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)');
|
||||||
setState(json);
|
setState(json);
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
if ((e as any)?.name === 'AbortError') return; // wurde abgebrochen -> still
|
// Fetch-Abbruch sauber erkennen (läuft in allen Browsern)
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') return;
|
||||||
|
|
||||||
setState(null);
|
setState(null);
|
||||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
|
||||||
} finally {
|
} finally {
|
||||||
@ -792,7 +794,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
|
|
||||||
scheduleReload(); // <— statt load()
|
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
|
// Effect NUR an stabile Keys + Tab hängen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -228,7 +228,7 @@ export default function TeamCardComponent({
|
|||||||
} finally {
|
} finally {
|
||||||
softReloadInFlight.current = false;
|
softReloadInFlight.current = false;
|
||||||
}
|
}
|
||||||
}, [pendingInvitations.length, selectedTeam?.id]);
|
}, [pendingInvitations.length]);
|
||||||
|
|
||||||
/* ------- SSE-gestützte Updates (dedupliziert) ------- */
|
/* ------- SSE-gestützte Updates (dedupliziert) ------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -293,7 +293,6 @@ export default function TeamCardComponent({
|
|||||||
if (!selectedTeam) return;
|
if (!selectedTeam) return;
|
||||||
if (Array.isArray(selectedTeam.invitedPlayers)) return;
|
if (Array.isArray(selectedTeam.invitedPlayers)) return;
|
||||||
|
|
||||||
// schon für diese ID vollgeladen?
|
|
||||||
if (fullLoadedFor.current.has(selectedTeam.id)) return;
|
if (fullLoadedFor.current.has(selectedTeam.id)) return;
|
||||||
fullLoadedFor.current.add(selectedTeam.id);
|
fullLoadedFor.current.add(selectedTeam.id);
|
||||||
|
|
||||||
@ -306,7 +305,7 @@ export default function TeamCardComponent({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [selectedTeam?.id]);
|
}, [selectedTeam]);
|
||||||
|
|
||||||
/* ------- Render-Zweige ------- */
|
/* ------- 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 ───────── */
|
/* ───────── small utils/types ───────── */
|
||||||
type UnknownRecord = Record<string, unknown>
|
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 isObj = (v: unknown): v is UnknownRecord => !!v && typeof v === 'object'
|
||||||
|
|
||||||
const pickFirst = (o: UnknownRecord, keys: string[]): unknown =>
|
const pickFirst = (o: UnknownRecord, keys: string[]): unknown =>
|
||||||
@ -236,7 +242,7 @@ export default function LiveRadar() {
|
|||||||
const [bomb, setBomb] = useState<BombState | null>(null)
|
const [bomb, setBomb] = useState<BombState | null>(null)
|
||||||
|
|
||||||
const ensureTeamsLoaded = useAvatarDirectoryStore((s) => s.ensureTeamsLoaded)
|
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)
|
const [useAvatars, setUseAvatars] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -482,7 +488,7 @@ export default function LiveRadar() {
|
|||||||
// Upsert
|
// Upsert
|
||||||
playersRef.current.set(id, {
|
playersRef.current.set(id, {
|
||||||
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),
|
team: mapTeam(pickFirst(e, ['team']) ?? old?.team),
|
||||||
x, y, z,
|
x, y, z,
|
||||||
yaw: Number.isFinite(yaw ?? NaN) ? (yaw as number) : (old?.yaw ?? null),
|
yaw: Number.isFinite(yaw ?? NaN) ? (yaw as number) : (old?.yaw ?? null),
|
||||||
@ -569,7 +575,7 @@ export default function LiveRadar() {
|
|||||||
|
|
||||||
playersRef.current.set(id, {
|
playersRef.current.set(id, {
|
||||||
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),
|
team: mapTeam((pRaw as UnknownRecord).team ?? old?.team),
|
||||||
x, y, z,
|
x, y, z,
|
||||||
yaw: Number.isFinite(yawDeg) ? yawDeg : (old?.yaw ?? null),
|
yaw: Number.isFinite(yawDeg) ? yawDeg : (old?.yaw ?? null),
|
||||||
@ -1148,17 +1154,28 @@ export default function LiveRadar() {
|
|||||||
}
|
}
|
||||||
}, [isBeepActive, bomb])
|
}, [isBeepActive, bomb])
|
||||||
|
|
||||||
if (!isAuthed) {
|
const avatarByIdNormalized = useMemo(() => {
|
||||||
return (
|
const out: Record<string, { avatar?: string; notFound?: boolean } | undefined> = {};
|
||||||
<div className="h-full w-full grid place-items-center">
|
|
||||||
<div className="text-center max-w-sm">
|
for (const [id, v] of Object.entries(avatarById)) {
|
||||||
<h2 className="text-xl font-semibold mb-2">Live Radar</h2>
|
if (!v) continue;
|
||||||
<p className="opacity-80">Bitte einloggen, um das Live-Radar zu sehen.</p>
|
|
||||||
</div>
|
if ('notFound' in v && v.notFound === true) {
|
||||||
</div>
|
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 => {
|
const teamOfPlayer = (sid?: string | null): 'T' | 'CT' | string | null => {
|
||||||
if (!sid) return null
|
if (!sid) return null
|
||||||
return playersRef.current.get(sid)?.team ?? null
|
return playersRef.current.get(sid)?.team ?? null
|
||||||
@ -1179,6 +1196,15 @@ export default function LiveRadar() {
|
|||||||
/* ───────── render ───────── */
|
/* ───────── render ───────── */
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 w-full flex flex-col overflow-hidden">
|
<div className="h-full min-h-0 w-full flex flex-col overflow-hidden">
|
||||||
|
{!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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<RadarHeader
|
<RadarHeader
|
||||||
useAvatars={useAvatars}
|
useAvatars={useAvatars}
|
||||||
setUseAvatars={setUseAvatars}
|
setUseAvatars={setUseAvatars}
|
||||||
@ -1193,13 +1219,8 @@ export default function LiveRadar() {
|
|||||||
onMap={(k) => setActiveMapKey(String(k).toLowerCase())}
|
onMap={(k) => setActiveMapKey(String(k).toLowerCase())}
|
||||||
onPlayerUpdate={(p) => { upsertPlayer(p); scheduleFlush() }}
|
onPlayerUpdate={(p) => { upsertPlayer(p); scheduleFlush() }}
|
||||||
onPlayersAll={(m) => { handlePlayersAll(m as UnknownRecord); scheduleFlush() }}
|
onPlayersAll={(m) => { handlePlayersAll(m as UnknownRecord); scheduleFlush() }}
|
||||||
onGrenades={(g) => {
|
onGrenades={(g) => { handleGrenades(g); scheduleFlush() }}
|
||||||
handleGrenades(g)
|
onRoundStart={() => { clearRoundArtifacts(true) }}
|
||||||
scheduleFlush()
|
|
||||||
}}
|
|
||||||
onRoundStart={() => {
|
|
||||||
clearRoundArtifacts(true)
|
|
||||||
}}
|
|
||||||
onRoundEnd={() => {
|
onRoundEnd={() => {
|
||||||
for (const [id, p] of playersRef.current) {
|
for (const [id, p] of playersRef.current) {
|
||||||
playersRef.current.set(id, { ...p, hasBomb: false })
|
playersRef.current.set(id, { ...p, hasBomb: false })
|
||||||
@ -1281,9 +1302,8 @@ export default function LiveRadar() {
|
|||||||
trails={trails}
|
trails={trails}
|
||||||
deathMarkers={deathMarkers}
|
deathMarkers={deathMarkers}
|
||||||
useAvatars={useAvatars}
|
useAvatars={useAvatars}
|
||||||
avatarById={avatarById}
|
avatarById={avatarByIdNormalized}
|
||||||
hoveredPlayerId={hoveredPlayerId}
|
hoveredPlayerId={hoveredPlayerId}
|
||||||
setHoveredPlayerId={setHoveredPlayerId}
|
|
||||||
myTeam={myTeam}
|
myTeam={myTeam}
|
||||||
beepState={beepState}
|
beepState={beepState}
|
||||||
bombFinal10={bombFinal10}
|
bombFinal10={bombFinal10}
|
||||||
@ -1314,6 +1334,9 @@ export default function LiveRadar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { contrastStroke } from './lib/helpers';
|
||||||
import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types';
|
import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types';
|
||||||
|
|
||||||
type AvatarEntry = { avatar?: string; notFound?: boolean } | undefined
|
type AvatarEntry = { avatar?: string | null; notFound?: boolean } | undefined
|
||||||
|
|
||||||
export default function RadarCanvas({
|
export default function RadarCanvas({
|
||||||
activeMapKey,
|
activeMapKey,
|
||||||
|
|||||||
@ -170,7 +170,11 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
else if (nextAlive === false && (old?.alive !== false)) addDeathMarkerFor(id, x, y);
|
else if (nextAlive === false && (old?.alive !== false)) addDeathMarkerFor(id, x, y);
|
||||||
|
|
||||||
type WeaponRec = { name?: unknown; state?: unknown } | string;
|
type WeaponRec = { name?: unknown; state?: unknown } | string;
|
||||||
|
|
||||||
|
// Rohdaten lesen
|
||||||
const weaponsArr = getArr(src.weapons) as WeaponRec[] | undefined;
|
const weaponsArr = getArr(src.weapons) as WeaponRec[] | undefined;
|
||||||
|
|
||||||
|
// Aktive Waffe wie gehabt
|
||||||
const activeFromArr =
|
const activeFromArr =
|
||||||
weaponsArr?.find((w) => isObj(w) && String(w.state ?? '').toLowerCase() === 'active') ?? null;
|
weaponsArr?.find((w) => isObj(w) && String(w.state ?? '').toLowerCase() === 'active') ?? null;
|
||||||
const activeWeaponName =
|
const activeWeaponName =
|
||||||
@ -179,6 +183,23 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
(isObj(activeFromArr) ? (activeFromArr.name as string | undefined) : undefined) ||
|
(isObj(activeFromArr) ? (activeFromArr.name as string | undefined) : undefined) ||
|
||||||
null;
|
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);
|
const stateObj = getObj(src.state);
|
||||||
playersRef.current.set(id, {
|
playersRef.current.set(id, {
|
||||||
id,
|
id,
|
||||||
@ -202,7 +223,7 @@ export function useRadarState(mySteamId: string | null) {
|
|||||||
(src as UnknownRecord)['hasDefuser'] ??
|
(src as UnknownRecord)['hasDefuser'] ??
|
||||||
stateObj?.defusekit) as boolean | null ?? (old?.defuse ?? null),
|
stateObj?.defusekit) as boolean | null ?? (old?.defuse ?? null),
|
||||||
activeWeapon: activeWeaponName ?? old?.activeWeapon ?? 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,
|
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'
|
'use client'
|
||||||
|
|
||||||
import MapVotePanel from '@/app/[locale]/components/MapVotePanel'
|
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 Card from '@/app/[locale]/components/Card'
|
||||||
import VoteClient from './VoteClient' // Client-Komponente
|
import VoteClient from './VoteClient' // Client-Komponente
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
// /src/app/[locale]/team/[teamId]/TeamDetailClient.tsx
|
// /src/app/[locale]/team/[teamId]/TeamDetailClient.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, KeyboardEvent, MouseEvent, ChangeEvent } from 'react'
|
import { useMemo, useEffect, useState, ChangeEvent } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||||
import Card from '../../components/Card'
|
import Card from '../../components/Card'
|
||||||
import PremierRankBadge from '../../components/PremierRankBadge'
|
import Button from '../../components/Button'
|
||||||
import { Player, Team } from '@/types/team'
|
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[] {
|
function uniqBySteamId<T extends { steamId: string }>(list: T[]): T[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const out: T[] = []
|
const out: T[] = []
|
||||||
@ -22,116 +26,191 @@ function uniqBySteamId<T extends { steamId: string }>(list: T[]): T[] {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function byName<T extends { name: string }>(a: T, b: T) {
|
function byName<T extends { name: string }>(a: T, b: T) {
|
||||||
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' })
|
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function classNames(...xs: Array<string | false | null | undefined>) {
|
function classNames(...xs: Array<string | false | null | undefined>) {
|
||||||
return xs.filter(Boolean).join(' ')
|
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 router = useRouter()
|
||||||
const [team, setTeam] = useState<Team | null>(null)
|
const [team, setTeam] = useState<Team | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 [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(() => {
|
useEffect(() => {
|
||||||
const ac = new AbortController();
|
if (mySteamId) return
|
||||||
(async () => {
|
let alive = true
|
||||||
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
const res = await fetch('/api/user', { cache: 'no-store' })
|
||||||
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store', signal: ac.signal });
|
if (!res.ok) return
|
||||||
if (res.status === 404) { router.replace('/404'); return; }
|
const data = (await res.json()) as { steamId?: string | null }
|
||||||
if (!res.ok) throw new Error('Team konnte nicht geladen werden');
|
if (alive) setMySteamId(data.steamId ?? null)
|
||||||
const data: Team = await res.json();
|
} catch {}
|
||||||
setTeam(data);
|
})()
|
||||||
} catch (e) {
|
return () => { alive = false }
|
||||||
if (!ac.signal.aborted) setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
|
}, [mySteamId])
|
||||||
} finally {
|
|
||||||
if (!ac.signal.aborted) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => ac.abort();
|
|
||||||
}, [teamId]); // ← router entfernt
|
|
||||||
|
|
||||||
|
// 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 ---------- */
|
/* ---------- Ableitungen ---------- */
|
||||||
|
|
||||||
const members = useMemo(() => {
|
// Counts (ohne Eingeladene)
|
||||||
if (!team) return []
|
const counts = useMemo(() => {
|
||||||
const all = [
|
if (!team) return { all: 0, active: 0, inactive: 0 }
|
||||||
|
const all = uniqBySteamId([
|
||||||
...(team.leader ? [team.leader] : []),
|
...(team.leader ? [team.leader] : []),
|
||||||
...team.activePlayers,
|
...team.activePlayers,
|
||||||
...team.inactivePlayers,
|
...team.inactivePlayers,
|
||||||
]
|
])
|
||||||
// uniq + Leader nach vorne
|
return {
|
||||||
const uniq = uniqBySteamId(all).sort(byName)
|
all: all.length,
|
||||||
if (team.leader) {
|
active: team.activePlayers.length,
|
||||||
const i = uniq.findIndex((p) => p.steamId === team.leader!.steamId)
|
inactive: team.inactivePlayers.length,
|
||||||
if (i > 0) {
|
|
||||||
const [lead] = uniq.splice(i, 1)
|
|
||||||
uniq.unshift(lead)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return uniq
|
|
||||||
}, [team])
|
}, [team])
|
||||||
|
|
||||||
const counts = useMemo(
|
// Gefilterte Liste
|
||||||
() => ({
|
|
||||||
active: team?.activePlayers.length ?? 0,
|
|
||||||
inactive: team?.inactivePlayers.length ?? 0,
|
|
||||||
invited: team?.invitedPlayers.length ?? 0,
|
|
||||||
total: members.length,
|
|
||||||
}),
|
|
||||||
[team, members]
|
|
||||||
)
|
|
||||||
|
|
||||||
const filteredList = useMemo(() => {
|
const filteredList = useMemo(() => {
|
||||||
if (!team) return []
|
if (!team) return []
|
||||||
|
|
||||||
const norm = (s: string) => s.toLowerCase().normalize('NFKD')
|
const norm = (s: string) => s.toLowerCase().normalize('NFKD')
|
||||||
const search = norm(q)
|
const search = norm(q)
|
||||||
const matchQ = (p: { name: string; location?: string; steamId: string }) => {
|
const matchQ = (p: { name: string; location?: string; steamId: string }) =>
|
||||||
if (!search) return true
|
!search || norm(p.name).includes(search) || norm(p.location ?? '').includes(search) || norm(p.steamId).includes(search)
|
||||||
return (
|
|
||||||
norm(p.name).includes(search) ||
|
|
||||||
norm(p.location ?? '').includes(search) ||
|
|
||||||
norm(p.steamId).includes(search)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seg === 'active') {
|
if (seg === 'active') return uniqBySteamId(team.activePlayers).filter(matchQ).sort(byName)
|
||||||
return uniqBySteamId(team.activePlayers).filter(matchQ).sort(byName)
|
if (seg === 'inactive') return uniqBySteamId(team.inactivePlayers).filter(matchQ).sort(byName)
|
||||||
}
|
|
||||||
if (seg === 'inactive') {
|
const union = uniqBySteamId([
|
||||||
return uniqBySteamId(team.inactivePlayers).filter(matchQ).sort(byName)
|
...(team.leader ? [team.leader] : []),
|
||||||
}
|
...team.activePlayers,
|
||||||
// invited
|
...team.inactivePlayers,
|
||||||
return uniqBySteamId(team.invitedPlayers).filter(matchQ).sort(byName)
|
])
|
||||||
|
return union.filter(matchQ).sort(byName)
|
||||||
}, [team, q, seg])
|
}, [team, q, seg])
|
||||||
|
|
||||||
/* ---------- Interaktionen ---------- */
|
const activeSet = useMemo(
|
||||||
|
() => new Set(team?.activePlayers.map(p => p.steamId) ?? []),
|
||||||
|
[team]
|
||||||
|
)
|
||||||
|
|
||||||
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
|
// Mitgliedsstatus
|
||||||
const onCardClick =
|
const isMemberOfThisTeam = useMemo(() => {
|
||||||
(steamId: string) =>
|
if (!team || !mySteamId) return false
|
||||||
(e: MouseEvent) => {
|
const inActive = (team.activePlayers ?? []).some(p => String(p.steamId) === String(mySteamId))
|
||||||
e.preventDefault()
|
const inInactive = (team.inactivePlayers ?? []).some(p => String(p.steamId) === String(mySteamId))
|
||||||
goToProfile(steamId)
|
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>) => {
|
// Falls du später wieder Join zeigen willst: Handler bleibt nutzbar
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
const handleJoinBranches = async () => {
|
||||||
e.preventDefault()
|
if (!team || joining) return
|
||||||
goToProfile(steamId)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,6 +234,8 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
|
|||||||
}
|
}
|
||||||
if (!team) return null
|
if (!team) return null
|
||||||
|
|
||||||
|
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card maxWidth="auto">
|
<Card maxWidth="auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -172,12 +253,34 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
|
|||||||
{team.name}
|
{team.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-neutral-400">
|
<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 ? (
|
{team.leader?.name ? (
|
||||||
<span className="ml-2 text-gray-400 dark:text-neutral-500">• Leader: {team.leader.name}</span>
|
<span className="ml-2 text-gray-400 dark:text-neutral-500">• Leader: {team.leader.name}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@ -194,31 +297,24 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
|
|||||||
dark:border-neutral-700 dark:bg-neutral-800 dark:text-white"
|
dark:border-neutral-700 dark:bg-neutral-800 dark:text-white"
|
||||||
aria-label="Spieler suchen"
|
aria-label="Spieler suchen"
|
||||||
/>
|
/>
|
||||||
<svg
|
<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">
|
||||||
aria-hidden
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segment */}
|
{/* Segment */}
|
||||||
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
|
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
|
||||||
{([
|
{[
|
||||||
|
{ key: 'all', label: `Alle (${counts.all})` },
|
||||||
{ key: 'active', label: `Aktiv (${counts.active})` },
|
{ key: 'active', label: `Aktiv (${counts.active})` },
|
||||||
{ key: 'inactive', label: `Inaktiv (${counts.inactive})` },
|
{ key: 'inactive', label: `Inaktiv (${counts.inactive})` },
|
||||||
{ key: 'invited', label: `Eingeladen (${counts.invited})` },
|
].map((opt) => (
|
||||||
] as const).map((opt) => (
|
|
||||||
<button
|
<button
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
onClick={() => setSeg(opt.key)}
|
onClick={() => setSeg(opt.key as 'all' | 'active' | 'inactive')}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'px-3 py-1.5 text-sm transition',
|
'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'
|
? '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'
|
: 'text-gray-600 hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800'
|
||||||
)}
|
)}
|
||||||
@ -231,97 +327,50 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
|
|||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
{filteredList.length === 0 ? (
|
{filteredList.length === 0 ? (
|
||||||
<div
|
<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">
|
||||||
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 ? (
|
{q ? (
|
||||||
<>
|
<>Keine Treffer für „<span className="font-medium">{q}</span>“.</>
|
||||||
Keine Treffer für „<span className="font-medium">{q}</span>“.
|
|
||||||
</>
|
|
||||||
) : seg === 'active' ? (
|
) : seg === 'active' ? (
|
||||||
'Keine aktiven Mitglieder.'
|
'Keine aktiven Mitglieder.'
|
||||||
) : seg === 'inactive' ? (
|
) : seg === 'inactive' ? (
|
||||||
'Keine inaktiven Mitglieder.'
|
'Keine inaktiven Mitglieder.'
|
||||||
) : (
|
) : (
|
||||||
'Keine eingeladenen Spieler.'
|
'Keine Mitglieder.'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filteredList.map((m) => (
|
{filteredList.map((m) => {
|
||||||
<div
|
const p = m as PlayerWithFaceitLevel
|
||||||
key={m.steamId}
|
return (
|
||||||
role="button"
|
<TeamPlayerCard
|
||||||
tabIndex={0}
|
key={p.steamId}
|
||||||
aria-label={`${m.name} – Profil öffnen`}
|
steamId={p.steamId}
|
||||||
onClick={onCardClick(m.steamId)}
|
name={p.name}
|
||||||
onKeyDown={onCardKey(m.steamId)}
|
avatar={p.avatar}
|
||||||
className="
|
isLeader={team.leader?.steamId === p.steamId}
|
||||||
group flex items-center gap-4 p-3 rounded-lg border
|
isActive={activeSet.has(p.steamId)}
|
||||||
border-gray-200 dark:border-neutral-700
|
rank={p.premierRank ?? 0}
|
||||||
bg-white dark:bg-neutral-800 shadow-sm
|
faceitLevel={p.faceitLevel ?? null}
|
||||||
transition cursor-pointer focus:outline-none
|
onOpen={(id) => router.push(`/profile/${id}`)}
|
||||||
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)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* 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 */}
|
{/* Leader-Leave-Modal (wie in TeamMemberView) */}
|
||||||
{seg !== 'invited' && (
|
{isLeader && (
|
||||||
<span
|
<LeaveTeamModal
|
||||||
className={classNames(
|
show={showLeaveModal}
|
||||||
'inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded',
|
onClose={() => setShowLeaveModal(false)}
|
||||||
team.activePlayers.some((a) => a.steamId === m.steamId)
|
onSuccess={async () => {
|
||||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
setShowLeaveModal(false)
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
|
const updated = await reloadTeam(team.id)
|
||||||
)}
|
if (updated) setTeam(updated)
|
||||||
>
|
}}
|
||||||
{team.activePlayers.some((a) => a.steamId === m.steamId) ? 'Aktiv' : 'Inaktiv'}
|
team={team}
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import { buildAuthOptions } from '@/lib/auth'
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
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))
|
NextAuth(req, ctx, buildAuthOptions(req))
|
||||||
|
|
||||||
export { handler as GET, handler as POST }
|
export { handler as GET, handler as POST }
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
function buildPanelUrl(base: string, serverId: string) {
|
function buildPanelUrl(base: string, serverId: string) {
|
||||||
const u = new URL(base.includes('://') ? base : `https://${base}`)
|
const u = new URL(base.includes('://') ? base : `https://${base}`)
|
||||||
const cleaned = (u.pathname || '').replace(/\/+$/,'')
|
const cleaned = (u.pathname || '').replace(/\/+$/, '')
|
||||||
// Client-API Endpoint
|
// Client-API Endpoint
|
||||||
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
|
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
|
||||||
return u.toString()
|
return u.toString()
|
||||||
@ -69,7 +69,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
let payload: any
|
let payload: unknown
|
||||||
try { payload = JSON.parse(text) } catch { payload = text }
|
try { payload = JSON.parse(text) } catch { payload = text }
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
@ -92,7 +92,11 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// In seltenen Fällen kommt JSON zurück
|
// In seltenen Fällen kommt JSON zurück
|
||||||
return NextResponse.json({ ok: true, status: res.status, response: payload }, { headers: { 'Cache-Control': 'no-store' } })
|
return NextResponse.json({ ok: true, status: res.status, response: payload }, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
return NextResponse.json({ error: e?.message ?? 'request_failed' }, { status: 500 })
|
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
|
] 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) {
|
async function ensureVote(matchId: string) {
|
||||||
const match = await prisma.match.findUnique({
|
const match = await prisma.match.findUnique({
|
||||||
where: { id: matchId },
|
where: { id: matchId },
|
||||||
@ -58,7 +80,7 @@ async function ensureVote(matchId: string) {
|
|||||||
if (!match) return { match: null, vote: null }
|
if (!match) return { match: null, vote: null }
|
||||||
|
|
||||||
// Wenn schon vorhanden → direkt zurück
|
// 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
|
// Default-Werte für neue Votes
|
||||||
const DEFAULT_BEST_OF = 3
|
const DEFAULT_BEST_OF = 3
|
||||||
@ -94,35 +116,41 @@ async function ensureVote(matchId: string) {
|
|||||||
include: { steps: true },
|
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
|
return vote.adminEditingBy
|
||||||
? {
|
? {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
by: vote.adminEditingBy as string,
|
by: vote.adminEditingBy,
|
||||||
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
|
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
|
||||||
}
|
}
|
||||||
: { enabled: false as const, by: null, since: null }
|
: { enabled: false as const, by: null, since: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
function shapeStateSlim(vote: any) {
|
function shapeStateSlim(vote: VoteDb) {
|
||||||
return {
|
const sorted: VoteStepDb[] = [...vote.steps].sort((a, b) => a.order - b.order)
|
||||||
bestOf: vote.bestOf as number,
|
const steps = sorted.map((s) => ({
|
||||||
mapPool: vote.mapPool as string[],
|
|
||||||
currentIndex: vote.currentIdx as number,
|
|
||||||
locked: vote.locked as boolean,
|
|
||||||
adminEdit: shapeAdminEdit(vote),
|
|
||||||
steps: [...vote.steps].sort((a: any, b: any) => a.order - b.order).map((s: any) => ({
|
|
||||||
order : s.order,
|
order : s.order,
|
||||||
action : mapActionToApi(s.action),
|
action : mapActionToApi(s.action),
|
||||||
teamId : s.teamId,
|
teamId : s.teamId,
|
||||||
map : s.map ?? null,
|
map : s.map ?? null,
|
||||||
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
|
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
|
||||||
chosenBy: s.chosenBy ?? null,
|
chosenBy: s.chosenBy ?? null,
|
||||||
})),
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
bestOf: vote.bestOf,
|
||||||
|
mapPool: vote.mapPool,
|
||||||
|
currentIndex: vote.currentIdx,
|
||||||
|
locked: vote.locked,
|
||||||
|
adminEdit: shapeAdminEdit(vote),
|
||||||
|
steps,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,7 +192,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<Params> }) {
|
|||||||
? { adminEditingBy: null, adminEditingSince: null }
|
? { adminEditingBy: null, adminEditingSince: null }
|
||||||
: {}),
|
: {}),
|
||||||
include: { steps: true },
|
include: { steps: true },
|
||||||
})
|
}) as unknown as VoteDb
|
||||||
|
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'map-vote-admin-edit',
|
type: 'map-vote-admin-edit',
|
||||||
|
|||||||
@ -6,36 +6,63 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
|
import { MapVoteAction } from '@/generated/prisma'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
type Ctx = { params: Promise<{ matchId: string }> }
|
type Ctx = { params: Promise<{ matchId: string }> }
|
||||||
|
|
||||||
function shapeState(vote: any) {
|
// ---- DB Shapes (leichtgewichtige lokale Typen) ----
|
||||||
const ACTION_MAP = { BAN: 'ban', PICK: 'pick', DECIDER: 'decider' } as const
|
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]
|
const steps = [...vote.steps]
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((s: any) => ({
|
.map((s: VoteStepDb) => ({
|
||||||
order : s.order,
|
order: s.order,
|
||||||
action : ACTION_MAP[s.action as keyof typeof ACTION_MAP],
|
action: ACTION_MAP[s.action],
|
||||||
teamId : s.teamId,
|
teamId: s.teamId,
|
||||||
map : s.map,
|
map: s.map,
|
||||||
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
|
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
|
||||||
chosenBy: s.chosenBy ?? null,
|
chosenBy: s.chosenBy ?? null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bestOf : vote.bestOf,
|
bestOf: vote.bestOf,
|
||||||
mapPool : vote.mapPool as string[],
|
mapPool: vote.mapPool,
|
||||||
currentIndex: vote.currentIdx,
|
currentIndex: vote.currentIdx,
|
||||||
locked : vote.locked as boolean,
|
locked: vote.locked,
|
||||||
opensAt : vote.opensAt ? new Date(vote.opensAt).toISOString() : null,
|
opensAt: vote.opensAt ? new Date(vote.opensAt).toISOString() : null,
|
||||||
steps,
|
steps,
|
||||||
adminEdit: vote.adminEditingBy
|
adminEdit: vote.adminEditingBy
|
||||||
? {
|
? {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
by: vote.adminEditingBy as string,
|
by: vote.adminEditingBy,
|
||||||
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
|
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
|
||||||
}
|
}
|
||||||
: { enabled: false, by: null, since: null },
|
: { enabled: false, by: null, since: null },
|
||||||
@ -84,14 +111,14 @@ export async function POST(req: NextRequest, ctx: Ctx) {
|
|||||||
vote.steps.map(s =>
|
vote.steps.map(s =>
|
||||||
tx.mapVoteStep.update({
|
tx.mapVoteStep.update({
|
||||||
where: { id: s.id },
|
where: { id: s.id },
|
||||||
data : { map: null, chosenAt: null, chosenBy: null },
|
data: { map: null, chosenAt: null, chosenBy: null },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await tx.mapVote.update({
|
await tx.mapVote.update({
|
||||||
where: { id: vote.id },
|
where: { id: vote.id },
|
||||||
data : {
|
data: {
|
||||||
currentIdx: 0,
|
currentIdx: 0,
|
||||||
locked: false,
|
locked: false,
|
||||||
adminEditingBy: null,
|
adminEditingBy: null,
|
||||||
@ -102,10 +129,11 @@ export async function POST(req: NextRequest, ctx: Ctx) {
|
|||||||
await tx.matchReady.deleteMany({ where: { matchId } })
|
await tx.matchReady.deleteMany({ where: { matchId } })
|
||||||
})
|
})
|
||||||
|
|
||||||
const updated = await prisma.mapVote.findUnique({
|
const updated = (await prisma.mapVote.findUnique({
|
||||||
where: { id: vote.id },
|
where: { id: vote.id },
|
||||||
include: { steps: true },
|
include: { steps: true },
|
||||||
})
|
})) as VoteDb | null
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return NextResponse.json({ message: 'Reset fehlgeschlagen' }, { status: 500 })
|
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 })
|
if (vote.locked) return NextResponse.json({ message: 'Voting bereits abgeschlossen' }, { status: 409 })
|
||||||
|
|
||||||
// Aktuellen Schritt bestimmen
|
// Aktuellen Schritt bestimmen
|
||||||
const stepsSorted = [...vote.steps].sort((a: any, b: any) => a.order - b.order)
|
const stepsSorted: VoteStepDb[] = [...vote.steps].sort(byOrder)
|
||||||
const current = stepsSorted.find((s: any) => s.order === vote.currentIdx)
|
const current = stepsSorted.find(s => s.order === vote.currentIdx)
|
||||||
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
// Kein Schritt mehr -> Vote abschließen
|
// 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)
|
// 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 isLeaderA = match.teamA?.leaderId === me.steamId
|
||||||
const isLeaderB = !!(match as any).teamB?.leaderId && (match as any).teamB.leaderId === me.steamId
|
const isLeaderB = match.teamB?.leaderId === me.steamId
|
||||||
const allowed = me.isAdmin || (current.teamId && (
|
const allowed = me.isAdmin || (current.teamId && (
|
||||||
(current.teamId === match.teamA?.id && isLeaderA) ||
|
(current.teamId === match.teamA?.id && isLeaderA) ||
|
||||||
(current.teamId === match.teamB?.id && isLeaderB)
|
(current.teamId === match.teamB?.id && isLeaderB)
|
||||||
@ -822,15 +822,15 @@ export async function POST(req: NextRequest, ctx: Ctx) {
|
|||||||
const after = await tx.mapVote.findUnique({
|
const after = await tx.mapVote.findUnique({
|
||||||
where : { id: vote.id },
|
where : { id: vote.id },
|
||||||
include: { steps: true },
|
include: { steps: true },
|
||||||
})
|
}) as (Pick<VoteDb, 'id' | 'currentIdx' | 'steps' | 'mapPool'> | null)
|
||||||
|
|
||||||
if (!after) return
|
if (!after) return
|
||||||
|
|
||||||
const stepsAfter = [...after.steps].sort((a: any, b: any) => a.order - b.order)
|
const stepsAfter: VoteStepDb[] = [...after.steps].sort(byOrder)
|
||||||
let idx = after.currentIdx + 1
|
let idx = after.currentIdx + 1
|
||||||
let locked = false
|
let lockedFlag = false
|
||||||
|
|
||||||
// Falls nächster Schritt DECIDER und genau 1 Map übrig -> auto setzen & locken
|
|
||||||
const next = stepsAfter.find(s => s.order === idx)
|
const next = stepsAfter.find(s => s.order === idx)
|
||||||
|
|
||||||
if (next?.action === 'DECIDER') {
|
if (next?.action === 'DECIDER') {
|
||||||
const avail = computeAvailableMaps(after.mapPool, stepsAfter)
|
const avail = computeAvailableMaps(after.mapPool, stepsAfter)
|
||||||
if (avail.length === 1) {
|
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 },
|
data : { map: avail[0], chosenAt: new Date(), chosenBy: me.steamId },
|
||||||
})
|
})
|
||||||
idx += 1
|
idx += 1
|
||||||
locked = true
|
lockedFlag = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ende erreicht?
|
|
||||||
const maxOrder = Math.max(...stepsAfter.map(s => s.order))
|
const maxOrder = Math.max(...stepsAfter.map(s => s.order))
|
||||||
if (idx > maxOrder) locked = true
|
if (idx > maxOrder) lockedFlag = true
|
||||||
|
|
||||||
await tx.mapVote.update({
|
await tx.mapVote.update({
|
||||||
where: { id: after.id },
|
where: { id: after.id },
|
||||||
data : {
|
data : {
|
||||||
currentIdx: idx,
|
currentIdx: idx,
|
||||||
locked,
|
locked: lockedFlag,
|
||||||
// ➜ Nur wenn jetzt abgeschlossen: Admin-Edit beenden
|
...(lockedFlag ? { adminEditingBy: null, adminEditingSince: null } : {}),
|
||||||
...(locked ? { adminEditingBy: null, adminEditingSince: null } : {}),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -13,6 +13,19 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
type Ctx = { params: Promise<{ matchId: string }> }
|
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
|
// Hilfsfunktion: akzeptiert Date | string | number | null | undefined
|
||||||
function parseDateOrNull(v: unknown): Date | null | undefined {
|
function parseDateOrNull(v: unknown): Date | null | undefined {
|
||||||
if (typeof v === 'undefined') return undefined
|
if (typeof v === 'undefined') return undefined
|
||||||
@ -126,7 +139,7 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1) Match-Felder zusammenbauen
|
// 1) Match-Felder zusammenbauen
|
||||||
const updateData: any = {}
|
const updateData: MatchUpdateData = {}
|
||||||
if (typeof title === 'string') updateData.title = title
|
if (typeof title === 'string') updateData.title = title
|
||||||
if (typeof matchType === 'string') updateData.matchType = matchType
|
if (typeof matchType === 'string') updateData.matchType = matchType
|
||||||
|
|
||||||
|
|||||||
@ -11,29 +11,52 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
type Ctx = { params: Promise<{ matchId: string }> }
|
type Ctx = { params: Promise<{ matchId: string }> }
|
||||||
|
|
||||||
/** ---- Helpers: Spieler-Liste aus Match bauen ---- */
|
// ---- Mini-Typen (nur die Felder, die wir wirklich brauchen) ----
|
||||||
function shapeUser(u: any) {
|
type UserLite = {
|
||||||
if (!u) return null
|
steamId?: string
|
||||||
return {
|
name?: string | null
|
||||||
steamId: u.steamId,
|
avatar?: string | null
|
||||||
name : u.name ?? '',
|
|
||||||
avatar : u.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildParticipants(match: any) {
|
type MatchForParticipants = {
|
||||||
const teamAUsers = (match.teamAUsers ?? []).map(shapeUser).filter(Boolean)
|
teamAUsers?: UserLite[]
|
||||||
const teamBUsers = (match.teamBUsers ?? []).map(shapeUser).filter(Boolean)
|
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
|
// Fallback: aus match.players → user ziehen, falls teamA/B leer wären
|
||||||
const fromPlayers = (match.players ?? [])
|
const fromPlayers = (match.players ?? [])
|
||||||
.map((mp: any) => mp?.user)
|
.map((mp: { user?: UserLite | null }) => mp?.user)
|
||||||
.map(shapeUser)
|
.map(shapeUser)
|
||||||
.filter(Boolean)
|
.filter(Boolean) as ShapedUser[]
|
||||||
|
|
||||||
// deduplizieren – Teamzuordnung merken
|
// deduplizieren – Teamzuordnung merken
|
||||||
const seen = new Set<string>()
|
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) {
|
for (const u of teamAUsers) {
|
||||||
if (!u || seen.has(u.steamId)) continue
|
if (!u || seen.has(u.steamId)) continue
|
||||||
@ -59,7 +82,7 @@ export async function GET(_req: NextRequest, ctx: Ctx) {
|
|||||||
const { matchId } = await ctx.params
|
const { matchId } = await ctx.params
|
||||||
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
|
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 session = await getServerSession(sessionAuthOptions)
|
||||||
const me = session?.user as { steamId?: string } | undefined
|
const me = session?.user as { steamId?: string } | undefined
|
||||||
const mySteamId = me?.steamId
|
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 })
|
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({
|
const readyRows = await prisma.matchReady.findMany({
|
||||||
where: { matchId },
|
where: { matchId },
|
||||||
select: { steamId: true, acceptedAt: true },
|
select: { steamId: true, acceptedAt: true },
|
||||||
@ -88,16 +111,19 @@ export async function GET(_req: NextRequest, ctx: Ctx) {
|
|||||||
const total = participants.length
|
const total = participants.length
|
||||||
const countReady = participants.filter(p => !!readyMap[p.steamId]).length
|
const countReady = participants.filter(p => !!readyMap[p.steamId]).length
|
||||||
|
|
||||||
// ⬇️ neu: eigener Ready-Status
|
// eigener Ready-Status
|
||||||
const myReady = !!(mySteamId && readyMap[mySteamId])
|
const myReady = !!(mySteamId && readyMap[mySteamId])
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
participants,
|
participants,
|
||||||
ready: readyMap,
|
ready: readyMap,
|
||||||
total,
|
total,
|
||||||
countReady,
|
countReady,
|
||||||
myReady, // <-- neu
|
myReady,
|
||||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } }
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[match-ready][GET] error', e)
|
console.error('[match-ready][GET] error', e)
|
||||||
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
|
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 ---
|
// --- Intent-Schutz: Header + Body müssen passen ---
|
||||||
const hdr = req.headers.get('X-Ready-Accept')
|
const hdr = req.headers.get('X-Ready-Accept')
|
||||||
let body: any = null
|
type ReadyBody = { intent?: 'accept' | string }
|
||||||
try { body = await req.json() } catch {}
|
let body: ReadyBody | null = null
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as ReadyBody
|
||||||
|
} catch {
|
||||||
|
body = null
|
||||||
|
}
|
||||||
if (hdr !== '1' || body?.intent !== 'accept') {
|
if (hdr !== '1' || body?.intent !== 'accept') {
|
||||||
return NextResponse.json({ message: 'Invalid ready intent' }, { status: 400 })
|
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>([
|
const participants = new Set<string>([
|
||||||
...match.teamAUsers.map(u => u.steamId),
|
...match.teamAUsers.map(u => u.steamId),
|
||||||
...match.teamBUsers.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)) {
|
if (!participants.has(me.steamId)) {
|
||||||
return NextResponse.json({ message: 'Not a participant' }, { status: 403 })
|
return NextResponse.json({ message: 'Not a participant' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Idempotent upsert: nur dann „ready“, wenn Intent korrekt war ---
|
// --- Idempotent upsert ---
|
||||||
await prisma.matchReady.upsert({
|
await prisma.matchReady.upsert({
|
||||||
where: { matchId_steamId: { matchId, steamId: me.steamId } },
|
where: { matchId_steamId: { matchId, steamId: me.steamId } },
|
||||||
create: { matchId, steamId: me.steamId },
|
create: { matchId, steamId: me.steamId },
|
||||||
|
|||||||
@ -10,12 +10,32 @@ import {
|
|||||||
buildDefaultPayload,
|
buildDefaultPayload,
|
||||||
} from './_builders'
|
} from './_builders'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
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 }> }
|
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
|
// prisma User -> schlanker FE-User inkl. banStatus
|
||||||
function packUser(u: any) {
|
function packUser(u: DbUserSelected): Player {
|
||||||
return {
|
return {
|
||||||
steamId: u.steamId,
|
steamId: u.steamId,
|
||||||
name: u.name ?? 'Unbekannt',
|
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 ───────────────────────── */
|
/* ───────────────────────── GET ───────────────────────── */
|
||||||
export async function GET(_req: Request, ctx: Ctx) {
|
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 })
|
if (!m) return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
||||||
|
|
||||||
const payload =
|
const payload =
|
||||||
m.matchType === 'community' && isFuture(m)
|
(m.matchType === 'community' && isFuture(m)
|
||||||
? await buildCommunityFuturePayload(m)
|
? await buildCommunityFuturePayload(m)
|
||||||
: buildDefaultPayload(m);
|
: buildDefaultPayload(m)) as BasePayload
|
||||||
|
|
||||||
// ⬇️ Zusatz: opensAt (und leadMinutes) an die Antwort hängen
|
// ⬇️ Zusatz: opensAt (und leadMinutes) an die Antwort hängen
|
||||||
const baseTs = (m.matchDate ?? m.demoDate)?.getTime?.() ?? null
|
const baseTs = (m.matchDate ?? m.demoDate)?.getTime?.() ?? null
|
||||||
@ -89,23 +114,23 @@ export async function GET(_req: Request, ctx: Ctx) {
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
// Spieler für A/B aufteilen & mappen
|
// Spieler für A/B aufteilen & mappen
|
||||||
const setA = new Set(m.teamAUsers.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 setB = new Set(m.teamBUsers.map(u => u.steamId))
|
||||||
|
|
||||||
const playersA = m.players
|
const playersA = m.players
|
||||||
.filter(p => setA.has(p.steamId))
|
.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
|
const playersB = m.players
|
||||||
.filter(p => setB.has(p.steamId))
|
.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!)
|
// MapVote formen (opensAt als ISO-String!)
|
||||||
const mapVotePayload = m.mapVote
|
const mapVotePayload = m.mapVote
|
||||||
? {
|
? {
|
||||||
locked: m.mapVote.locked,
|
locked: m.mapVote.locked,
|
||||||
isOpen: !m.mapVote.locked && (opensAt ? Date.now() >= opensAt.getTime() : false),
|
isOpen: !m.mapVote.locked && (opensAt ? Date.now() >= opensAt.getTime() : false),
|
||||||
opensAt: opensAt ? opensAt.toISOString() : null, // <-- wichtig
|
opensAt: opensAt ? opensAt.toISOString() : null,
|
||||||
leadMinutes,
|
leadMinutes,
|
||||||
steps: m.mapVote.steps
|
steps: m.mapVote.steps
|
||||||
.sort((a, b) => a.order - b.order)
|
.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),
|
.map(s => s.map as string),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
return NextResponse.json({
|
// 🔧 any-freier Merge
|
||||||
...payload,
|
const mergedMapVote = {
|
||||||
mapVote: {
|
...(isObj(payload.mapVote) ? payload.mapVote : {}),
|
||||||
...(payload as any).mapVote,
|
|
||||||
...(mapVotePayload ?? {}),
|
...(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 },
|
{ headers: { 'Cache-Control': 'no-store' } }
|
||||||
teamB: { ...(payload as any).teamB, players: playersB },
|
)
|
||||||
}, { headers: { 'Cache-Control': 'no-store' } });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`GET /matches/${id} failed:`, err)
|
console.error(`GET /matches/${id} failed:`, err)
|
||||||
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
|
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
|
||||||
@ -217,25 +256,49 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
|
|||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// 1) teamAUsers / teamBUsers SETZEN (nur die Seite(n), die editiert werden)
|
// 1) teamAUsers / teamBUsers SETZEN (nur die Seite(n), die editiert werden)
|
||||||
const data: any = {}
|
const data: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (editorSide === 'both' || editorSide === 'A') {
|
if (editorSide === 'both' || editorSide === 'A') {
|
||||||
data.teamAUsers = {
|
;(data as { teamAUsers?: unknown }).teamAUsers = {
|
||||||
set: [], // erst leeren
|
set: [],
|
||||||
connect: aSteamIds.map(steamId => ({ steamId })),
|
connect: aSteamIds.map(steamId => ({ steamId })),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (editorSide === 'both' || editorSide === 'B') {
|
if (editorSide === 'both' || editorSide === 'B') {
|
||||||
data.teamBUsers = {
|
;(data as { teamBUsers?: unknown }).teamBUsers = {
|
||||||
set: [],
|
set: [],
|
||||||
connect: bSteamIds.map(steamId => ({ steamId })),
|
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
|
// 2) matchPlayer je Seite synchron halten
|
||||||
// – so bleiben Teilnehmer + spätere Stats konsistent.
|
|
||||||
if (editorSide === 'both' || editorSide === 'A') {
|
if (editorSide === 'both' || editorSide === 'A') {
|
||||||
if (hasTeamIds) {
|
if (hasTeamIds) {
|
||||||
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamAId! } })
|
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamAId! } })
|
||||||
@ -246,7 +309,6 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Community ohne teamId → anhand Roster löschen
|
|
||||||
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterA) } } })
|
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterA) } } })
|
||||||
if (aSteamIds.length) {
|
if (aSteamIds.length) {
|
||||||
await tx.matchPlayer.createMany({
|
await tx.matchPlayer.createMany({
|
||||||
@ -308,18 +370,17 @@ 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 setA = new Set(updated.teamAUsers.map(u => u.steamId))
|
||||||
const setB = new Set(updated.teamBUsers.map(u => u.steamId))
|
const setB = new Set(updated.teamBUsers.map(u => u.steamId))
|
||||||
const playersA = updated.players
|
const playersA = updated.players
|
||||||
.filter(p => setA.has(p.steamId))
|
.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
|
const playersB = updated.players
|
||||||
.filter(p => setB.has(p.steamId))
|
.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({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
description: updated.description,
|
description: updated.description,
|
||||||
@ -341,15 +402,15 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
|
|||||||
score: updated.scoreB,
|
score: updated.scoreB,
|
||||||
players: playersB,
|
players: playersB,
|
||||||
},
|
},
|
||||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } }
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`PUT /matches/${id} failed:`, err)
|
console.error(`PUT /matches/${id} failed:`, err)
|
||||||
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
|
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ───────────────────────── DELETE ───────────────────────── */
|
/* ───────────────────────── DELETE ───────────────────────── */
|
||||||
export async function DELETE(_req: NextRequest, ctx: Ctx) {
|
export async function DELETE(_req: NextRequest, ctx: Ctx) {
|
||||||
const { matchId: id } = await ctx.params
|
const { matchId: id } = await ctx.params
|
||||||
|
|||||||
@ -7,14 +7,23 @@ import type { AsyncParams } from '@/types/next'
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const revalidate = 0
|
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(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
ctx: AsyncParams<{ teamId: string }>
|
ctx: AsyncParams<{ teamId: string }>
|
||||||
) {
|
) {
|
||||||
try {
|
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({
|
const team = await prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
include: {
|
include: {
|
||||||
@ -26,6 +35,7 @@ export async function GET(
|
|||||||
location: true,
|
location: true,
|
||||||
premierRank: true,
|
premierRank: true,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
|
...FACEIT_SELECT, // ⬅︎ Faceit für Leader
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invites: {
|
invites: {
|
||||||
@ -38,6 +48,7 @@ export async function GET(
|
|||||||
location: true,
|
location: true,
|
||||||
premierRank: true,
|
premierRank: true,
|
||||||
isAdmin: 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 })
|
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 allIds = Array.from(new Set([...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]))
|
||||||
|
|
||||||
const users = allIds.length
|
const users = allIds.length
|
||||||
@ -62,11 +73,12 @@ export async function GET(
|
|||||||
location: true,
|
location: true,
|
||||||
premierRank: true,
|
premierRank: true,
|
||||||
isAdmin: 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 = {
|
type UserLike = {
|
||||||
steamId: string
|
steamId: string
|
||||||
name: string | null
|
name: string | null
|
||||||
@ -74,17 +86,23 @@ export async function GET(
|
|||||||
location: string | null
|
location: string | null
|
||||||
premierRank: number | null
|
premierRank: number | null
|
||||||
isAdmin: boolean | null
|
isAdmin: boolean | null
|
||||||
|
faceitGames?: { skillLevel: number | null; elo: number | null }[] // ⬅︎ neu
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Ein Helper: Prisma -> Player
|
// Prisma -> Player
|
||||||
const toPlayer = (u: UserLike): Player => ({
|
const toPlayer = (u: UserLike): Player => {
|
||||||
|
const fg = u.faceitGames?.[0] // nur cs2, siehe FACEIT_SELECT.take=1
|
||||||
|
return {
|
||||||
steamId: u.steamId,
|
steamId: u.steamId,
|
||||||
name: u.name ?? 'Unbekannt',
|
name: u.name ?? 'Unbekannt',
|
||||||
avatar: u.avatar ?? '/assets/img/avatars/default.png',
|
avatar: u.avatar ?? '/assets/img/avatars/default.png',
|
||||||
location: u.location ?? undefined,
|
location: u.location ?? undefined,
|
||||||
premierRank: u.premierRank ?? undefined,
|
premierRank: u.premierRank ?? undefined,
|
||||||
isAdmin: u.isAdmin ?? 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 byId: Record<string, Player> = Object.fromEntries(users.map(u => [u.steamId, toPlayer(u)]))
|
||||||
const safeSort = (a?: string, b?: string) => (a ?? '').localeCompare(b ?? '')
|
const safeSort = (a?: string, b?: string) => (a ?? '').localeCompare(b ?? '')
|
||||||
@ -102,21 +120,17 @@ export async function GET(
|
|||||||
/* 3) Eingeladene Spieler inkl. invitationId */
|
/* 3) Eingeladene Spieler inkl. invitationId */
|
||||||
const invitedPlayers: InvitedPlayer[] = (team.invites ?? [])
|
const invitedPlayers: InvitedPlayer[] = (team.invites ?? [])
|
||||||
.map(inv => {
|
.map(inv => {
|
||||||
const u = inv.user
|
const u = inv.user as UserLike
|
||||||
|
const p = toPlayer(u)
|
||||||
return {
|
return {
|
||||||
invitationId: inv.id,
|
invitationId: inv.id,
|
||||||
steamId: u.steamId,
|
...p,
|
||||||
name: u.name ?? 'Unbekannt',
|
|
||||||
avatar: u.avatar ?? '/assets/img/avatars/default.png',
|
|
||||||
location: u.location ?? undefined,
|
|
||||||
premierRank: u.premierRank ?? undefined,
|
|
||||||
isAdmin: u.isAdmin ?? undefined,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => safeSort(a.name, b.name))
|
.sort((a, b) => safeSort(a.name, b.name))
|
||||||
|
|
||||||
/* 4) Leader als Player-Objekt */
|
/* 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 */
|
/* 5) Antwort */
|
||||||
const result = {
|
const result = {
|
||||||
|
|||||||
@ -44,26 +44,6 @@ export async function POST(req: NextRequest) {
|
|||||||
data: { teamId: newTeam.id },
|
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({
|
await sendServerSSEMessage({
|
||||||
type: 'self-updated',
|
type: 'self-updated',
|
||||||
targetUserIds: [leader],
|
targetUserIds: [leader],
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export async function POST(req: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
steamId : uid,
|
steamId : uid,
|
||||||
title : 'Team gelöscht',
|
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',
|
actionType: 'user-team-cleared',
|
||||||
actionData: team.id,
|
actionData: team.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -20,6 +20,8 @@ export type Player = {
|
|||||||
premierRank?: number
|
premierRank?: number
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
banStatus?: BanStatus
|
banStatus?: BanStatus
|
||||||
|
faceitLevel?: number | null
|
||||||
|
faceitElo?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InvitedPlayer = Player & {
|
export type InvitedPlayer = Player & {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user