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, Legend,
Title, Title,
Filler, Filler,
type Plugin,
} from 'chart.js' } from 'chart.js'
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2' import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
import { useMemo } from 'react' import { useMemo, useRef } from 'react'
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@ -31,7 +32,8 @@ ChartJS.register(
Filler Filler
) )
type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter' type ChartType =
| 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
type BaseDataset = { type BaseDataset = {
label: string label: string
@ -52,35 +54,32 @@ type ChartProps = {
/** Fixe Höhe in px ODER 'auto' (Höhe aus aspectRatio) */ /** Fixe Höhe in px ODER 'auto' (Höhe aus aspectRatio) */
height?: number | 'auto' height?: number | 'auto'
/** Wird genutzt, wenn height='auto'. Standard: 2 (Breite/Höhe = 2:1) */ /** Wird genutzt, wenn height='auto'. Standard: 2 (Breite/Höhe = 2:1) */
aspectRatio?: number aspectRatio?: number
/** Legende & Achsen (nicht Radar) ausblenden */
/** Legend & Achsen (nicht Radar) ausblenden */
hideLabels?: boolean hideLabels?: boolean
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
/** Radar-spezifisch */ /** Radar-Achse */
radarMin?: number // default 0 radarMin?: number // default 0
radarMax?: number // default 100 radarMax?: number // default 120 (wegen +20 Offset)
radarStepSize?: number // default 20 (erster Ring) radarStepSize?: number // default 20
radarZeroToFirstTick?: boolean // default true 0% auf ersten Tick zeichnen radarHideTicks?: boolean // default true
radarHideTicks?: boolean // default true 100%, 80%, … ausblenden
/** Feintuning Radar */ /** Radar-Darstellung */
radarFillMode?: boolean | number | 'origin' | 'start' | 'end' // default: true radarFillMode?: boolean | number | 'origin' | 'start' | 'end' // default: true
radarTension?: number // default: 0.2 radarTension?: number // default: 0
radarSpanGaps?: boolean // default: false radarSpanGaps?: boolean // default: false
/** /** Werte auf den Ringen +stepSize nach außen schieben (Tooltips bleiben original) */
* Werte im Intervall (0..ersterTick) werden in radarAddRingOffset?: boolean
* [ersterTick .. ersterTick+radarSubStepBand] gemappt,
* damit sie sichtbar ÜBER 0% liegen. /** Icons statt Text-Labels */
* Einheit: Prozentpunkte. default: 4 radarIcons?: string[] | Record<string, string>
*/ radarIconSize?: number // px
radarSubStepBand?: number radarIconOffset?: number // Abstand über dem äußersten Ring (Skaleneinheiten)
} }
export default function Chart({ export default function Chart({
@ -94,55 +93,114 @@ export default function Chart({
className = '', className = '',
style, style,
// Radar defaults: 0..120 mit 20er Ringen (0/20/40/60/80/100/120)
radarMin = 0, radarMin = 0,
radarMax = 100, radarMax = 120,
radarStepSize = 20, radarStepSize = 20,
radarZeroToFirstTick = true,
radarHideTicks = true, radarHideTicks = true,
radarFillMode = true, radarFillMode = true,
radarTension = 0.2, radarTension = 0,
radarSpanGaps = false, radarSpanGaps = false,
radarSubStepBand = 4,
radarAddRingOffset = true,
radarIcons,
radarIconSize = 28,
radarIconOffset = 6,
}: ChartProps) { }: ChartProps) {
const isRadar = type === 'radar' const isRadar = type === 'radar'
const isAutoHeight = height === 'auto' const isAutoHeight = height === 'auto'
// Original-Daten für Tooltip-Anzeige (echte Prozentwerte) // -------- Daten vorbereiten (Radar: +stepSize verschieben; Tooltips bleiben original) --------
const originalDatasets = datasets const originalDatasets = datasets
// Werte fürs Radar aufbereiten:
// - 0% auf ersten Tick
// - (0..ersterTick) leicht oberhalb des ersten Ticks verteilen
const preparedDatasets = useMemo(() => { const preparedDatasets = useMemo(() => {
if (!isRadar) return originalDatasets if (!isRadar) return originalDatasets
const step = Math.max(radarStepSize, 1) const ringOffset = radarAddRingOffset ? Math.max(1, radarStepSize) : 0
const band = Math.max(0, radarSubStepBand) const clamp = (v: number) => Math.min(v, radarMax)
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
}
return originalDatasets.map(ds => ({ return originalDatasets.map(ds => ({
...ds, ...ds,
fill: ds.fill ?? radarFillMode, fill: ds.fill ?? radarFillMode,
tension: ds.tension ?? radarTension, tension: ds.tension ?? radarTension,
spanGaps: ds.spanGaps ?? radarSpanGaps, 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( const data = useMemo(
() => ({ labels, datasets: preparedDatasets }), () => ({ labels, datasets: preparedDatasets }),
[labels, 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 options = useMemo(() => {
const base: any = { const base: any = {
responsive: true, responsive: true,
@ -153,7 +211,7 @@ export default function Chart({
title: { display: !!title, text: title }, title: { display: !!title, text: title },
tooltip: { tooltip: {
callbacks: { callbacks: {
// Tooltip zeigt Originalwert (nicht den gesnappten) an // Tooltip zeigt Originalwerte (ohne +Offset)
label: (ctx: any) => { label: (ctx: any) => {
const dsIdx = ctx.datasetIndex const dsIdx = ctx.datasetIndex
const i = ctx.dataIndex const i = ctx.dataIndex
@ -181,7 +239,8 @@ export default function Chart({
}, },
angleLines: { color: 'rgba(255,255,255,0.08)' }, angleLines: { color: 'rgba(255,255,255,0.08)' },
grid: { 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) { } else if (hideLabels) {
@ -202,10 +261,25 @@ export default function Chart({
originalDatasets, 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 = { const chartMap = {
line: Line, line: Line,
bar: Bar, bar: Bar,
radar: Radar,
doughnut: Doughnut, doughnut: Doughnut,
polararea: PolarArea, polararea: PolarArea,
bubble: Bubble, bubble: Bubble,
@ -213,16 +287,11 @@ export default function Chart({
scatter: Scatter, scatter: Scatter,
} as const } as const
const ChartComponent = chartMap[type] const NonRadar = chartMap[type as Exclude<ChartType, 'radar'>]
// 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 }
return ( return (
<div className={className} style={wrapperStyle}> <div className={className} style={wrapperStyle}>
<ChartComponent data={data} options={options} /> <NonRadar data={data} options={options} />
</div> </div>
) )
} }

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback, useMemo, useTransition } from 'react'
import Modal from './Modal' import Modal from './Modal'
import MiniCard from './MiniCard' import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
@ -8,6 +8,7 @@ import LoadingSpinner from './LoadingSpinner'
import { Player, Team } from '../../../types/team' import { Player, Team } from '../../../types/team'
import Pagination from './Pagination' import Pagination from './Pagination'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import Switch from './Switch'
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending' 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 [allUsers, setAllUsers] = useState<Player[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([]) const [selectedIds, setSelectedIds] = useState<string[]>([])
const [knownUsers, setKnownUsers] = useState<Record<string, Player>>({})
const [invitedIds, setInvitedIds] = useState<string[]>([]) const [invitedIds, setInvitedIds] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isInviting, setIsInviting] = useState(false) const [isInviting, setIsInviting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false) const [isSuccess, setIsSuccess] = useState(false)
const [sentCount, setSentCount] = useState(0) const [sentCount, setSentCount] = useState(0)
const [searchTerm, setSearchTerm] = useState('') 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 // Dynamisch berechnete Items pro Seite
const [usersPerPage, setUsersPerPage] = useState<number>(9) const [usersPerPage, setUsersPerPage] = useState<number>(9)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [invitedStatus, setInvitedStatus] = useState<Record<string, InviteStatus>>({}) const [invitedStatus, setInvitedStatus] = useState<Record<string, InviteStatus>>({})
// Refs für die Messung des verfügbaren Platzes // Refs / Layout
const descRef = useRef<HTMLParagraphElement>(null) const descRef = useRef<HTMLParagraphElement>(null)
const selectedWrapRef = useRef<HTMLDivElement>(null) const selectedWrapRef = useRef<HTMLDivElement>(null)
const searchRef = 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 gridRef = useRef<HTMLDivElement>(null)
const firstCardRef = useRef<HTMLDivElement>(null) const firstCardRef = useRef<HTMLDivElement>(null)
// aktuelle Grid-Höhe halten
const [gridHoldHeight, setGridHoldHeight] = useState<number>(0)
useEffect(() => { useEffect(() => {
if (show) { if (!show) return
fetchUsersNotInTeam() void fetchUsers({ resetLayout: true })
setIsSuccess(false) setIsSuccess(false)
setInvitedIds([]) setInvitedIds([])
setInvitedStatus({}) setInvitedStatus({})
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [show]) }, [show])
useEffect(() => { 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(() => { const observer = new ResizeObserver(() => {
if (!gridRef.current) return; if (!gridRef.current) return
const gridHeight = gridRef.current.clientHeight; const gridHeight = gridRef.current.clientHeight
const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0; const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0
const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1; const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1
const cols = window.innerWidth >= 640 ? 3 : 2; // sm:grid-cols-3 vs grid-cols-2 const cols = window.innerWidth >= 640 ? 3 : 2
setUsersPerPage(rows * cols); setUsersPerPage(rows * cols)
}); })
observer.observe(gridRef.current)
return () => observer.disconnect()
}, [])
observer.observe(gridRef.current); async function fetchUsers(opts: { resetLayout: boolean }) {
return () => observer.disconnect();
}, []);
const fetchUsersNotInTeam = async () => {
try { try {
setIsLoading(true) abortRef.current?.abort()
const res = await fetch(`/api/team/available-users?teamId=${encodeURIComponent(team.id)}`) 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() 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 { } 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) => { const handleSelect = (steamId: string) => {
setSelectedIds(prev => setSelectedIds(prev => {
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId] 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 () => { const handleInvite = async () => {
@ -108,7 +210,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
let json: any = null let json: any = null
try { json = await res.clone().json() } catch {} try { json = await res.clone().json() } catch {}
// --- Auswertung: bevorzugt 'results', fallback auf 'invitationIds' ---
let results: { steamId: string; ok: boolean }[] = [] let results: { steamId: string; ok: boolean }[] = []
if (json?.results && Array.isArray(json.results)) { if (json?.results && Array.isArray(json.results)) {
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok })) 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) const okSet = new Set<string>(json.invitationIds)
results = ids.map(id => ({ steamId: id, ok: okSet.has(id) })) results = ids.map(id => ({ steamId: id, ok: okSet.has(id) }))
} else { } else {
// Keine verwertbaren Details → alles als failed markieren
results = ids.map(id => ({ steamId: id, ok: false })) results = ids.map(id => ({ steamId: id, ok: false }))
} }
@ -133,8 +233,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
setSentCount(okCount) setSentCount(okCount)
setIsSuccess(true) setIsSuccess(true)
setSelectedIds([]) setSelectedIds([])
// nur beim Erfolg wenigstens einer Einladung „onSuccess“ und Auto-Close
if (okCount > 0) onSuccess() if (okCount > 0) onSuccess()
} catch (err) { } catch (err) {
console.error('Fehler beim Einladen:', err) console.error('Fehler beim Einladen:', err)
@ -149,34 +247,23 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
useEffect(() => { useEffect(() => {
if (!isSuccess) return if (!isSuccess) return
// nur automatisch schließen, wenn wirklich etwas versendet/ hinzugefügt wurde
if (sentCount > 0) { if (sentCount > 0) {
const t = setTimeout(() => { const t = setTimeout(() => {
const modalEl = document.getElementById('invite-members-modal') const modalEl = document.getElementById('invite-members-modal')
if (modalEl && (window as any).HSOverlay?.close) { if (modalEl && (window as any).HSOverlay?.close) (window as any).HSOverlay.close(modalEl)
(window as any).HSOverlay.close(modalEl)
}
onClose() onClose()
}, 2000) }, 2000)
return () => clearTimeout(t) return () => clearTimeout(t)
} }
}, [isSuccess, sentCount, onClose]) }, [isSuccess, sentCount, onClose])
useEffect(() => { useEffect(() => { setCurrentPage(1) }, [searchTerm])
setCurrentPage(1)
}, [searchTerm])
const filteredUsers = allUsers.filter(user => const filteredUsers = useMemo(
user.name?.toLowerCase().includes(searchTerm.toLowerCase()) () => 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 => const unselectedUsers = filteredUsers.filter(user =>
!selectedIds.includes(user.steamId) && !selectedIds.includes(user.steamId) &&
(!isSuccess || !invitedIds.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 startIdx = (currentPage - 1) * Math.max(1, usersPerPage)
const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + Math.max(1, usersPerPage)) const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + Math.max(1, usersPerPage))
// Seite einklemmen, wenn PerPage / Anzahl sich ändern
useEffect(() => { useEffect(() => {
if (totalPages === 0 && currentPage !== 1) setCurrentPage(1) if (totalPages === 0 && currentPage !== 1) setCurrentPage(1)
else if (currentPage > totalPages) setCurrentPage(totalPages || 1) else if (currentPage > totalPages) setCurrentPage(totalPages || 1)
}, [totalPages, currentPage]) }, [totalPages, currentPage])
// ---- Dynamische Berechnung von usersPerPage (keine Scrollbars im Modal) ----
const recalcUsersPerPage = useCallback(() => { const recalcUsersPerPage = useCallback(() => {
const gridEl = gridRef.current const gridEl = gridRef.current
if (!gridEl) return if (!gridEl) return
// Modal-Body ist der Parent des Grids
const bodyEl = gridEl.parentElement as HTMLElement | null const bodyEl = gridEl.parentElement as HTMLElement | null
if (!bodyEl) return if (!bodyEl) return
// bereits genutzte Höhe "oberhalb" des Grids
const outer = (el: HTMLElement | null) => { const outer = (el: HTMLElement | null) => {
if (!el) return 0 if (!el) return 0
const cs = getComputedStyle(el) const cs = getComputedStyle(el)
return el.offsetHeight + parseFloat(cs.marginTop || '0') + parseFloat(cs.marginBottom || '0') 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 gridMT = parseFloat(getComputedStyle(gridEl).marginTop || '0')
const usedAbove = const usedAbove =
outer(descRef.current) + outer(descRef.current) +
outer(selectedWrapRef.current) + outer(selectedWrapRef.current) +
@ -218,45 +298,27 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
outer(successRef.current) + outer(successRef.current) +
gridMT gridMT
// Optional etwas Platz für Pagination reservieren (wenn sie sichtbar wäre) const reserveForPagination = (!isFetching && !isSuccess && totalPages > 1) ? 48 : 0
const reserveForPagination = (!isLoading && !isSuccess && totalPages > 1) ? 48 : 0
const availableHeight = Math.max(0, bodyEl.clientHeight - usedAbove - reserveForPagination) const availableHeight = Math.max(0, bodyEl.clientHeight - usedAbove - reserveForPagination)
// Ermittel Spalten und Zeilenabstand aus dem Grid
const csGrid = getComputedStyle(gridEl) const csGrid = getComputedStyle(gridEl)
const cols = Math.max( const cols = Math.max(1, csGrid.gridTemplateColumns.split(' ').filter(Boolean).length)
1,
csGrid.gridTemplateColumns.split(' ').filter(Boolean).length
)
const rowGap = parseFloat(csGrid.rowGap || '0') const rowGap = parseFloat(csGrid.rowGap || '0')
// Kartenhöhe messen: nimm die erste echte Karte, sonst Fallback
const cardEl = firstCardRef.current const cardEl = firstCardRef.current
let cardHeight = cardEl?.offsetHeight || 0 let cardHeight = cardEl?.offsetHeight || 0
if (!cardHeight) { if (!cardHeight) cardHeight = 140
// vorsichtiger Fallback (angepasst an typische MiniCard)
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 rows = Math.max(1, Math.floor((availableHeight + rowGap) / (cardHeight + rowGap)))
const nextPerPage = Math.max(1, rows * cols) const nextPerPage = Math.max(1, rows * cols)
if (nextPerPage !== usersPerPage) { setUsersPerPage(prev => (prev !== nextPerPage ? nextPerPage : prev))
setUsersPerPage(nextPerPage) }, [isFetching, isSuccess, totalPages])
}
}, [isLoading, isSuccess, totalPages, usersPerPage])
// Recalc auf Resize/Content-Änderungen
useEffect(() => { useEffect(() => {
// Direkt versuchen
recalcUsersPerPage() recalcUsersPerPage()
const gridEl = gridRef.current const gridEl = gridRef.current
if (!gridEl) return if (!gridEl) return
const bodyEl = gridEl.parentElement as HTMLElement | null const bodyEl = gridEl.parentElement as HTMLElement | null
const cardEl = firstCardRef.current const cardEl = firstCardRef.current
@ -267,10 +329,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const onResize = () => recalcUsersPerPage() const onResize = () => recalcUsersPerPage()
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
// bei Show/Hide leichter Delay, bis Layout stabil ist
const id = window.setTimeout(recalcUsersPerPage, 60) const id = window.setTimeout(recalcUsersPerPage, 60)
return () => { return () => {
window.clearTimeout(id) window.clearTimeout(id)
ro.disconnect() ro.disconnect()
@ -288,11 +347,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
closeButtonColor={!isSuccess ? (isSuccess ? 'teal' : 'blue') : undefined} closeButtonColor={!isSuccess ? (isSuccess ? 'teal' : 'blue') : undefined}
closeButtonTitle={ closeButtonTitle={
!isSuccess !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 : undefined
} }
closeButtonLoading={!isSuccess && isInviting} 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:'} : 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}
</p> </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 && ( {selectedIds.length > 0 && (
<div ref={selectedWrapRef} className="col-span-full"> <div ref={selectedWrapRef} className="col-span-full mt-2">
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2"> <h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">Ausgewählte Spieler:</h3>
Ausgewählte Spieler:
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{selectedIds.map((id) => { {selectedIds.map((id) => {
const user = allUsers.find((u) => u.steamId === id) const user = knownUsers[id]
if (!user) return null if (!user) return null
return ( return (
<motion.div <motion.div key={user.steamId} layout initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
key={user.steamId}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<MiniCard <MiniCard
steamId={user.steamId} steamId={user.steamId}
title={user.name} title={user.name}
avatar={user.avatar} avatar={user.avatar}
location={user.location} location={user.location}
selected={true} selected
onSelect={handleSelect} onSelect={handleSelect}
draggable={false} draggable={false}
currentUserSteamId={steamId!} currentUserSteamId={steamId!}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader} teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
hideActions={true} hideActions
rank={user.premierRank} rank={user.premierRank}
/> />
</motion.div> </motion.div>
@ -345,43 +416,47 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
</div> </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 && ( {isSuccess && (
<div ref={successRef} className="mt-2 px-4 py-2 text-sm rounded-lg border" <div
style={{ background: sentCount ? '#dcfce7' : '#fee2e2', borderColor: sentCount ? '#bbf7d0' : '#fecaca', color: sentCount ? '#166534' : '#991b1b' }}> ref={successRef}
{directAdd className={`mt-2 px-4 py-2 text-sm rounded-lg border transition-opacity duration-150 ${
? (sentCount === 0 isSuccess ? 'opacity-100' : 'opacity-0'
? 'Niemand konnte hinzugefügt werden.' }`}
: sentCount === invitedIds.length style={{
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!` minHeight: 40, // ~ Höhe reservieren, vermeidet Jump
: `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt, andere fehlgeschlagen.`) background: sentCount ? '#dcfce7' : '#fee2e2',
: (sentCount === 0 borderColor: sentCount ? '#bbf7d0' : '#fecaca',
? 'Keine Einladungen versendet.' color: sentCount ? '#166534' : '#991b1b'
: sentCount === invitedIds.length }}
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!` >
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)} {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> </div>
)} )}
{/* Grid der MiniCards — Höhe/Anzahl wird dynamisch berechnet */} {/* Grid */}
<div ref={gridRef} className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4"> <div
{isLoading ? ( ref={gridRef}
<LoadingSpinner /> className="relative grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4 transition-[min-height] duration-150"
) : filteredUsers.length === 0 ? ( style={gridHoldHeight ? { minHeight: gridHoldHeight } : undefined}
>
{unselectedUsers.length === 0 ? (
<div className="col-span-full text-center text-gray-500 dark:text-neutral-400"> <div className="col-span-full text-center text-gray-500 dark:text-neutral-400">
{allUsers.length === 0 {allUsers.length === 0
? directAdd ? directAdd ? 'Keine Benutzer verfügbar :(' : 'Niemand zum Einladen verfügbar :('
? 'Keine Benutzer verfügbar :('
: 'Niemand zum Einladen verfügbar :('
: 'Keine Benutzer gefunden.'} : 'Keine Benutzer gefunden.'}
</div> </div>
) : ( ) : (
@ -394,7 +469,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
// 👉 erste sichtbare Karte als Mess-Referenz verwenden
ref={idx === 0 ? firstCardRef : undefined} ref={idx === 0 ? firstCardRef : undefined}
> >
<MiniCard <MiniCard
@ -414,7 +488,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
))} ))}
{isSuccess && invitedIds.map((id, idx) => { {isSuccess && invitedIds.map((id, idx) => {
const user = allUsers.find((u) => u.steamId === id) const user = knownUsers[id]
if (!user) return null if (!user) return null
return ( return (
<motion.div <motion.div
@ -444,23 +518,29 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
})} })}
</AnimatePresence> </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> </div>
{/* Pagination */} {/* Pagination */}
{!isLoading && !isSuccess && totalPages > 1 && ( <div className="mt-3 min-h-[44px] flex justify-center items-center">
<div className="mt-3 flex justify-center"> {!isFetching && !isSuccess && totalPages > 1 ? (
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
onPageChange={(p) => { onPageChange={(p) => {
const next = Math.max(1, Math.min(totalPages, p)) const next = Math.max(1, Math.min(totalPages, p))
setCurrentPage(next) setCurrentPage(next)
// nach Page-Wechsel neu kalkulieren (Layout kann „springen“)
setTimeout(() => recalcUsersPerPage(), 0) setTimeout(() => recalcUsersPerPage(), 0)
}} }}
/> />
</div> ) : null}
)} </div>
</Modal> </Modal>
) )
} }

View File

@ -469,19 +469,6 @@ export default function MapVotePanel({ match }: Props) {
[activeMapKeys] [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) // Helper: Durchschnitt (nur finite Werte)
function avg(values: number[]) { function avg(values: number[]) {
const valid = values.filter(v => Number.isFinite(v)) const valid = values.filter(v => Number.isFinite(v))
@ -905,20 +892,25 @@ export default function MapVotePanel({ match }: Props) {
// Winrate-Tab // Winrate-Tab
<div className="w-full max-w-xl justify-self-center"> <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="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 <Chart
type="radar" type="radar"
labels={activeMapLabels} labels={activeMapLabels} // z.B. ['Ancient','Anubis', ...]
height={"auto"} height={"auto"}
radarZeroToFirstTick // 0% auf ersten Tick
radarSubStepBand={4} // (0..20%) wird in 20..24% gestreckt (sichtbar über 0)
radarHideTicks // Ticks ausblenden (optional)
datasets={[ 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 }, 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 }, 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>
</div> </div>

View File

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

View File

@ -5,11 +5,13 @@ export async function GET(req: NextRequest) {
try { try {
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url)
const teamId = searchParams.get('teamId') const teamId = searchParams.get('teamId')
const onlyFree = (searchParams.get('onlyFree') ?? '').toLowerCase() === 'true'
if (!teamId) { if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 }) 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({ const team = await prisma.team.findUnique({
where: { id: teamId }, where: { id: teamId },
select: { activePlayers: true, inactivePlayers: true } select: { activePlayers: true, inactivePlayers: true }
@ -18,22 +20,41 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 }) 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 if (onlyFree) {
const pendingInvites = await prisma.teamInvite.findMany({ // 2a) Nur Spieler ohne Team => ALLE Teammitglieder (aus allen Teams) ausschließen
where: { teamId }, const allTeams = await prisma.team.findMany({
select: { steamId: true } select: { activePlayers: true, inactivePlayers: true }
}) })
const invited = new Set(pendingInvites.map(i => i.steamId)) 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 pendingInvites = await prisma.teamInvite.findMany({
const excludeIds = Array.from(new Set([...members, ...invited])) 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({ const availableUsers = await prisma.user.findMany({
where: { where: {
canBeInvited: true, // << nur einladbare canBeInvited: true,
steamId: { notIn: excludeIds } // << nicht schon Mitglied/geladen steamId: { notIn: excludeIds }
}, },
select: { select: {
steamId: true, steamId: true,