updated teams
This commit is contained in:
parent
16ce72b1a6
commit
72a0ca015f
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user