updated
This commit is contained in:
parent
8ae14cc2b9
commit
aff1f090c1
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
startTransition(() => {
|
||||||
setAllUsers(data.users || [])
|
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,19 +416,21 @@ 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}
|
||||||
|
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
|
{directAdd
|
||||||
? (sentCount === 0
|
? (sentCount === 0
|
||||||
? 'Niemand konnte hinzugefügt werden.'
|
? 'Niemand konnte hinzugefügt werden.'
|
||||||
@ -369,19 +442,21 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
: sentCount === invitedIds.length
|
: sentCount === invitedIds.length
|
||||||
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`
|
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`
|
||||||
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)}
|
: `${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)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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[] = []
|
||||||
|
|
||||||
|
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 ?? [])
|
||||||
|
])
|
||||||
|
|
||||||
// 2) Pending-Invites dieses Teams
|
|
||||||
const pendingInvites = await prisma.teamInvite.findMany({
|
const pendingInvites = await prisma.teamInvite.findMany({
|
||||||
where: { teamId },
|
where: { teamId },
|
||||||
select: { steamId: true }
|
select: { steamId: true }
|
||||||
})
|
})
|
||||||
const invited = new Set(pendingInvites.map(i => i.steamId))
|
const invited = new Set<string>(pendingInvites.map(i => i.steamId))
|
||||||
|
|
||||||
// 3) Kandidaten: nur canBeInvited === true, nicht Mitglied, nicht eingeladen
|
excludeIds = Array.from(new Set([...members, ...invited]))
|
||||||
const 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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user