ironie-nextjs/src/app/components/NoTeamView.tsx
2025-09-06 15:19:09 +02:00

279 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 (AZ)</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>
)
}