279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import TeamCard from './TeamCard'
|
||
import type { Team, Player } from '../types/team'
|
||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||
import { TEAM_EVENTS, INVITE_EVENTS } from '../lib/sseEvents'
|
||
|
||
type Props = {
|
||
initialTeams: Team[]
|
||
initialInvitationMap: Record<string, string>
|
||
}
|
||
|
||
/* helpers */
|
||
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
|
||
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
||
if (a.length !== b.length) return false
|
||
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
|
||
return true
|
||
}
|
||
const eqTeam = (a: Team, b: Team) => {
|
||
if (a.id !== b.id) return false
|
||
if ((a.name ?? '') !== (b.name ?? '')) return false
|
||
if ((a.logo ?? '') !== (b.logo ?? '')) return false
|
||
if ((a.leader as any) !== (b.leader as any)) return false
|
||
return (
|
||
eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
|
||
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
|
||
)
|
||
}
|
||
const eqTeamList = (a: Team[], b: Team[]) => {
|
||
if (a.length !== b.length) return false
|
||
const mapA = new Map(a.map(t => [t.id, t]))
|
||
for (const t of b) {
|
||
const x = mapA.get(t.id)
|
||
if (!x || !eqTeam(x, t)) return false
|
||
}
|
||
return true
|
||
}
|
||
function parseTeamsResponse(raw: any): Team[] {
|
||
if (Array.isArray(raw)) return raw as Team[]
|
||
if (raw && Array.isArray(raw.teams)) return raw.teams as Team[]
|
||
return []
|
||
}
|
||
|
||
export default function NoTeamView({ initialTeams, initialInvitationMap }: Props) {
|
||
const { data: session } = useSession()
|
||
const currentSteamId = session?.user?.steamId || ''
|
||
const { lastEvent } = useSSEStore()
|
||
|
||
const [teams, setTeams] = useState<Team[]>(initialTeams)
|
||
const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>(initialInvitationMap)
|
||
const [query, setQuery] = useState('')
|
||
const [sortBy, setSortBy] = useState<'name-asc' | 'members-desc'>('name-asc')
|
||
|
||
const fetchTeamsAndInvitations = async () => {
|
||
try {
|
||
const [teamRes, invitesRes] = await Promise.all([
|
||
fetch('/api/teams', { cache: 'no-store' }),
|
||
fetch('/api/user/invitations', { cache: 'no-store' }),
|
||
])
|
||
if (!teamRes.ok || !invitesRes.ok) return
|
||
const rawTeams = await teamRes.json()
|
||
const rawInv = await invitesRes.json()
|
||
const nextTeams: Team[] = parseTeamsResponse(rawTeams)
|
||
|
||
const mapping: Record<string, string> = {}
|
||
for (const inv of rawInv?.invitations || []) {
|
||
if (inv.type === 'team-join-request') mapping[inv.teamId] = inv.id
|
||
}
|
||
|
||
setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams))
|
||
setTeamToInvitationId(prev => {
|
||
const same =
|
||
Object.keys(prev).length === Object.keys(mapping).length &&
|
||
Object.keys(prev).every(k => prev[k] === mapping[k])
|
||
return same ? prev : mapping
|
||
})
|
||
} catch (e) {
|
||
console.error('[NoTeamView] fetch error:', e)
|
||
}
|
||
}
|
||
|
||
useEffect(() => { fetchTeamsAndInvitations() }, [])
|
||
useEffect(() => {
|
||
if (!lastEvent) return
|
||
const { type, payload } = lastEvent
|
||
if (TEAM_EVENTS.has(type)) {
|
||
if (!payload?.teamId || teams.some(t => t.id === payload.teamId)) fetchTeamsAndInvitations()
|
||
return
|
||
}
|
||
if (INVITE_EVENTS.has(type)) fetchTeamsAndInvitations()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [lastEvent, teams])
|
||
|
||
const visibleTeams = useMemo(() => {
|
||
const q = query.trim().toLowerCase()
|
||
let list = q ? teams.filter(t => (t.name ?? '').toLowerCase().includes(q)) : teams.slice()
|
||
if (sortBy === 'name-asc') {
|
||
list.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', 'de', { sensitivity: 'base' }))
|
||
} else {
|
||
const count = (t: Team) => (t.activePlayers?.length ?? 0) + (t.inactivePlayers?.length ?? 0)
|
||
list.sort((a, b) => count(b) - count(a))
|
||
}
|
||
return list
|
||
}, [teams, query, sortBy])
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header-Panel */}
|
||
<section className="team-find-panel group rounded-xl border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-4 sm:p-5 shadow-sm relative overflow-hidden">
|
||
{/* Linker Rand: Orbit um linken Panelrand, rotiert vorwärts */}
|
||
<div className="edge-orbit edge-orbit-left" aria-hidden>
|
||
<div className="blob blob-left" />
|
||
</div>
|
||
{/* Rechter Rand: Orbit um rechten Panelrand, rotiert rückwärts */}
|
||
<div className="edge-orbit edge-orbit-right" aria-hidden>
|
||
<div className="blob blob-right" />
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<div className="flex items-start gap-3">
|
||
<div className="shrink-0 inline-flex items-center justify-center rounded-lg bg-blue-50 dark:bg-blue-900/20 w-10 h-10">
|
||
<svg viewBox="0 0 24 24" className="w-5 h-5 text-blue-600 dark:text-blue-300">
|
||
<path fill="currentColor" d="M12 12a5 5 0 1 0-5-5a5 5 0 0 0 5 5Zm0 2c-4.418 0-8 2.239-8 5v1h16v-1c0-2.761-3.582-5-8-5Z"/>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Finde dein Team</h2>
|
||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||
Stöbere durch die Teams oder starte selbst eins – du kannst später jederzeit wechseln.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={sortBy}
|
||
onChange={e => setSortBy(e.target.value as any)}
|
||
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||
>
|
||
<option value="name-asc">Sortieren: Name (A–Z)</option>
|
||
<option value="members-desc">Sortieren: Mitglieder (viele → wenige)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Suche */}
|
||
<div className="mt-4">
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
value={query}
|
||
onChange={e => setQuery(e.target.value)}
|
||
placeholder="Teams suchen …"
|
||
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100 text-sm"
|
||
/>
|
||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-neutral-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="11" cy="11" r="7"></circle>
|
||
<path d="m21 21-4.3-4.3"></path>
|
||
</svg>
|
||
</div>
|
||
|
||
<div className="mt-2 text-xs text-gray-500 dark:text-neutral-400">
|
||
{visibleTeams.length} Team{visibleTeams.length === 1 ? '' : 's'} gefunden
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Teamliste */}
|
||
{visibleTeams.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed border-gray-300 dark:border-neutral-600 p-8 text-center text-sm text-gray-600 dark:text-neutral-400">
|
||
Keine Treffer. Passe die Suche oder Sortierung an — oder erstelle ein eigenes Team.
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||
{visibleTeams.map(team => (
|
||
<TeamCard
|
||
key={team.id}
|
||
team={team}
|
||
currentUserSteamId={currentSteamId}
|
||
invitationId={teamToInvitationId[team.id]}
|
||
onUpdateInvitation={(teamId, newValue) => {
|
||
setTeamToInvitationId(prev => {
|
||
const updated = { ...prev }
|
||
if (!newValue) delete updated[teamId]
|
||
else if (newValue === 'pending') updated[teamId] = updated[teamId] ?? 'pending'
|
||
else updated[teamId] = newValue
|
||
return updated
|
||
})
|
||
}}
|
||
adminMode={false}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Globale Styles nur für dieses Component */}
|
||
<style jsx global>{`
|
||
.team-find-panel {
|
||
--blobSize: 10rem; /* Größe der Kegel */
|
||
--blur: 2rem; /* Weichzeichner */
|
||
--rEdge: 36%; /* Abstand der Kreisbahn vom Rand nach innen */
|
||
--speedL: 22s; /* Geschwindigkeit links */
|
||
--speedR: 28s; /* Geschwindigkeit rechts */
|
||
--opacity: 0.8;
|
||
}
|
||
|
||
/* Orbit-Container genau am linken/rechten Rand, vertikal zentriert */
|
||
.team-find-panel .edge-orbit {
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 0;
|
||
height: 0;
|
||
pointer-events: none;
|
||
transform-origin: 0 0;
|
||
will-change: transform;
|
||
z-index: 0;
|
||
}
|
||
.team-find-panel .edge-orbit-left {
|
||
left: 0;
|
||
/* rotiert vorwärts (im Uhrzeigersinn) */
|
||
animation: orbit var(--speedL) linear infinite;
|
||
}
|
||
.team-find-panel .edge-orbit-right {
|
||
left: 100%;
|
||
/* an den rechten Rand verschieben, damit der Transform-Ursprung dort liegt */
|
||
transform: translateX(-100%);
|
||
/* rotiert rückwärts (gegen Uhrzeigersinn) */
|
||
animation: orbit var(--speedR) linear infinite reverse;
|
||
}
|
||
|
||
/* Die Blobs liegen auf einer Kreisbahn, die vom Rand nach innen zeigt */
|
||
.team-find-panel .blob {
|
||
position: absolute;
|
||
left: 0; top: 0;
|
||
width: var(--blobSize);
|
||
height: var(--blobSize);
|
||
border-radius: 9999px;
|
||
filter: blur(var(--blur));
|
||
transform: translate(-50%, -50%); /* Orbit-Zentrum als Ausgangspunkt */
|
||
will-change: transform;
|
||
opacity: var(--opacity);
|
||
mix-blend-mode: normal;
|
||
}
|
||
|
||
/* Linker Blob: vom Rand nach rechts in den Inhalt (positive X) */
|
||
.team-find-panel .edge-orbit-left .blob-left {
|
||
background: rgba(59, 130, 246, 0.14); /* blue-500/14 */
|
||
transform: translate(-50%, -50%) translateX(var(--rEdge));
|
||
}
|
||
|
||
/* Rechter Blob: vom Rand nach links in den Inhalt (negative X) */
|
||
.team-find-panel .edge-orbit-right .blob-right {
|
||
background: rgba(99, 102, 241, 0.14); /* indigo-500/14 */
|
||
transform: translate(-50%, -50%) translateX(calc(-1 * var(--rEdge)));
|
||
}
|
||
|
||
/* Kreisbewegung */
|
||
@keyframes orbit {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* Dark Mode: etwas mehr Punch */
|
||
@media (prefers-color-scheme: dark) {
|
||
.team-find-panel { --blur: 2.25rem; --opacity: 0.9; }
|
||
}
|
||
|
||
/* Auf größeren Screens die Bahn etwas weiter hineinziehen */
|
||
@media (min-width: 768px) {
|
||
.team-find-panel { --rEdge: 42%; }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)
|
||
}
|