updated teams

This commit is contained in:
Linrador 2025-10-02 15:03:34 +02:00
parent 16ce72b1a6
commit 72a0ca015f

View File

@ -1,8 +1,8 @@
// /src/app/team/[teamId]/page.tsx
'use client'
import { useEffect, useState, KeyboardEvent, MouseEvent } from 'react'
import { useRouter } from 'next/navigation'
import {useEffect, useMemo, useState, KeyboardEvent, MouseEvent, ChangeEvent} from 'react'
import {useRouter} from 'next/navigation'
import LoadingSpinner from '../../components/LoadingSpinner'
import Card from '../../components/Card'
import PremierRankBadge from '../../components/PremierRankBadge'
@ -16,6 +16,16 @@ type Player = {
isAdmin?: boolean
}
type InvitedPlayer = {
invitationId: string
steamId: string
name: string
avatar: string
location?: string
premierRank?: number
isAdmin?: boolean
}
type TeamResponse = {
id: string
name: string
@ -23,27 +33,46 @@ type TeamResponse = {
leader?: Player | null
activePlayers: Player[]
inactivePlayers: Player[]
invitedPlayers: Array<{
invitationId: string
steamId: string
name: string
avatar: string
location?: string
premierRank?: number
isAdmin?: boolean
}>
invitedPlayers: InvitedPlayer[]
}
/* ---------- kleine Helfer ---------- */
function uniqBySteamId<T extends {steamId: string}>(list: T[]): T[] {
const seen = new Set<string>()
const out: T[] = []
for (const p of list) {
if (!seen.has(p.steamId)) {
seen.add(p.steamId)
out.push(p)
}
}
return out
}
function byName<T extends {name: string}>(a: T, b: T) {
return a.name.localeCompare(b.name, 'de', {sensitivity: 'base'})
}
function classNames(...xs: Array<string | false | null | undefined>) {
return xs.filter(Boolean).join(' ')
}
/* ---------- Hauptseite ---------- */
export default function TeamDetailPage({ params }: { params: { teamId: string } }) {
const router = useRouter()
const [team, setTeam] = useState<TeamResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let isMounted = true
// UI-State
const [q, setQ] = useState('') // Suche
const [seg, setSeg] = useState<'active'|'inactive'|'invited'>('active') // Segment
async function loadTeam() {
useEffect(() => {
let alive = true
;(async () => {
try {
setLoading(true)
setError(null)
@ -54,69 +83,174 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
}
if (!res.ok) throw new Error('Team konnte nicht geladen werden')
const data: TeamResponse = await res.json()
if (isMounted) setTeam(data)
if (alive) setTeam(data)
} catch (e: any) {
if (isMounted) setError(e?.message ?? 'Unbekannter Fehler')
if (alive) setError(e?.message ?? 'Unbekannter Fehler')
} finally {
if (isMounted) setLoading(false)
if (alive) setLoading(false)
}
}
loadTeam()
return () => { isMounted = false }
})()
return () => { alive = false }
}, [params.teamId, router])
if (loading) return <div className="p-4"><LoadingSpinner /></div>
if (error) return <div className="p-4 text-red-600">{error}</div>
if (!team) return null
/* ---------- Ableitungen ---------- */
// --- Mitglieder zusammenstellen (ohne invited) ---
const byId = new Map<string, Player>()
const pushUnique = (p?: Player | null) => { if (p && !byId.has(p.steamId)) byId.set(p.steamId, p) }
const members = useMemo(() => {
if (!team) return []
const all = [
...(team.leader ? [team.leader] : []),
...team.activePlayers,
...team.inactivePlayers,
]
// uniq + Leader nach vorne
const uniq = uniqBySteamId(all).sort(byName)
if (team.leader) {
const i = uniq.findIndex(p => p.steamId === team.leader!.steamId)
if (i > 0) {
const [lead] = uniq.splice(i, 1)
uniq.unshift(lead)
}
}
return uniq
}, [team])
pushUnique(team.leader ?? undefined)
team.activePlayers.forEach(pushUnique)
team.inactivePlayers.forEach(pushUnique)
const counts = useMemo(() => ({
active: team?.activePlayers.length ?? 0,
inactive: team?.inactivePlayers.length ?? 0,
invited: team?.invitedPlayers.length ?? 0,
total: members.length,
}), [team, members])
const members = Array.from(byId.values())
const filteredList = useMemo(() => {
if (!team) return []
const isLeader = (p: Player) => team.leader?.steamId === p.steamId
const isActive = (p: Player) => team.activePlayers.some(a => a.steamId === p.steamId)
const norm = (s: string) => s.toLowerCase().normalize('NFKD')
const search = norm(q)
const matchQ = (p: {name: string; location?: string; steamId: string}) => {
if (!search) return true
return (
norm(p.name).includes(search) ||
norm(p.location ?? '').includes(search) ||
norm(p.steamId).includes(search)
)
}
if (seg === 'active') {
return uniqBySteamId(team.activePlayers).filter(matchQ).sort(byName)
}
if (seg === 'inactive') {
return uniqBySteamId(team.inactivePlayers).filter(matchQ).sort(byName)
}
// invited
return uniqBySteamId(team.invitedPlayers).filter(matchQ).sort(byName)
}, [team, q, seg])
/* ---------- Interaktionen ---------- */
// Profil öffnen
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
const onCardClick = (steamId: string) => (e: MouseEvent) => { e.preventDefault(); goToProfile(steamId) }
const onCardKey = (steamId: string) => (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToProfile(steamId) }
}
/* ---------- Render ---------- */
if (loading) {
return (
<Card maxWidth="auto">
<div className="py-10 flex justify-center"><LoadingSpinner /></div>
</Card>
)
}
if (error) {
return (
<Card maxWidth="auto">
<div className="p-4 text-red-600">{error}</div>
</Card>
)
}
if (!team) return null
return (
<Card maxWidth="auto">
{/* Header */}
<div className="mb-5 sm:mb-8 flex items-center gap-4">
<div className="mb-5 sm:mb-6 flex items-center gap-4">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt={team.name}
className="w-14 h-14 sm:w-16 sm:h-16 rounded-full object-cover border border-gray-200 dark:border-neutral-700"
/>
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-neutral-100">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-neutral-100 truncate">
{team.name}
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-400">
{members.length} Mitglied{members.length === 1 ? '' : 'er'}
{counts.total} Mitglied{counts.total === 1 ? '' : 'er'}
{team.leader?.name ? (
<span className="ml-2 text-gray-400 dark:text-neutral-500"> Leader: {team.leader.name}</span>
) : null}
</p>
</div>
</div>
{/* Mitglieder-Grid */}
{members.length === 0 ? (
<div className="text-sm text-gray-500 dark:text-neutral-400">
Dieses Team hat noch keine Mitglieder.
{/* Toolbar */}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Suche */}
<div className="relative w-full sm:max-w-xs">
<input
type="search"
value={q}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
placeholder="Spieler suchen…"
className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm
placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500
dark:border-neutral-700 dark:bg-neutral-800 dark:text-white"
aria-label="Spieler suchen"
/>
<svg aria-hidden viewBox="0 0 24 24" className="pointer-events-none absolute right-3 top-2.5 h-5 w-5 text-gray-400">
<path fill="currentColor" d="M21 20l-5.8-5.8A7 7 0 1 0 4 11a7 7 0 0 0 11.2 5.2L21 20zM6 11a5 5 0 1 1 10.001.001A5 5 0 0 1 6 11z"/>
</svg>
</div>
{/* Segment */}
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
{([
{key: 'active', label: `Aktiv (${counts.active})`},
{key: 'inactive', label: `Inaktiv (${counts.inactive})`},
{key: 'invited', label: `Eingeladen (${counts.invited})`},
] as const).map(opt => (
<button
key={opt.key}
onClick={() => setSeg(opt.key)}
className={classNames(
'px-3 py-1.5 text-sm transition',
seg === opt.key
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Grid */}
{filteredList.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 p-8 text-center text-sm
text-gray-500 dark:border-neutral-700 dark:text-neutral-400">
{q
? <>Keine Treffer für <span className="font-medium">{q}</span>.</>
: seg === 'active'
? 'Keine aktiven Mitglieder.'
: seg === 'inactive'
? 'Keine inaktiven Mitglieder.'
: 'Keine eingeladenen Spieler.'}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{members.map((m) => (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredList.map((m) => (
<div
key={m.steamId}
role="button"
@ -129,8 +263,7 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
border-gray-200 dark:border-neutral-700
bg-white dark:bg-neutral-800 shadow-sm
transition cursor-pointer focus:outline-none
hover:shadow-md hover:scale-105
hover:bg-neutral-200 hover:dark:bg-neutral-700
hover:shadow-md hover:bg-gray-50 dark:hover:bg-neutral-700
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
focus:ring-offset-white dark:focus:ring-offset-neutral-800
"
@ -142,36 +275,45 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
onClick={(e) => { e.stopPropagation(); goToProfile(m.steamId) }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 dark:text-neutral-100 truncate">
{m.name}
</span>
{isLeader(m) && (
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
{/* Leader-Badge (falls vorhanden) */}
{team.leader?.steamId === m.steamId && (
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded
bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Leader
</span>
)}
<span
className={`inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded ${
isActive(m)
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
}`}
>
{isActive(m) ? 'Aktiv' : 'Inaktiv'}
</span>
{/* Badge IMMER anzeigen, auch bei Rank 0 */}
<PremierRankBadge rank={m.premierRank ?? 0} />
{/* Aktiv/Inaktiv sichtbar, aber dezenter */}
{seg !== 'invited' && (
<span
className={classNames(
'inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded',
(team.activePlayers.some(a => a.steamId === m.steamId))
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
)}
>
{team.activePlayers.some(a => a.steamId === m.steamId) ? 'Aktiv' : 'Inaktiv'}
</span>
)}
{/* PremierRankBadge IMMER rendern (0 => „unranked“ Style in deiner Badge) */}
<PremierRankBadge rank={(m as Player).premierRank ?? 0} />
</div>
{m.location && (
{(m as Player).location && (
<div className="text-xs text-gray-500 dark:text-neutral-400">
{m.location}
{(m as Player).location}
</div>
)}
</div>
{/* Chevron rechts (rein visuell) */}
{/* Chevron */}
<svg aria-hidden viewBox="0 0 24 24" className="w-4 h-4 text-gray-400 group-hover:text-gray-500 transition">
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
</svg>