updated teams
This commit is contained in:
parent
16ce72b1a6
commit
72a0ca015f
@ -1,7 +1,7 @@
|
|||||||
// /src/app/team/[teamId]/page.tsx
|
// /src/app/team/[teamId]/page.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, KeyboardEvent, MouseEvent } from 'react'
|
import {useEffect, useMemo, useState, KeyboardEvent, MouseEvent, ChangeEvent} from 'react'
|
||||||
import {useRouter} from 'next/navigation'
|
import {useRouter} from 'next/navigation'
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||||
import Card from '../../components/Card'
|
import Card from '../../components/Card'
|
||||||
@ -16,6 +16,16 @@ type Player = {
|
|||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InvitedPlayer = {
|
||||||
|
invitationId: string
|
||||||
|
steamId: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
location?: string
|
||||||
|
premierRank?: number
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type TeamResponse = {
|
type TeamResponse = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -23,27 +33,46 @@ type TeamResponse = {
|
|||||||
leader?: Player | null
|
leader?: Player | null
|
||||||
activePlayers: Player[]
|
activePlayers: Player[]
|
||||||
inactivePlayers: Player[]
|
inactivePlayers: Player[]
|
||||||
invitedPlayers: Array<{
|
invitedPlayers: InvitedPlayer[]
|
||||||
invitationId: string
|
|
||||||
steamId: string
|
|
||||||
name: string
|
|
||||||
avatar: string
|
|
||||||
location?: string
|
|
||||||
premierRank?: number
|
|
||||||
isAdmin?: boolean
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 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 } }) {
|
export default function TeamDetailPage({ params }: { params: { teamId: string } }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [team, setTeam] = useState<TeamResponse | null>(null)
|
const [team, setTeam] = useState<TeamResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
// UI-State
|
||||||
let isMounted = true
|
const [q, setQ] = useState('') // Suche
|
||||||
|
const [seg, setSeg] = useState<'active'|'inactive'|'invited'>('active') // Segment
|
||||||
|
|
||||||
async function loadTeam() {
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
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')
|
if (!res.ok) throw new Error('Team konnte nicht geladen werden')
|
||||||
const data: TeamResponse = await res.json()
|
const data: TeamResponse = await res.json()
|
||||||
if (isMounted) setTeam(data)
|
if (alive) setTeam(data)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (isMounted) setError(e?.message ?? 'Unbekannter Fehler')
|
if (alive) setError(e?.message ?? 'Unbekannter Fehler')
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) setLoading(false)
|
if (alive) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
})()
|
||||||
|
return () => { alive = false }
|
||||||
loadTeam()
|
|
||||||
return () => { isMounted = false }
|
|
||||||
}, [params.teamId, router])
|
}, [params.teamId, router])
|
||||||
|
|
||||||
if (loading) return <div className="p-4"><LoadingSpinner /></div>
|
/* ---------- Ableitungen ---------- */
|
||||||
if (error) return <div className="p-4 text-red-600">{error}</div>
|
|
||||||
if (!team) return null
|
|
||||||
|
|
||||||
// --- Mitglieder zusammenstellen (ohne invited) ---
|
const members = useMemo(() => {
|
||||||
const byId = new Map<string, Player>()
|
if (!team) return []
|
||||||
const pushUnique = (p?: Player | null) => { if (p && !byId.has(p.steamId)) byId.set(p.steamId, p) }
|
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)
|
const counts = useMemo(() => ({
|
||||||
team.activePlayers.forEach(pushUnique)
|
active: team?.activePlayers.length ?? 0,
|
||||||
team.inactivePlayers.forEach(pushUnique)
|
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 norm = (s: string) => s.toLowerCase().normalize('NFKD')
|
||||||
const isActive = (p: Player) => team.activePlayers.some(a => a.steamId === p.steamId)
|
|
||||||
|
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 goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
|
||||||
const onCardClick = (steamId: string) => (e: MouseEvent) => { e.preventDefault(); goToProfile(steamId) }
|
const onCardClick = (steamId: string) => (e: MouseEvent) => { e.preventDefault(); goToProfile(steamId) }
|
||||||
const onCardKey = (steamId: string) => (e: KeyboardEvent<HTMLDivElement>) => {
|
const onCardKey = (steamId: string) => (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToProfile(steamId) }
|
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 (
|
return (
|
||||||
<Card maxWidth="auto">
|
<Card maxWidth="auto">
|
||||||
{/* Header */}
|
{/* 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
|
<img
|
||||||
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
alt={team.name}
|
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"
|
className="w-14 h-14 sm:w-16 sm:h-16 rounded-full object-cover border border-gray-200 dark:border-neutral-700"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-neutral-100">
|
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-neutral-100 truncate">
|
||||||
{team.name}
|
{team.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-neutral-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mitglieder-Grid */}
|
{/* Toolbar */}
|
||||||
{members.length === 0 ? (
|
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="text-sm text-gray-500 dark:text-neutral-400">
|
{/* Suche */}
|
||||||
Dieses Team hat noch keine Mitglieder.
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{members.map((m) => (
|
{filteredList.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.steamId}
|
key={m.steamId}
|
||||||
role="button"
|
role="button"
|
||||||
@ -129,8 +263,7 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
|||||||
border-gray-200 dark:border-neutral-700
|
border-gray-200 dark:border-neutral-700
|
||||||
bg-white dark:bg-neutral-800 shadow-sm
|
bg-white dark:bg-neutral-800 shadow-sm
|
||||||
transition cursor-pointer focus:outline-none
|
transition cursor-pointer focus:outline-none
|
||||||
hover:shadow-md hover:scale-105
|
hover:shadow-md hover:bg-gray-50 dark:hover:bg-neutral-700
|
||||||
hover:bg-neutral-200 hover:dark:bg-neutral-700
|
|
||||||
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||||
focus:ring-offset-white dark:focus:ring-offset-neutral-800
|
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) }}
|
onClick={(e) => { e.stopPropagation(); goToProfile(m.steamId) }}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<span className="font-medium text-gray-900 dark:text-neutral-100 truncate">
|
||||||
{m.name}
|
{m.name}
|
||||||
</span>
|
</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
|
Leader
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Aktiv/Inaktiv sichtbar, aber dezenter */}
|
||||||
|
{seg !== 'invited' && (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded ${
|
className={classNames(
|
||||||
isActive(m)
|
'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-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'
|
: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{isActive(m) ? 'Aktiv' : 'Inaktiv'}
|
{team.activePlayers.some(a => a.steamId === m.steamId) ? 'Aktiv' : 'Inaktiv'}
|
||||||
</span>
|
</span>
|
||||||
{/* Badge IMMER anzeigen, auch bei Rank 0 */}
|
)}
|
||||||
<PremierRankBadge rank={m.premierRank ?? 0} />
|
|
||||||
|
{/* PremierRankBadge IMMER rendern (0 => „unranked“ Style in deiner Badge) */}
|
||||||
|
<PremierRankBadge rank={(m as Player).premierRank ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m.location && (
|
{(m as Player).location && (
|
||||||
<div className="text-xs text-gray-500 dark:text-neutral-400">
|
<div className="text-xs text-gray-500 dark:text-neutral-400">
|
||||||
{m.location}
|
{(m as Player).location}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
<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" />
|
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user