This commit is contained in:
Linrador 2025-09-28 22:42:32 +02:00
parent 8ae14cc2b9
commit aff1f090c1
5 changed files with 390 additions and 225 deletions

View File

@ -13,9 +13,10 @@ import {
Legend,
Title,
Filler,
type Plugin,
} from 'chart.js'
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
import { useMemo } from 'react'
import { useMemo, useRef } from 'react'
ChartJS.register(
CategoryScale,
@ -31,7 +32,8 @@ ChartJS.register(
Filler
)
type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
type ChartType =
| 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
type BaseDataset = {
label: string
@ -52,35 +54,32 @@ type ChartProps = {
/** Fixe Höhe in px ODER 'auto' (Höhe aus aspectRatio) */
height?: number | 'auto'
/** Wird genutzt, wenn height='auto'. Standard: 2 (Breite/Höhe = 2:1) */
aspectRatio?: number
/** Legend & Achsen (nicht Radar) ausblenden */
/** Legende & Achsen (nicht Radar) ausblenden */
hideLabels?: boolean
className?: string
style?: React.CSSProperties
/** Radar-spezifisch */
/** Radar-Achse */
radarMin?: number // default 0
radarMax?: number // default 100
radarStepSize?: number // default 20 (erster Ring)
radarZeroToFirstTick?: boolean // default true 0% auf ersten Tick zeichnen
radarHideTicks?: boolean // default true 100%, 80%, … ausblenden
radarMax?: number // default 120 (wegen +20 Offset)
radarStepSize?: number // default 20
radarHideTicks?: boolean // default true
/** Feintuning Radar */
/** Radar-Darstellung */
radarFillMode?: boolean | number | 'origin' | 'start' | 'end' // default: true
radarTension?: number // default: 0.2
radarTension?: number // default: 0
radarSpanGaps?: boolean // default: false
/**
* Werte im Intervall (0..ersterTick) werden in
* [ersterTick .. ersterTick+radarSubStepBand] gemappt,
* damit sie sichtbar ÜBER 0% liegen.
* Einheit: Prozentpunkte. default: 4
*/
radarSubStepBand?: number
/** Werte auf den Ringen +stepSize nach außen schieben (Tooltips bleiben original) */
radarAddRingOffset?: boolean
/** Icons statt Text-Labels */
radarIcons?: string[] | Record<string, string>
radarIconSize?: number // px
radarIconOffset?: number // Abstand über dem äußersten Ring (Skaleneinheiten)
}
export default function Chart({
@ -94,55 +93,114 @@ export default function Chart({
className = '',
style,
// Radar defaults: 0..120 mit 20er Ringen (0/20/40/60/80/100/120)
radarMin = 0,
radarMax = 100,
radarMax = 120,
radarStepSize = 20,
radarZeroToFirstTick = true,
radarHideTicks = true,
radarFillMode = true,
radarTension = 0.2,
radarTension = 0,
radarSpanGaps = false,
radarSubStepBand = 4,
radarAddRingOffset = true,
radarIcons,
radarIconSize = 28,
radarIconOffset = 6,
}: ChartProps) {
const isRadar = type === 'radar'
const isAutoHeight = height === 'auto'
// Original-Daten für Tooltip-Anzeige (echte Prozentwerte)
// -------- Daten vorbereiten (Radar: +stepSize verschieben; Tooltips bleiben original) --------
const originalDatasets = datasets
// Werte fürs Radar aufbereiten:
// - 0% auf ersten Tick
// - (0..ersterTick) leicht oberhalb des ersten Ticks verteilen
const preparedDatasets = useMemo(() => {
if (!isRadar) return originalDatasets
const step = Math.max(radarStepSize, 1)
const band = Math.max(0, radarSubStepBand)
const mapVal = (v: number) => {
if (!radarZeroToFirstTick) return v
if (v === 0) return step
if (v > 0 && v < step) {
const t = v / step // 0..1
return step + t * band
}
return v
}
const ringOffset = radarAddRingOffset ? Math.max(1, radarStepSize) : 0
const clamp = (v: number) => Math.min(v, radarMax)
return originalDatasets.map(ds => ({
...ds,
fill: ds.fill ?? radarFillMode,
tension: ds.tension ?? radarTension,
spanGaps: ds.spanGaps ?? radarSpanGaps,
data: ds.data.map(mapVal),
data: ds.data.map(v => clamp(v + ringOffset)),
}))
}, [originalDatasets, isRadar, radarZeroToFirstTick, radarStepSize, radarSubStepBand, radarFillMode, radarTension, radarSpanGaps])
}, [
originalDatasets,
isRadar,
radarAddRingOffset,
radarStepSize,
radarFillMode,
radarTension,
radarSpanGaps,
radarMax,
])
const data = useMemo(
() => ({ labels, datasets: preparedDatasets }),
[labels, preparedDatasets]
)
// -------- Icon-Plugin (nur für Radar) --------
const imageCacheRef = useRef<Map<string, HTMLImageElement>>(new Map())
const resolveIconUrl = (i: number, label: string): string | null => {
if (!radarIcons) return null
if (Array.isArray(radarIcons)) return radarIcons[i] ?? null
return radarIcons[label] ?? null
}
// exakt auf Radar typisieren
const radarIconsPlugin: Plugin<'radar'> | undefined = useMemo(() => {
if (!isRadar || !radarIcons) return undefined
const plugin: Plugin<'radar'> = {
id: 'radar-icons',
afterDraw: (chart) => {
const scale: any = chart.scales?.r
if (!scale) return
const ctx = chart.ctx
const n = (chart.data.labels ?? []).length
if (!n) return
// Zeichnen knapp außerhalb des äußersten Rings
const radiusValue = (scale.max ?? radarMax) + radarIconOffset
for (let i = 0; i < n; i++) {
const label = String(chart.data.labels?.[i] ?? '')
const url = resolveIconUrl(i, label)
if (!url) continue
let img = imageCacheRef.current.get(url)
if (!img) {
img = new Image()
img.crossOrigin = 'anonymous'
img.src = url
imageCacheRef.current.set(url, img)
img.onload = () => { try { chart.draw() } catch {} }
}
if (!img.complete) continue
const pos = scale.getPointPositionForValue(i, radiusValue) // (index, value)
const size = radarIconSize
const x = pos.x - size / 2
const y = pos.y - size / 2
ctx.save()
ctx.shadowColor = 'rgba(0,0,0,0.35)'
ctx.shadowBlur = 4
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 1
ctx.drawImage(img, x, y, size, size)
ctx.restore()
}
},
}
return plugin
}, [isRadar, radarIcons, radarIconOffset, radarIconSize, radarMax])
// -------- Optionen --------
const options = useMemo(() => {
const base: any = {
responsive: true,
@ -153,7 +211,7 @@ export default function Chart({
title: { display: !!title, text: title },
tooltip: {
callbacks: {
// Tooltip zeigt Originalwert (nicht den gesnappten) an
// Tooltip zeigt Originalwerte (ohne +Offset)
label: (ctx: any) => {
const dsIdx = ctx.datasetIndex
const i = ctx.dataIndex
@ -181,7 +239,8 @@ export default function Chart({
},
angleLines: { color: 'rgba(255,255,255,0.08)' },
grid: { color: 'rgba(255,255,255,0.08)' },
pointLabels: { color: 'inherit' },
// Text-Labels ausblenden Icons übernehmen die Rolle
pointLabels: { display: false },
},
}
} else if (hideLabels) {
@ -202,10 +261,25 @@ export default function Chart({
originalDatasets,
])
// -------- Render (typsicher) --------
const wrapperStyle: React.CSSProperties = isAutoHeight
? { width: '100%', ...style }
: { height: typeof height === 'number' ? height : undefined, width: '100%', ...style }
if (isRadar) {
// Nur hier das streng typisierte Radar-Plugin übergeben
const radarPlugins = radarIconsPlugin ? [radarIconsPlugin] as Plugin<'radar'>[] : undefined
return (
<div className={className} style={wrapperStyle}>
<Radar data={data} options={options} plugins={radarPlugins} />
</div>
)
}
// andere Chart-Typen ohne Radar-Plugin
const chartMap = {
line: Line,
bar: Bar,
radar: Radar,
doughnut: Doughnut,
polararea: PolarArea,
bubble: Bubble,
@ -213,16 +287,11 @@ export default function Chart({
scatter: Scatter,
} as const
const ChartComponent = chartMap[type]
// Container: bei fixer Höhe -> px setzen; bei auto -> Höhe aus aspectRatio
const wrapperStyle: React.CSSProperties = isAutoHeight
? { width: '100%', ...style }
: { height: typeof height === 'number' ? height : undefined, width: '100%', ...style }
const NonRadar = chartMap[type as Exclude<ChartType, 'radar'>]
return (
<div className={className} style={wrapperStyle}>
<ChartComponent data={data} options={options} />
<NonRadar data={data} options={options} />
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useState, useEffect, useRef, useCallback, useMemo, useTransition } from 'react'
import Modal from './Modal'
import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react'
@ -8,6 +8,7 @@ import LoadingSpinner from './LoadingSpinner'
import { Player, Team } from '../../../types/team'
import Pagination from './Pagination'
import { AnimatePresence, motion } from 'framer-motion'
import Switch from './Switch'
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
@ -25,19 +26,34 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const [allUsers, setAllUsers] = useState<Player[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [knownUsers, setKnownUsers] = useState<Record<string, Player>>({})
const [invitedIds, setInvitedIds] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isInviting, setIsInviting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [sentCount, setSentCount] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
// Toggle: nur Spieler ohne Team
const [onlyFree, setOnlyFree] = useState(false)
// Sanftes UI-Update
const [isPending, startTransition] = useTransition()
const [isFetching, setIsFetching] = useState(false)
const abortRef = useRef<AbortController | null>(null)
// 🔽 Anti-Flacker: verzögert anzeigen + Mindestdauer
const SPINNER_DELAY_MS = 120
const SPINNER_MIN_MS = 250
const [spinnerVisible, setSpinnerVisible] = useState(false)
const spinnerShowTimer = useRef<number | null>(null)
const spinnerShownAt = useRef<number | null>(null)
// Dynamisch berechnete Items pro Seite
const [usersPerPage, setUsersPerPage] = useState<number>(9)
const [currentPage, setCurrentPage] = useState(1)
const [invitedStatus, setInvitedStatus] = useState<Record<string, InviteStatus>>({})
// Refs für die Messung des verfügbaren Platzes
// Refs / Layout
const descRef = useRef<HTMLParagraphElement>(null)
const selectedWrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLDivElement>(null)
@ -45,46 +61,132 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const gridRef = useRef<HTMLDivElement>(null)
const firstCardRef = useRef<HTMLDivElement>(null)
// aktuelle Grid-Höhe halten
const [gridHoldHeight, setGridHoldHeight] = useState<number>(0)
useEffect(() => {
if (show) {
fetchUsersNotInTeam()
setIsSuccess(false)
setInvitedIds([])
setInvitedStatus({})
}
if (!show) return
void fetchUsers({ resetLayout: true })
setIsSuccess(false)
setInvitedIds([])
setInvitedStatus({})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show])
useEffect(() => {
if (!gridRef.current) return;
if (!show) return
setCurrentPage(1)
void fetchUsers({ resetLayout: false })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onlyFree])
useEffect(() => {
if (!gridRef.current) return
const observer = new ResizeObserver(() => {
if (!gridRef.current) return;
const gridHeight = gridRef.current.clientHeight;
const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0;
const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1;
const cols = window.innerWidth >= 640 ? 3 : 2; // sm:grid-cols-3 vs grid-cols-2
setUsersPerPage(rows * cols);
});
if (!gridRef.current) return
const gridHeight = gridRef.current.clientHeight
const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0
const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1
const cols = window.innerWidth >= 640 ? 3 : 2
setUsersPerPage(rows * cols)
})
observer.observe(gridRef.current)
return () => observer.disconnect()
}, [])
observer.observe(gridRef.current);
return () => observer.disconnect();
}, []);
const fetchUsersNotInTeam = async () => {
async function fetchUsers(opts: { resetLayout: boolean }) {
try {
setIsLoading(true)
const res = await fetch(`/api/team/available-users?teamId=${encodeURIComponent(team.id)}`)
abortRef.current?.abort()
const ctrl = new AbortController()
abortRef.current = ctrl
// Höhe einfrieren
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight)
// Start Fetch
setIsFetching(true)
// 🔽 Spinner NICHT sofort zeigen erst nach kurzer Verzögerung
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current)
spinnerShowTimer.current = window.setTimeout(() => {
setSpinnerVisible(true)
spinnerShownAt.current = Date.now()
}, SPINNER_DELAY_MS)
const qs = new URLSearchParams({ teamId: team.id })
if (onlyFree) qs.set('onlyFree', 'true')
const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
signal: ctrl.signal,
cache: 'no-store',
})
if (!res.ok) throw new Error('load failed')
const data = await res.json()
setAllUsers(data.users || [])
startTransition(() => {
setAllUsers(data.users || [])
setKnownUsers(prev => {
const next = { ...prev }
for (const u of (data.users || [])) next[u.steamId] = u
return next
})
if (opts.resetLayout) {
setSelectedIds([])
setInvitedIds([])
setIsSuccess(false)
}
})
} catch (e: any) {
if (e?.name !== 'AbortError') console.error('Fehler beim Laden der Benutzer:', e)
} finally {
setIsLoading(false)
setIsFetching(false)
abortRef.current = null
// 🔽 Spinner Mindestdauer respektieren
const hide = () => {
setSpinnerVisible(false)
spinnerShownAt.current = null
setGridHoldHeight(0)
}
if (spinnerShowTimer.current) {
// Wenn der Delay-Timer noch nicht gefeuert hat: einfach abbrechen
window.clearTimeout(spinnerShowTimer.current)
spinnerShowTimer.current = null
// Der Spinner wurde evtl. nie sichtbar -> direkt aufräumen
if (!spinnerShownAt.current) {
hide()
return
}
}
if (spinnerShownAt.current) {
const elapsed = Date.now() - spinnerShownAt.current
const remain = Math.max(0, SPINNER_MIN_MS - elapsed)
if (remain > 0) {
window.setTimeout(hide, remain)
} else {
hide()
}
} else {
// Falls er nie sichtbar war
hide()
}
}
}
const handleSelect = (steamId: string) => {
setSelectedIds(prev =>
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
)
setSelectedIds(prev => {
const isSelected = prev.includes(steamId)
// Beim Hinzufügen: sicherstellen, dass wir die Card-Daten im Cache haben
if (!isSelected) {
const u = allUsers.find(u => u.steamId === steamId)
if (u) {
setKnownUsers(map => (map[steamId] ? map : { ...map, [steamId]: u }))
}
}
return isSelected ? prev.filter(id => id !== steamId) : [...prev, steamId]
})
}
const handleInvite = async () => {
@ -108,7 +210,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
let json: any = null
try { json = await res.clone().json() } catch {}
// --- Auswertung: bevorzugt 'results', fallback auf 'invitationIds' ---
let results: { steamId: string; ok: boolean }[] = []
if (json?.results && Array.isArray(json.results)) {
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
@ -116,7 +217,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const okSet = new Set<string>(json.invitationIds)
results = ids.map(id => ({ steamId: id, ok: okSet.has(id) }))
} else {
// Keine verwertbaren Details → alles als failed markieren
results = ids.map(id => ({ steamId: id, ok: false }))
}
@ -133,8 +233,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
setSentCount(okCount)
setIsSuccess(true)
setSelectedIds([])
// nur beim Erfolg wenigstens einer Einladung „onSuccess“ und Auto-Close
if (okCount > 0) onSuccess()
} catch (err) {
console.error('Fehler beim Einladen:', err)
@ -149,34 +247,23 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
useEffect(() => {
if (!isSuccess) return
// nur automatisch schließen, wenn wirklich etwas versendet/ hinzugefügt wurde
if (sentCount > 0) {
const t = setTimeout(() => {
const modalEl = document.getElementById('invite-members-modal')
if (modalEl && (window as any).HSOverlay?.close) {
(window as any).HSOverlay.close(modalEl)
}
if (modalEl && (window as any).HSOverlay?.close) (window as any).HSOverlay.close(modalEl)
onClose()
}, 2000)
return () => clearTimeout(t)
}
}, [isSuccess, sentCount, onClose])
useEffect(() => {
setCurrentPage(1)
}, [searchTerm])
useEffect(() => { setCurrentPage(1) }, [searchTerm])
const filteredUsers = allUsers.filter(user =>
user.name?.toLowerCase().includes(searchTerm.toLowerCase())
const filteredUsers = useMemo(
() => allUsers.filter(u => u.name?.toLowerCase().includes(searchTerm.toLowerCase())),
[allUsers, searchTerm]
)
// Sortierung: ausgewählte nach vorne (nur für Anzeige der Selected-List)
const sortedUsers = [...filteredUsers].sort((a, b) => {
const aSelected = selectedIds.includes(a.steamId) ? -1 : 0
const bSelected = selectedIds.includes(b.steamId) ? -1 : 0
return aSelected - bSelected
})
const unselectedUsers = filteredUsers.filter(user =>
!selectedIds.includes(user.steamId) &&
(!isSuccess || !invitedIds.includes(user.steamId))
@ -186,31 +273,24 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const startIdx = (currentPage - 1) * Math.max(1, usersPerPage)
const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + Math.max(1, usersPerPage))
// Seite einklemmen, wenn PerPage / Anzahl sich ändern
useEffect(() => {
if (totalPages === 0 && currentPage !== 1) setCurrentPage(1)
else if (currentPage > totalPages) setCurrentPage(totalPages || 1)
}, [totalPages, currentPage])
// ---- Dynamische Berechnung von usersPerPage (keine Scrollbars im Modal) ----
const recalcUsersPerPage = useCallback(() => {
const gridEl = gridRef.current
if (!gridEl) return
// Modal-Body ist der Parent des Grids
const bodyEl = gridEl.parentElement as HTMLElement | null
if (!bodyEl) return
// bereits genutzte Höhe "oberhalb" des Grids
const outer = (el: HTMLElement | null) => {
if (!el) return 0
const cs = getComputedStyle(el)
return el.offsetHeight + parseFloat(cs.marginTop || '0') + parseFloat(cs.marginBottom || '0')
}
// Abstand des Grids selbst (z.B. mt-4)
const gridMT = parseFloat(getComputedStyle(gridEl).marginTop || '0')
const usedAbove =
outer(descRef.current) +
outer(selectedWrapRef.current) +
@ -218,45 +298,27 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
outer(successRef.current) +
gridMT
// Optional etwas Platz für Pagination reservieren (wenn sie sichtbar wäre)
const reserveForPagination = (!isLoading && !isSuccess && totalPages > 1) ? 48 : 0
const reserveForPagination = (!isFetching && !isSuccess && totalPages > 1) ? 48 : 0
const availableHeight = Math.max(0, bodyEl.clientHeight - usedAbove - reserveForPagination)
// Ermittel Spalten und Zeilenabstand aus dem Grid
const csGrid = getComputedStyle(gridEl)
const cols = Math.max(
1,
csGrid.gridTemplateColumns.split(' ').filter(Boolean).length
)
const cols = Math.max(1, csGrid.gridTemplateColumns.split(' ').filter(Boolean).length)
const rowGap = parseFloat(csGrid.rowGap || '0')
// Kartenhöhe messen: nimm die erste echte Karte, sonst Fallback
const cardEl = firstCardRef.current
let cardHeight = cardEl?.offsetHeight || 0
if (!cardHeight) {
// vorsichtiger Fallback (angepasst an typische MiniCard)
cardHeight = 140
}
if (!cardHeight) cardHeight = 140
// rows * cardHeight + (rows - 1) * rowGap <= availableHeight
// => rows <= (availableHeight + rowGap) / (cardHeight + rowGap)
const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (cardHeight + rowGap)))
const nextPerPage = Math.max(1, rows * cols)
if (nextPerPage !== usersPerPage) {
setUsersPerPage(nextPerPage)
}
}, [isLoading, isSuccess, totalPages, usersPerPage])
setUsersPerPage(prev => (prev !== nextPerPage ? nextPerPage : prev))
}, [isFetching, isSuccess, totalPages])
// Recalc auf Resize/Content-Änderungen
useEffect(() => {
// Direkt versuchen
recalcUsersPerPage()
const gridEl = gridRef.current
if (!gridEl) return
const bodyEl = gridEl.parentElement as HTMLElement | null
const cardEl = firstCardRef.current
@ -267,10 +329,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const onResize = () => recalcUsersPerPage()
window.addEventListener('resize', onResize)
// bei Show/Hide leichter Delay, bis Layout stabil ist
const id = window.setTimeout(recalcUsersPerPage, 60)
return () => {
window.clearTimeout(id)
ro.disconnect()
@ -288,11 +347,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
closeButtonColor={!isSuccess ? (isSuccess ? 'teal' : 'blue') : undefined}
closeButtonTitle={
!isSuccess
? (
isInviting
? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...')
: (directAdd ? 'Hinzufügen' : 'Einladungen senden')
)
? (isInviting ? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...') : (directAdd ? 'Hinzufügen' : 'Einladungen senden'))
: undefined
}
closeButtonLoading={!isSuccess && isInviting}
@ -304,37 +359,53 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
: 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}
</p>
{/* Ausgewählte Benutzer anzeigen */}
{/* Filterleiste */}
<div ref={searchRef} className="mt-1 grid grid-cols-[auto_1fr] items-center gap-x-3">
{/* kompakteres Suchfeld */}
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-40 sm:w-56 rounded border px-2 py-1.5 text-[13px]
focus:outline-none focus:ring focus:ring-blue-400
dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
/>
{/* Toggle bekommt die breite zweite Spalte */}
<div className="justify-self-end min-w-[240px] sm:min-w-[300px]">
<Switch
id="only-free-switch"
checked={onlyFree}
onChange={(v) => startTransition(() => setOnlyFree(v))}
labelLeft="Alle"
labelRight="Nur ohne Team"
/>
</div>
</div>
{/* Ausgewählte */}
{selectedIds.length > 0 && (
<div ref={selectedWrapRef} className="col-span-full">
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">
Ausgewählte Spieler:
</h3>
<div ref={selectedWrapRef} className="col-span-full mt-2">
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">Ausgewählte Spieler:</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
<AnimatePresence initial={false}>
{selectedIds.map((id) => {
const user = allUsers.find((u) => u.steamId === id)
const user = knownUsers[id]
if (!user) return null
return (
<motion.div
key={user.steamId}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<motion.div key={user.steamId} layout initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<MiniCard
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected={true}
selected
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
hideActions={true}
hideActions
rank={user.premierRank}
/>
</motion.div>
@ -345,43 +416,47 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
</div>
)}
<div ref={searchRef}>
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mt-2 w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring focus:ring-blue-400 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
/>
</div>
{isSuccess && (
<div ref={successRef} className="mt-2 px-4 py-2 text-sm rounded-lg border"
style={{ background: sentCount ? '#dcfce7' : '#fee2e2', borderColor: sentCount ? '#bbf7d0' : '#fecaca', color: sentCount ? '#166534' : '#991b1b' }}>
{directAdd
? (sentCount === 0
? 'Niemand konnte hinzugefügt werden.'
: sentCount === invitedIds.length
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
: `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt, andere fehlgeschlagen.`)
: (sentCount === 0
? 'Keine Einladungen versendet.'
: sentCount === invitedIds.length
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)}
<div
ref={successRef}
className={`mt-2 px-4 py-2 text-sm rounded-lg border transition-opacity duration-150 ${
isSuccess ? 'opacity-100' : 'opacity-0'
}`}
style={{
minHeight: 40, // ~ Höhe reservieren, vermeidet Jump
background: sentCount ? '#dcfce7' : '#fee2e2',
borderColor: sentCount ? '#bbf7d0' : '#fecaca',
color: sentCount ? '#166534' : '#991b1b'
}}
>
{isSuccess && (
<>
{directAdd
? (sentCount === 0
? 'Niemand konnte hinzugefügt werden.'
: sentCount === invitedIds.length
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
: `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt, andere fehlgeschlagen.`)
: (sentCount === 0
? 'Keine Einladungen versendet.'
: sentCount === invitedIds.length
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)}
</>
)}
</div>
)}
{/* Grid der MiniCards — Höhe/Anzahl wird dynamisch berechnet */}
<div ref={gridRef} className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{isLoading ? (
<LoadingSpinner />
) : filteredUsers.length === 0 ? (
{/* Grid */}
<div
ref={gridRef}
className="relative grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4 transition-[min-height] duration-150"
style={gridHoldHeight ? { minHeight: gridHoldHeight } : undefined}
>
{unselectedUsers.length === 0 ? (
<div className="col-span-full text-center text-gray-500 dark:text-neutral-400">
{allUsers.length === 0
? directAdd
? 'Keine Benutzer verfügbar :('
: 'Niemand zum Einladen verfügbar :('
? directAdd ? 'Keine Benutzer verfügbar :(' : 'Niemand zum Einladen verfügbar :('
: 'Keine Benutzer gefunden.'}
</div>
) : (
@ -394,7 +469,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
// 👉 erste sichtbare Karte als Mess-Referenz verwenden
ref={idx === 0 ? firstCardRef : undefined}
>
<MiniCard
@ -414,7 +488,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
))}
{isSuccess && invitedIds.map((id, idx) => {
const user = allUsers.find((u) => u.steamId === id)
const user = knownUsers[id]
if (!user) return null
return (
<motion.div
@ -444,23 +518,29 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
})}
</AnimatePresence>
)}
{/* Overlay-Spinner: erscheint verzögert und bleibt min. SPINNER_MIN_MS sichtbar */}
{spinnerVisible && (
<div className="absolute inset-0 bg-black/5 dark:bg-white/5 backdrop-blur-[1px] pointer-events-none flex items-center justify-center rounded transition-opacity duration-150">
<LoadingSpinner />
</div>
)}
</div>
{/* Pagination */}
{!isLoading && !isSuccess && totalPages > 1 && (
<div className="mt-3 flex justify-center">
<div className="mt-3 min-h-[44px] flex justify-center items-center">
{!isFetching && !isSuccess && totalPages > 1 ? (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={(p) => {
const next = Math.max(1, Math.min(totalPages, p))
setCurrentPage(next)
// nach Page-Wechsel neu kalkulieren (Layout kann „springen“)
setTimeout(() => recalcUsersPerPage(), 0)
}}
/>
</div>
)}
) : null}
</div>
</Modal>
)
}

View File

@ -469,19 +469,6 @@ export default function MapVotePanel({ match }: Props) {
[activeMapKeys]
)
// Response-Struktur des /api/user/:id/winrate Endpoints (wir brauchen nur keys/values)
type WinrateResponse = { keys: string[]; values: number[] }
// Helper: Winrate eines Spielers als Dictionary { [mapKey]: pct }
async function fetchWinrate(steamId: string): Promise<Record<string, number>> {
const r = await fetch(`/api/user/${steamId}/winrate?onlyActive=true`, { cache: 'no-store' })
if (!r.ok) throw new Error('winrate fetch failed')
const j: WinrateResponse = await r.json()
const out: Record<string, number> = {}
j.keys.forEach((key, i) => { out[key] = j.values[i] ?? 0 })
return out
}
// Helper: Durchschnitt (nur finite Werte)
function avg(values: number[]) {
const valid = values.filter(v => Number.isFinite(v))
@ -905,20 +892,25 @@ export default function MapVotePanel({ match }: Props) {
// Winrate-Tab
<div className="w-full max-w-xl justify-self-center">
<div className="rounded-lg border border-white/10 bg-neutral-900/40 backdrop-blur-sm p-3" style={{ height: 360 }}>
<div className="w-full max-w-xl justify-self-center">
<div className="h-full w-full max-w-xl justify-self-center">
<Chart
type="radar"
labels={activeMapLabels}
labels={activeMapLabels} // z.B. ['Ancient','Anubis', ...]
height={"auto"}
radarZeroToFirstTick // 0% auf ersten Tick
radarSubStepBand={4} // (0..20%) wird in 20..24% gestreckt (sichtbar über 0)
radarHideTicks // Ticks ausblenden (optional)
datasets={[
{ label: teamLeft?.name ?? 'Team Links', data: teamRadarLeft,
{ label: teamLeft?.name ?? 'Links', data: teamRadarLeft,
borderColor: 'rgba(54,162,235,0.9)', backgroundColor: 'rgba(54,162,235,0.20)', borderWidth: 2 },
{ label: teamRight?.name ?? 'Team Rechts', data: teamRadarRight,
{ label: teamRight?.name ?? 'Rechts', data: teamRadarRight,
borderColor: 'rgba(255,99,132,0.9)', backgroundColor: 'rgba(255,99,132,0.20)', borderWidth: 2 },
]}
// Icons (array passt 1:1 zu labels) ODER als Mapping label->url
radarIcons={activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`)}
radarIconSize={28}
radarIconOffset={24}
// Skala + Offset (weil wir werte um +20 schieben)
radarMax={120}
radarStepSize={20}
radarAddRingOffset
/>
</div>
</div>

View File

@ -117,6 +117,8 @@ export default function Modal({
rounded-xl
/* -> Höhe des Panels auf den Viewport begrenzen */
max-h-[calc(100vh-56px)]
/* -> Fix: Verhindert Höhen-Jumps bei Filterwechsel */
min-h-[620px]
"
>
{/* Header (fixe Höhe) */}
@ -156,6 +158,7 @@ export default function Modal({
p-4 flex-1 min-h-0
${scrollBody ? 'overflow-y-auto' : 'overflow-y-hidden'}
`}
style={{ scrollbarGutter: 'stable both-edges' }}
>
{children}
</div>

View File

@ -5,11 +5,13 @@ export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const teamId = searchParams.get('teamId')
const onlyFree = (searchParams.get('onlyFree') ?? '').toLowerCase() === 'true'
if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
}
// 1) Team laden
// 1) Team laden (für den Standard-Fall weiterhin nötig)
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { activePlayers: true, inactivePlayers: true }
@ -18,22 +20,41 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
const members = new Set([...team.activePlayers, ...team.inactivePlayers])
let excludeIds: string[] = []
// 2) Pending-Invites dieses Teams
const pendingInvites = await prisma.teamInvite.findMany({
where: { teamId },
select: { steamId: true }
})
const invited = new Set(pendingInvites.map(i => i.steamId))
if (onlyFree) {
// 2a) Nur Spieler ohne Team => ALLE Teammitglieder (aus allen Teams) ausschließen
const allTeams = await prisma.team.findMany({
select: { activePlayers: true, inactivePlayers: true }
})
const occupied = new Set<string>()
for (const t of allTeams) {
for (const id of (t.activePlayers ?? [])) occupied.add(id)
for (const id of (t.inactivePlayers ?? [])) occupied.add(id)
}
excludeIds = Array.from(occupied)
// Hinweis: Pending-Invites zählen nicht als "hat Team" werden hier NICHT ausgeschlossen.
} else {
// 2b) Standard: Mitglieder + bereits eingeladene dieses Teams ausschließen
const members = new Set<string>([
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? [])
])
// 3) Kandidaten: nur canBeInvited === true, nicht Mitglied, nicht eingeladen
const excludeIds = Array.from(new Set([...members, ...invited]))
const pendingInvites = await prisma.teamInvite.findMany({
where: { teamId },
select: { steamId: true }
})
const invited = new Set<string>(pendingInvites.map(i => i.steamId))
excludeIds = Array.from(new Set([...members, ...invited]))
}
// 3) Kandidaten: nur einladbare, nicht in excludeIds
const availableUsers = await prisma.user.findMany({
where: {
canBeInvited: true, // << nur einladbare
steamId: { notIn: excludeIds } // << nicht schon Mitglied/geladen
canBeInvited: true,
steamId: { notIn: excludeIds }
},
select: {
steamId: true,