updated
This commit is contained in:
parent
e93c00154a
commit
33b91ceb4b
@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ['ironieopen.local', '*.ironieopen.local'],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
@ -1,80 +1,56 @@
|
||||
// src/app/api/teams/route.ts
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import type { Player } from '@/app/types/team'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import type { Player } from '@/app/types/team'
|
||||
|
||||
export const dynamic = 'force-dynamic' // optional: Caching aus
|
||||
// export const revalidate = 0
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
/* 1) Alle Teams mit reinen Steam-ID-Arrays holen ---------------- */
|
||||
const teams = await prisma.team.findMany({
|
||||
select: {
|
||||
id : true,
|
||||
name : true,
|
||||
logo : true,
|
||||
leaderId : true,
|
||||
createdAt : true,
|
||||
activePlayers : true, // string[]
|
||||
inactivePlayers: true, // string[]
|
||||
},
|
||||
select: { id: true, name: true, logo: true, leaderId: true, createdAt: true,
|
||||
activePlayers: true, inactivePlayers: true },
|
||||
})
|
||||
|
||||
/* 2) Einmalig ALLE vorkommenden Steam-IDs sammeln --------------- */
|
||||
const uniqueIds = new Set<string>()
|
||||
teams.forEach(t => {
|
||||
t.activePlayers.forEach(id => uniqueIds.add(id))
|
||||
t.activePlayers.forEach(id => uniqueIds.add(id))
|
||||
t.inactivePlayers.forEach(id => uniqueIds.add(id))
|
||||
})
|
||||
|
||||
/* 3) Die zugehörigen User-Objekte laden (ein Query) ------------- */
|
||||
const users = await prisma.user.findMany({
|
||||
where : { steamId: { in: [...uniqueIds] } },
|
||||
select: {
|
||||
steamId : true,
|
||||
name : true,
|
||||
avatar : true,
|
||||
location : true,
|
||||
premierRank: true,
|
||||
},
|
||||
where: { steamId: { in: [...uniqueIds] } },
|
||||
select: { steamId: true, name: true, avatar: true, location: true, premierRank: true },
|
||||
})
|
||||
|
||||
/* 4) steamId → Player: Null-Werte abfangen ------------------------ */
|
||||
const byId: Record<string, Player> = {}
|
||||
|
||||
/* Fallbacks definieren */
|
||||
const DEFAULT_AVATAR = '/assets/img/avatars/default.png' // oder was du nutzt
|
||||
const DEFAULT_AVATAR = '/assets/img/avatars/default.png'
|
||||
const UNKNOWN_NAME = 'Unbekannt'
|
||||
|
||||
users.forEach(u => {
|
||||
byId[u.steamId] = {
|
||||
steamId : u.steamId,
|
||||
name : u.name ?? UNKNOWN_NAME,
|
||||
avatar : u.avatar ?? DEFAULT_AVATAR,
|
||||
location : u.location ?? '',
|
||||
steamId: u.steamId,
|
||||
name: u.name ?? UNKNOWN_NAME,
|
||||
avatar: u.avatar ?? DEFAULT_AVATAR,
|
||||
location: u.location ?? '',
|
||||
premierRank: u.premierRank ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
/* 5) Teams zurückgeben – jetzt mit aufgelösten Spielern --------- */
|
||||
const result = teams.map(t => ({
|
||||
id : t.id,
|
||||
name : t.name,
|
||||
logo : t.logo,
|
||||
leader: t.leaderId, // Steam-ID des Leaders
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
logo: t.logo,
|
||||
leader: t.leaderId,
|
||||
createdAt: t.createdAt,
|
||||
activePlayers : t.activePlayers
|
||||
.map(id => byId[id])
|
||||
.filter(Boolean) as Player[],
|
||||
inactivePlayers: t.inactivePlayers
|
||||
.map(id => byId[id])
|
||||
.filter(Boolean) as Player[],
|
||||
activePlayers: t.activePlayers .map(id => byId[id]).filter(Boolean) as Player[],
|
||||
inactivePlayers:t.inactivePlayers.map(id => byId[id]).filter(Boolean) as Player[],
|
||||
}))
|
||||
|
||||
return NextResponse.json({ teams: result })
|
||||
// HIER: direkt das Array zurückgeben
|
||||
return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } })
|
||||
} catch (err) {
|
||||
console.error('GET /api/teams failed:', err)
|
||||
return NextResponse.json(
|
||||
{ message: 'Interner Serverfehler' },
|
||||
{ status: 500 },
|
||||
)
|
||||
return NextResponse.json({ message: 'Interner Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /app/components/LoadingSpinner.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
export default function LoadingSpinner() {
|
||||
|
||||
@ -444,45 +444,43 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
||||
{/* Team A */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xl font-semibold">{match.teamA?.name ?? 'Team A'}</h2>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{match.teamA?.logo && (
|
||||
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
|
||||
<Image
|
||||
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
|
||||
alt="Teamlogo"
|
||||
fill
|
||||
sizes="96px"
|
||||
quality={75}
|
||||
priority={false}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{match.teamB?.name ?? 'Team B'}
|
||||
</h2>
|
||||
|
||||
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{canEditA && !mapvoteStarted ? (
|
||||
{canEditA && !mapvoteStarted && (
|
||||
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
||||
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
|
||||
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-gray-300">
|
||||
Du kannst die Aufstellung noch bis{' '}
|
||||
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className='text-gray-300'>
|
||||
{canEditA && !mapvoteStarted ? (
|
||||
<>
|
||||
Du kannst die Aufstellung noch bis{' '}
|
||||
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||
</>
|
||||
) : (
|
||||
<>Die Aufstellung kann nicht mehr bearbeitet werden.</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => (canEditA && !mapvoteStarted) && setEditSide('A')}
|
||||
disabled={!(canEditA && !mapvoteStarted)}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg ${
|
||||
canEditA && !mapvoteStarted
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Spieler bearbeiten
|
||||
</Button>
|
||||
</Alert>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setEditSide('A')}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Spieler bearbeiten
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderTable(teamAPlayers)}
|
||||
@ -498,7 +496,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
||||
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
|
||||
alt="Teamlogo"
|
||||
fill
|
||||
sizes="64px"
|
||||
sizes="96px"
|
||||
quality={75}
|
||||
priority={false}
|
||||
/>
|
||||
@ -507,43 +505,27 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
||||
{match.teamB?.name ?? 'Team B'}
|
||||
</h2>
|
||||
|
||||
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{canEditB && !mapvoteStarted ? (
|
||||
{canEditB && !mapvoteStarted && (
|
||||
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
||||
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
|
||||
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-gray-300">
|
||||
Du kannst die Aufstellung noch bis{' '}
|
||||
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className='text-gray-300'>
|
||||
{canEditB && !mapvoteStarted ? (
|
||||
<>
|
||||
Du kannst die Aufstellung noch bis{' '}
|
||||
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||
</>
|
||||
) : (
|
||||
<>Die Aufstellung kann nicht mehr bearbeitet werden.</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => (canEditB && !mapvoteStarted) && setEditSide('B')}
|
||||
disabled={!(canEditB && !mapvoteStarted)}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg ${
|
||||
canEditB && !mapvoteStarted
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Spieler bearbeiten
|
||||
</Button>
|
||||
</Alert>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setEditSide('B')}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Spieler bearbeiten
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderTable(teamBPlayers)}
|
||||
|
||||
@ -3,18 +3,17 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import TeamCard from './TeamCard'
|
||||
import { Team, Player } from '../types/team'
|
||||
import type { Team, Player } from '../types/team'
|
||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||
import { TEAM_EVENTS, INVITE_EVENTS } from '../lib/sseEvents'
|
||||
import Button from './Button'
|
||||
|
||||
type Props = {
|
||||
initialTeams: Team[]
|
||||
initialInvitationMap: Record<string, string>
|
||||
}
|
||||
|
||||
/** flache Vergleiche */
|
||||
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a,b)=>a.steamId.localeCompare(b.steamId))
|
||||
/* 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
|
||||
@ -22,11 +21,13 @@ const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
||||
}
|
||||
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 !== b.leader) return false
|
||||
return eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
|
||||
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
|
||||
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
|
||||
@ -37,21 +38,22 @@ const eqTeamList = (a: Team[], b: Team[]) => {
|
||||
}
|
||||
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 { lastEvent } = useSSEStore()
|
||||
|
||||
// Daten
|
||||
const [teams, setTeams] = useState<Team[]>(initialTeams)
|
||||
const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>(initialInvitationMap)
|
||||
|
||||
// UI-States
|
||||
const [query, setQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'name-asc'|'members-desc'>('name-asc')
|
||||
const [sortBy, setSortBy] = useState<'name-asc' | 'members-desc'>('name-asc')
|
||||
|
||||
// SSE: nur bei relevanten Events nachladen
|
||||
const fetchTeamsAndInvitations = async () => {
|
||||
try {
|
||||
const [teamRes, invitesRes] = await Promise.all([
|
||||
@ -59,16 +61,15 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
||||
fetch('/api/user/invitations', { cache: 'no-store' }),
|
||||
])
|
||||
if (!teamRes.ok || !invitesRes.ok) return
|
||||
const teamData = await teamRes.json()
|
||||
const inviteData = await invitesRes.json()
|
||||
const rawTeams = await teamRes.json()
|
||||
const rawInv = await invitesRes.json()
|
||||
const nextTeams: Team[] = parseTeamsResponse(rawTeams)
|
||||
|
||||
const nextTeams: Team[] = teamData.teams || []
|
||||
const mapping: Record<string, string> = {}
|
||||
for (const inv of inviteData?.invitations || []) {
|
||||
if (inv.type === 'team-join-request') {
|
||||
mapping[inv.teamId] = inv.id
|
||||
}
|
||||
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 =
|
||||
@ -81,63 +82,52 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
if (!payload?.teamId || teams.some(t => t.id === payload.teamId)) fetchTeamsAndInvitations()
|
||||
return
|
||||
}
|
||||
if (INVITE_EVENTS.has(type)) {
|
||||
fetchTeamsAndInvitations()
|
||||
}
|
||||
if (INVITE_EVENTS.has(type)) fetchTeamsAndInvitations()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lastEvent, teams])
|
||||
|
||||
// Ableiten: gefiltert & sortiert
|
||||
const visibleTeams = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
let list = q
|
||||
? teams.filter(t => (t.name ?? '').toLowerCase().includes(q))
|
||||
: teams.slice()
|
||||
|
||||
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' }))
|
||||
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))
|
||||
list.sort((a, b) => count(b) - count(a))
|
||||
}
|
||||
return list
|
||||
}, [teams, query, sortBy])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Empty-State / Header-Panel */}
|
||||
<section
|
||||
className="
|
||||
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
|
||||
"
|
||||
>
|
||||
{/* leichte Deko */}
|
||||
<div className="pointer-events-none absolute -right-10 -top-10 w-40 h-40 rounded-full bg-blue-500/10 blur-2xl" />
|
||||
<div className="pointer-events-none absolute -left-10 -bottom-10 w-40 h-40 rounded-full bg-indigo-500/10 blur-2xl" />
|
||||
{/* 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">
|
||||
{/* Icon */}
|
||||
<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>
|
||||
<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>
|
||||
@ -145,7 +135,6 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Sortierung */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as any)}
|
||||
@ -163,28 +152,16 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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
|
||||
"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* kleine Meta-Zeile */}
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-neutral-400">
|
||||
{visibleTeams.length} Team{visibleTeams.length === 1 ? '' : 's'} gefunden
|
||||
</div>
|
||||
@ -218,6 +195,84 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
308
src/app/components/NotificationBell.tsx
Normal file
308
src/app/components/NotificationBell.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import NotificationCenter from './NotificationCenter'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||
import { NOTIFICATION_EVENTS, isSseEventType } from '../lib/sseEvents'
|
||||
|
||||
type Notification = {
|
||||
id: string
|
||||
text: string
|
||||
read: boolean
|
||||
actionType?: string
|
||||
actionData?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
type ActionData =
|
||||
| { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
|
||||
| { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
|
||||
|
||||
// --- API Helper ---
|
||||
async function apiJSON(url: string, body?: any, method = 'POST') {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
|
||||
return res.json().catch(() => ({}))
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
|
||||
const bellRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [previewText, setPreviewText] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [animateBell, setAnimateBell] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
if (!isSseEventType(lastEvent.type)) return
|
||||
|
||||
const data = lastEvent.payload
|
||||
|
||||
// ⬅️ Einladung zurückgezogen: betroffene Notifications entfernen und abbrechen
|
||||
if (lastEvent.type === 'team-invite-revoked') {
|
||||
const invId = data?.invitationId as string | undefined
|
||||
const teamId = data?.teamId as string | undefined
|
||||
setNotifications(prev =>
|
||||
prev.filter(n => {
|
||||
const isInvite = n.actionType === 'team-invite' || n.actionType === 'invitation'
|
||||
if (!isInvite) return true
|
||||
if (invId) return n.actionData !== invId && n.id !== invId
|
||||
if (teamId) return n.actionData !== teamId
|
||||
return true
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Nur Events, die wir als sichtbare Notifications zeigen wollen
|
||||
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
|
||||
if (data?.type === 'heartbeat') return
|
||||
|
||||
const msg = (data?.message ?? '').trim()
|
||||
if (!msg) return
|
||||
}, [lastEvent])
|
||||
|
||||
// 1) Initial laden
|
||||
useEffect(() => {
|
||||
const steamId = session?.user?.steamId
|
||||
if (!steamId) return
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/user')
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
const data = await res.json()
|
||||
const loaded: Notification[] = data.notifications.map((n: any) => ({
|
||||
id: n.id,
|
||||
text: n.message,
|
||||
read: n.read,
|
||||
actionType: n.actionType,
|
||||
actionData: n.actionData,
|
||||
createdAt: n.createdAt,
|
||||
}))
|
||||
setNotifications(loaded)
|
||||
} catch (err) {
|
||||
console.error('[NotificationBell] Fehler beim Laden:', err)
|
||||
}
|
||||
})()
|
||||
}, [session?.user?.steamId])
|
||||
|
||||
// 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
|
||||
|
||||
const data = lastEvent.payload
|
||||
if (data?.type === 'heartbeat') return
|
||||
|
||||
const newNotification: Notification = {
|
||||
id: data?.id ?? crypto.randomUUID(),
|
||||
text: data?.message ?? 'Neue Benachrichtigung',
|
||||
read: false,
|
||||
actionType: data?.actionType,
|
||||
actionData: data?.actionData,
|
||||
createdAt: data?.createdAt ?? new Date().toISOString(),
|
||||
}
|
||||
|
||||
setNotifications(prev => [newNotification, ...prev])
|
||||
setPreviewText(newNotification.text) // <-- nur das hier
|
||||
}, [lastEvent])
|
||||
|
||||
// 2) Timer separat steuern: triggert bei neuem previewText
|
||||
useEffect(() => {
|
||||
if (!previewText) return
|
||||
|
||||
setShowPreview(true)
|
||||
setAnimateBell(true)
|
||||
|
||||
const PREVIEW_MS = 5000
|
||||
const CLEAR_DELAY = 300
|
||||
|
||||
const tHide = window.setTimeout(() => setShowPreview(false), PREVIEW_MS)
|
||||
const tBell = window.setTimeout(() => setAnimateBell(false), PREVIEW_MS)
|
||||
const tClear = window.setTimeout(() => setPreviewText(null), PREVIEW_MS + CLEAR_DELAY)
|
||||
|
||||
return () => {
|
||||
clearTimeout(tHide)
|
||||
clearTimeout(tBell)
|
||||
clearTimeout(tClear)
|
||||
}
|
||||
}, [previewText])
|
||||
|
||||
|
||||
// 3) Actions
|
||||
const markAllAsRead = async () => {
|
||||
await apiJSON('/api/notifications/mark-all-read', undefined, 'POST')
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
|
||||
}
|
||||
|
||||
const markOneAsRead = async (notificationId: string) => {
|
||||
await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST')
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)),
|
||||
)
|
||||
}
|
||||
|
||||
const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => {
|
||||
// passende Notification finden (per actionData oder id)
|
||||
const n = notifications.find(x => x.actionData === refId || x.id === refId)
|
||||
if (!n) {
|
||||
console.warn('[NotificationBell] Keine Notification zu', refId)
|
||||
return
|
||||
}
|
||||
|
||||
// actionData muss die Referenz tragen
|
||||
if (!n.actionData) {
|
||||
console.warn('[NotificationBell] actionData fehlt für Notification', n.id)
|
||||
return
|
||||
}
|
||||
|
||||
// actionData parsen: erlaubt JSON {kind, inviteId/requestId, teamId} ODER nackte ID
|
||||
let kind: 'invite' | 'join-request' | undefined
|
||||
let invitationId: string | undefined
|
||||
let requestId: string | undefined
|
||||
let teamId: string | undefined
|
||||
|
||||
try {
|
||||
const data = JSON.parse(n.actionData) as
|
||||
| { kind?: 'invite' | 'join-request'; inviteId?: string; requestId?: string; teamId?: string }
|
||||
| string
|
||||
|
||||
if (typeof data === 'object' && data) {
|
||||
kind = data.kind
|
||||
invitationId = data.inviteId
|
||||
requestId = data.requestId
|
||||
teamId = data.teamId
|
||||
} else if (typeof data === 'string') {
|
||||
// nackte ID: sowohl als invitationId als auch requestId nutzbar
|
||||
invitationId = data
|
||||
requestId = data
|
||||
}
|
||||
} catch {
|
||||
// non-JSON → nackte ID im actionData-String
|
||||
invitationId = n.actionData
|
||||
requestId = n.actionData
|
||||
}
|
||||
|
||||
// Fallback anhand actionType
|
||||
if (!kind && n.actionType === 'team-invite') kind = 'invite'
|
||||
if (!kind && n.actionType === 'team-join-request') kind = 'join-request'
|
||||
|
||||
// Sicherheitscheck
|
||||
if (kind === 'invite' && !invitationId) {
|
||||
console.warn('[NotificationBell] invitationId fehlt')
|
||||
return
|
||||
}
|
||||
if (kind === 'join-request' && !requestId) {
|
||||
console.warn('[NotificationBell] requestId fehlt')
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic Update (Buttons ausblenden)
|
||||
const snapshot = notifications
|
||||
setNotifications(prev =>
|
||||
prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)),
|
||||
)
|
||||
|
||||
try {
|
||||
if (kind === 'invite') {
|
||||
await apiJSON(`/api/user/invitations/${action}`, {
|
||||
invitationId,
|
||||
teamId,
|
||||
})
|
||||
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||
if (action === 'accept') router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'join-request') {
|
||||
if (action === 'accept') {
|
||||
await apiJSON('/api/team/request-join/accept', { requestId, teamId })
|
||||
} else {
|
||||
await apiJSON('/api/team/request-join/reject', { requestId })
|
||||
}
|
||||
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||
if (action === 'accept') router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
console.warn('[NotificationBell] Unbekannter Typ:', n.actionType, kind)
|
||||
setNotifications(snapshot) // rollback
|
||||
} catch (err) {
|
||||
console.error('[NotificationBell] Aktion fehlgeschlagen:', err)
|
||||
setNotifications(snapshot) // rollback
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const onNotificationClick = (notification: Notification) => {
|
||||
if (!notification.actionData) return
|
||||
try {
|
||||
const data = JSON.parse(notification.actionData)
|
||||
if (data.redirectUrl) router.push(data.redirectUrl)
|
||||
} catch (err) {
|
||||
console.error('[NotificationBell] Ungültige actionData:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Render
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
ref={bellRef}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()} // verhindert, dass der Outside-Handler denselben Pointer verwendet
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
className={`relative flex items-center transition-all duration-300 ease-in-out
|
||||
${showPreview ? 'w-[400px] pl-4 pr-11' : 'w-[44px] justify-center'}
|
||||
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
|
||||
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
|
||||
>
|
||||
{previewText && (
|
||||
<span className="truncate text-sm text-gray-800 dark:text-white">
|
||||
{previewText}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
|
||||
<svg
|
||||
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z" />
|
||||
</svg>
|
||||
{notifications.some(n => !n.read) && (
|
||||
<span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">
|
||||
<span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span>
|
||||
<span className="relative inline-flex items-center justify-center size-5 rounded-full text-xs font-bold bg-red-500 text-white">
|
||||
{notifications.filter(n => !n.read).length}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<NotificationCenter
|
||||
anchorRef={bellRef}
|
||||
notifications={notifications}
|
||||
markAllAsRead={markAllAsRead}
|
||||
onSingleRead={markOneAsRead}
|
||||
onClose={() => setOpen(false)}
|
||||
onAction={handleInviteAction}
|
||||
onClickNotification={onNotificationClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,11 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import NotificationDropdown from './NotificationDropdown'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||
import { NOTIFICATION_EVENTS, isSseEventType } from '../lib/sseEvents'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Button from './Button'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
type Notification = {
|
||||
id: string
|
||||
@ -16,289 +14,136 @@ type Notification = {
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
type ActionData =
|
||||
| { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
|
||||
| { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
|
||||
|
||||
// --- API Helper ---
|
||||
async function apiJSON(url: string, body?: any, method = 'POST') {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
|
||||
return res.json().catch(() => ({}))
|
||||
type Props = {
|
||||
notifications: Notification[]
|
||||
markAllAsRead: () => void
|
||||
onSingleRead: (id: string) => void
|
||||
onClose: () => void
|
||||
onAction: (action: 'accept' | 'reject', invitationId: string) => void
|
||||
onClickNotification?: (notification: Notification) => void
|
||||
anchorRef?: React.RefObject<HTMLElement> // Glocke
|
||||
}
|
||||
|
||||
export default function NotificationCenter() {
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
|
||||
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [previewText, setPreviewText] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [animateBell, setAnimateBell] = useState(false)
|
||||
export default function NotificationCenter({
|
||||
notifications,
|
||||
markAllAsRead,
|
||||
onSingleRead,
|
||||
onClose,
|
||||
onAction,
|
||||
onClickNotification,
|
||||
anchorRef
|
||||
}: Props) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// EIN Outside-Click Listener, der sowohl Panel als auch Anchor ignoriert
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
if (!isSseEventType(lastEvent.type)) return
|
||||
|
||||
const data = lastEvent.payload
|
||||
|
||||
// ⬅️ Einladung zurückgezogen: betroffene Notifications entfernen und abbrechen
|
||||
if (lastEvent.type === 'team-invite-revoked') {
|
||||
const invId = data?.invitationId as string | undefined
|
||||
const teamId = data?.teamId as string | undefined
|
||||
setNotifications(prev =>
|
||||
prev.filter(n => {
|
||||
const isInvite = n.actionType === 'team-invite' || n.actionType === 'invitation'
|
||||
if (!isInvite) return true
|
||||
if (invId) return n.actionData !== invId && n.id !== invId
|
||||
if (teamId) return n.actionData !== teamId
|
||||
return true
|
||||
})
|
||||
)
|
||||
return
|
||||
const onDocPointerDown = (e: PointerEvent) => {
|
||||
const t = e.target as Node
|
||||
if (panelRef.current?.contains(t)) return
|
||||
if (anchorRef?.current?.contains(t)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('pointerdown', onDocPointerDown, { passive: true })
|
||||
return () => document.removeEventListener('pointerdown', onDocPointerDown)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
// Nur Events, die wir als sichtbare Notifications zeigen wollen
|
||||
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
|
||||
if (data?.type === 'heartbeat') return
|
||||
|
||||
const msg = (data?.message ?? '').trim()
|
||||
if (!msg) return
|
||||
}, [lastEvent])
|
||||
|
||||
// 1) Initial laden
|
||||
useEffect(() => {
|
||||
const steamId = session?.user?.steamId
|
||||
if (!steamId) return
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/user')
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
const data = await res.json()
|
||||
const loaded: Notification[] = data.notifications.map((n: any) => ({
|
||||
id: n.id,
|
||||
text: n.message,
|
||||
read: n.read,
|
||||
actionType: n.actionType,
|
||||
actionData: n.actionData,
|
||||
createdAt: n.createdAt,
|
||||
}))
|
||||
setNotifications(loaded)
|
||||
} catch (err) {
|
||||
console.error('[NotificationCenter] Fehler beim Laden:', err)
|
||||
}
|
||||
})()
|
||||
}, [session?.user?.steamId])
|
||||
|
||||
// 1) Nur Events verarbeiten: Notifications sammeln + Preview-Text setzen
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
|
||||
|
||||
const data = lastEvent.payload
|
||||
if (data?.type === 'heartbeat') return
|
||||
|
||||
const newNotification: Notification = {
|
||||
id: data?.id ?? crypto.randomUUID(),
|
||||
text: data?.message ?? 'Neue Benachrichtigung',
|
||||
read: false,
|
||||
actionType: data?.actionType,
|
||||
actionData: data?.actionData,
|
||||
createdAt: data?.createdAt ?? new Date().toISOString(),
|
||||
}
|
||||
|
||||
setNotifications(prev => [newNotification, ...prev])
|
||||
setPreviewText(newNotification.text) // <-- nur das hier
|
||||
}, [lastEvent])
|
||||
|
||||
// 2) Timer separat steuern: triggert bei neuem previewText
|
||||
useEffect(() => {
|
||||
if (!previewText) return
|
||||
|
||||
setShowPreview(true)
|
||||
setAnimateBell(true)
|
||||
|
||||
const PREVIEW_MS = 5000
|
||||
const CLEAR_DELAY = 300
|
||||
|
||||
const tHide = window.setTimeout(() => setShowPreview(false), PREVIEW_MS)
|
||||
const tBell = window.setTimeout(() => setAnimateBell(false), PREVIEW_MS)
|
||||
const tClear = window.setTimeout(() => setPreviewText(null), PREVIEW_MS + CLEAR_DELAY)
|
||||
|
||||
return () => {
|
||||
clearTimeout(tHide)
|
||||
clearTimeout(tBell)
|
||||
clearTimeout(tClear)
|
||||
}
|
||||
}, [previewText])
|
||||
|
||||
|
||||
// 3) Actions
|
||||
const markAllAsRead = async () => {
|
||||
await apiJSON('/api/notifications/mark-all-read', undefined, 'POST')
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
|
||||
}
|
||||
|
||||
const markOneAsRead = async (notificationId: string) => {
|
||||
await apiJSON(`/api/notifications/mark-read/${notificationId}`, undefined, 'POST')
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)),
|
||||
)
|
||||
}
|
||||
|
||||
const handleInviteAction = async (action: 'accept' | 'reject', refId: string) => {
|
||||
// passende Notification finden (per actionData oder id)
|
||||
const n = notifications.find(x => x.actionData === refId || x.id === refId)
|
||||
if (!n) {
|
||||
console.warn('[NotificationCenter] Keine Notification zu', refId)
|
||||
return
|
||||
}
|
||||
|
||||
// actionData muss die Referenz tragen
|
||||
if (!n.actionData) {
|
||||
console.warn('[NotificationCenter] actionData fehlt für Notification', n.id)
|
||||
return
|
||||
}
|
||||
|
||||
// actionData parsen: erlaubt JSON {kind, inviteId/requestId, teamId} ODER nackte ID
|
||||
let kind: 'invite' | 'join-request' | undefined
|
||||
let invitationId: string | undefined
|
||||
let requestId: string | undefined
|
||||
let teamId: string | undefined
|
||||
|
||||
try {
|
||||
const data = JSON.parse(n.actionData) as
|
||||
| { kind?: 'invite' | 'join-request'; inviteId?: string; requestId?: string; teamId?: string }
|
||||
| string
|
||||
|
||||
if (typeof data === 'object' && data) {
|
||||
kind = data.kind
|
||||
invitationId = data.inviteId
|
||||
requestId = data.requestId
|
||||
teamId = data.teamId
|
||||
} else if (typeof data === 'string') {
|
||||
// nackte ID: sowohl als invitationId als auch requestId nutzbar
|
||||
invitationId = data
|
||||
requestId = data
|
||||
}
|
||||
} catch {
|
||||
// non-JSON → nackte ID im actionData-String
|
||||
invitationId = n.actionData
|
||||
requestId = n.actionData
|
||||
}
|
||||
|
||||
// Fallback anhand actionType
|
||||
if (!kind && n.actionType === 'team-invite') kind = 'invite'
|
||||
if (!kind && n.actionType === 'team-join-request') kind = 'join-request'
|
||||
|
||||
// Sicherheitscheck
|
||||
if (kind === 'invite' && !invitationId) {
|
||||
console.warn('[NotificationCenter] invitationId fehlt')
|
||||
return
|
||||
}
|
||||
if (kind === 'join-request' && !requestId) {
|
||||
console.warn('[NotificationCenter] requestId fehlt')
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic Update (Buttons ausblenden)
|
||||
const snapshot = notifications
|
||||
setNotifications(prev =>
|
||||
prev.map(x => (x.id === n.id ? { ...x, read: true, actionType: undefined } : x)),
|
||||
)
|
||||
|
||||
try {
|
||||
if (kind === 'invite') {
|
||||
await apiJSON(`/api/user/invitations/${action}`, {
|
||||
invitationId,
|
||||
teamId,
|
||||
})
|
||||
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||
if (action === 'accept') router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'join-request') {
|
||||
if (action === 'accept') {
|
||||
await apiJSON('/api/team/request-join/accept', { requestId, teamId })
|
||||
} else {
|
||||
await apiJSON('/api/team/request-join/reject', { requestId })
|
||||
}
|
||||
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||
if (action === 'accept') router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
console.warn('[NotificationCenter] Unbekannter Typ:', n.actionType, kind)
|
||||
setNotifications(snapshot) // rollback
|
||||
} catch (err) {
|
||||
console.error('[NotificationCenter] Aktion fehlgeschlagen:', err)
|
||||
setNotifications(snapshot) // rollback
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const onNotificationClick = (notification: Notification) => {
|
||||
if (!notification.actionData) return
|
||||
try {
|
||||
const data = JSON.parse(notification.actionData)
|
||||
if (data.redirectUrl) router.push(data.redirectUrl)
|
||||
} catch (err) {
|
||||
console.error('[NotificationCenter] Ungültige actionData:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Render
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
className={`relative flex items-center transition-all duration-300 ease-in-out
|
||||
${showPreview ? 'w-[400px] pl-4 pr-11' : 'w-[44px] justify-center'}
|
||||
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
|
||||
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
|
||||
>
|
||||
{previewText && (
|
||||
<span className="truncate text-sm text-gray-800 dark:text-white">
|
||||
{previewText}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
|
||||
<svg
|
||||
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z" />
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute bottom-20 right-0 w-80 bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-xl overflow-hidden z-50"
|
||||
>
|
||||
{/* Kopfzeile */}
|
||||
<div className="p-2 flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
|
||||
<span className="font-semibold text-gray-800 dark:text-white">
|
||||
Benachrichtigungen
|
||||
</span>
|
||||
<Button
|
||||
title="Alle als gelesen markieren"
|
||||
onClick={markAllAsRead}
|
||||
variant="solid"
|
||||
color="blue"
|
||||
size="sm"
|
||||
className="p-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
|
||||
<path d="M255.4 48.2c.2-.1.4-.2.6-.2s.4.1.6.2L460.6 194c2.1 1.5 3.4 3.9 3.4 6.5v13.6L291.5 355.7c-20.7 17-50.4 17-71.1 0L48 214.1v-13.6c0-2.6 1.2-5 3.4-6.5L255.4 48.2zM48 276.2L190 392.8c38.4 31.5 93.7 31.5 132 0L464 276.2V456c0 4.4-3.6 8-8 8H56c-4.4 0-8-3.6-8-8V276.2zM256 0c-10.2 0-20.2 3.2-28.5 9.1L23.5 154.9C8.7 165.4 0 182.4 0 200.5V456c0 30.9 25.1 56 56 56h400c30.9 0 56-25.1 56-56V200.5c0-18.1-8.7-35.1-23.4-45.6L284.5 9.1C276.2 3.2 266.2 0 256 0z" />
|
||||
</svg>
|
||||
{notifications.some(n => !n.read) && (
|
||||
<span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">
|
||||
<span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span>
|
||||
<span className="relative inline-flex items-center justify-center size-5 rounded-full text-xs font-bold bg-red-500 text-white">
|
||||
{notifications.filter(n => !n.read).length}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<NotificationDropdown
|
||||
notifications={notifications}
|
||||
markAllAsRead={markAllAsRead}
|
||||
onSingleRead={markOneAsRead}
|
||||
onClose={() => setOpen(false)}
|
||||
onAction={handleInviteAction}
|
||||
onClickNotification={onNotificationClick}
|
||||
/>
|
||||
)}
|
||||
{/* Liste */}
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-neutral-400">
|
||||
Keine Benachrichtigungen
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const needsAction =
|
||||
!n.read && (n.actionType === 'team-invite' || n.actionType === 'team-join-request')
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className="grid grid-cols-[auto_1fr_auto] items-center gap-2 py-3 px-2 border-b border-gray-200 dark:border-neutral-700 text-sm hover:bg-gray-50 dark:hover:bg-neutral-700 cursor-pointer"
|
||||
onClick={() => {
|
||||
onClickNotification?.(n)
|
||||
if (!n.read) onSingleRead(n.id)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${n.read ? 'bg-transparent' : 'bg-red-500'}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={n.read ? 'text-gray-600 dark:text-neutral-400' : 'font-semibold text-gray-900 dark:text-white'}>
|
||||
{n.text}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-neutral-500">
|
||||
{n.createdAt && formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: de })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{needsAction ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={(e) => { e.stopPropagation(); onAction('accept', n.actionData ?? n.id); onSingleRead(n.id) }}
|
||||
className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700"
|
||||
color="green"
|
||||
size="sm"
|
||||
>
|
||||
✔
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => { e.stopPropagation(); onAction('reject', n.actionData ?? n.id); onSingleRead(n.id) }}
|
||||
className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700"
|
||||
color="red"
|
||||
size="sm"
|
||||
>
|
||||
✖
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
!n.read && (
|
||||
<Button
|
||||
onClick={() => onSingleRead(n.id)}
|
||||
title="Als gelesen markieren"
|
||||
className="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-white"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
|
||||
<path d="M255.4 48.2c.2-.1.4-.2.6-.2s.4.1.6.2L460.6 194c2.1 1.5 3.4 3.9 3.4 6.5v13.6L291.5 355.7c-20.7 17-50.4 17-71.1 0L48 214.1v-13.6c0-2.6 1.2-5 3.4-6.5L255.4 48.2zM48 276.2L190 392.8c38.4 31.5 93.7 31.5 132 0L464 276.2V456c0 4.4-3.6 8-8 8H56c-4.4 0-8-3.6-8-8V276.2zM256 0c-10.2 0-20.2 3.2-28.5 9.1L23.5 154.9C8.7 165.4 0 182.4 0 200.5V456c0 30.9 25.1 56 56 56h400c30.9 0 56-25.1 56-56V200.5c0-18.1-8.7-35.1-23.4-45.6L284.5 9.1C276.2 3.2 266.2 0 256 0z" />
|
||||
</svg>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,181 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Button from './Button'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
type Notification = {
|
||||
id: string
|
||||
text: string
|
||||
read: boolean
|
||||
actionType?: string // z. B. "team-invite" | "team-join-request"
|
||||
actionData?: string // invitationId oder teamId
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
notifications: Notification[]
|
||||
markAllAsRead: () => void
|
||||
onSingleRead: (id: string) => void
|
||||
onClose: () => void
|
||||
onAction: (action: 'accept' | 'reject', invitationId: string) => void
|
||||
onClickNotification?: (notification: Notification) => void
|
||||
}
|
||||
|
||||
export default function NotificationDropdown({
|
||||
notifications,
|
||||
markAllAsRead,
|
||||
onSingleRead,
|
||||
onClose,
|
||||
onAction,
|
||||
onClickNotification
|
||||
}: Props) {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* --- Klick außerhalb schließt Dropdown ------------------------- */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [onClose])
|
||||
|
||||
/* --- Render ----------------------------------------------------- */
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute bottom-20 right-0 w-80 bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-xl overflow-hidden z-50"
|
||||
>
|
||||
{/* Kopfzeile */}
|
||||
<div className="p-2 flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
|
||||
<span className="font-semibold text-gray-800 dark:text-white">
|
||||
Benachrichtigungen
|
||||
</span>
|
||||
|
||||
<Button
|
||||
title="Alle als gelesen markieren"
|
||||
onClick={markAllAsRead}
|
||||
variant="solid"
|
||||
color="blue"
|
||||
size="sm"
|
||||
className="p-2"
|
||||
>
|
||||
{/* Icon */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
|
||||
<path d="M255.4 48.2c.2-.1.4-.2.6-.2s.4.1.6.2L460.6 194c2.1 1.5 3.4 3.9 3.4 6.5v13.6L291.5 355.7c-20.7 17-50.4 17-71.1 0L48 214.1v-13.6c0-2.6 1.2-5 3.4-6.5L255.4 48.2zM48 276.2L190 392.8c38.4 31.5 93.7 31.5 132 0L464 276.2V456c0 4.4-3.6 8-8 8H56c-4.4 0-8-3.6-8-8V276.2zM256 0c-10.2 0-20.2 3.2-28.5 9.1L23.5 154.9C8.7 165.4 0 182.4 0 200.5V456c0 30.9 25.1 56 56 56h400c30.9 0 56-25.1 56-56V200.5c0-18.1-8.7-35.1-23.4-45.6L284.5 9.1C276.2 3.2 266.2 0 256 0z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-neutral-400">
|
||||
Keine Benachrichtigungen
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const needsAction =
|
||||
!n.read &&
|
||||
(n.actionType === 'team-invite' ||
|
||||
n.actionType === 'team-join-request')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className="grid grid-cols-[auto_1fr_auto] items-center gap-2 py-3 px-2 border-b border-gray-200 dark:border-neutral-700 text-sm hover:bg-gray-50 dark:hover:bg-neutral-700 cursor-pointer"
|
||||
onClick={() => {
|
||||
onClickNotification?.(n) // ⬅️ Navigation / Weiterverarbeitung
|
||||
if (!n.read) onSingleRead(n.id) // ⬅️ erst danach als gelesen markieren
|
||||
}}
|
||||
>
|
||||
{/* roter Punkt */}
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
n.read ? 'bg-transparent' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text + Timestamp */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={
|
||||
n.read
|
||||
? 'text-gray-600 dark:text-neutral-400'
|
||||
: 'font-semibold text-gray-900 dark:text-white'
|
||||
}
|
||||
>
|
||||
{n.text}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-neutral-500">
|
||||
{n.createdAt &&
|
||||
formatDistanceToNow(new Date(n.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: de,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Aktionen */}
|
||||
<div className="flex items-center gap-1">
|
||||
{needsAction ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onAction('accept', n.actionData ?? n.id)
|
||||
onSingleRead(n.id)
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700"
|
||||
color="green"
|
||||
size="sm"
|
||||
>
|
||||
✔
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onAction('reject', n.actionData ?? n.id)
|
||||
onSingleRead(n.id)
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700"
|
||||
color="red"
|
||||
size="sm"
|
||||
>
|
||||
✖
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
!n.read && (
|
||||
<Button
|
||||
onClick={() => onSingleRead(n.id)}
|
||||
title="Als gelesen markieren"
|
||||
className="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-white"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
|
||||
<path d="M255.4 48.2c.2-.1.4-.2.6-.2s.4.1.6.2L460.6 194c2.1 1.5 3.4 3.9 3.4 6.5v13.6L291.5 355.7c-20.7 17-50.4 17-71.1 0L48 214.1v-13.6c0-2.6 1.2-5 3.4-6.5L255.4 48.2zM48 276.2L190 392.8c38.4 31.5 93.7 31.5 132 0L464 276.2V456c0 4.4-3.6 8-8 8H56c-4.4 0-8-3.6-8-8V276.2zM256 0c-10.2 0-20.2 3.2-28.5 9.1L23.5 154.9C8.7 165.4 0 182.4 0 200.5V456c0 30.9 25.1 56 56 56h400c30.9 0 56-25.1 56-56V200.5c0-18.1-8.7-35.1-23.4-45.6L284.5 9.1C276.2 3.2 266.2 0 256 0z" />
|
||||
</svg>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
// /app/components/TeamCardComponent.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useEffect, useRef, useState } from 'react'
|
||||
|
||||
@ -8,7 +8,7 @@ import { Providers } from './components/Providers';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import ThemeProvider from "@/theme/theme-provider";
|
||||
import Script from "next/script";
|
||||
import NotificationCenter from './components/NotificationCenter'
|
||||
import NotificationBell from './components/NotificationBell'
|
||||
import Navbar from "./components/Navbar";
|
||||
import SSEHandler from "./lib/SSEHandler";
|
||||
import UserActivityTracker from "./components/UserActivityTracker";
|
||||
@ -53,7 +53,7 @@ export default function RootLayout({
|
||||
<Sidebar>
|
||||
{children}
|
||||
</Sidebar>
|
||||
<NotificationCenter />
|
||||
<NotificationBell />
|
||||
</Providers>
|
||||
</ThemeProvider>
|
||||
<PrelineScriptWrapper />
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
// src/app/team/[teamId]/page.tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, KeyboardEvent, MouseEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||
import Card from '@/app/components/Card'
|
||||
import PremierRankBadge from '@/app/components/PremierRankBadge'
|
||||
|
||||
type Player = {
|
||||
steamId: string
|
||||
@ -62,29 +63,16 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
||||
}
|
||||
|
||||
loadTeam()
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
return () => { isMounted = 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
|
||||
}
|
||||
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
|
||||
|
||||
// --- Mitglieder zusammenstellen (ohne invited) ---
|
||||
const byId = new Map<string, Player>()
|
||||
const pushUnique = (p?: Player | null) => {
|
||||
if (!p) return
|
||||
if (!byId.has(p.steamId)) byId.set(p.steamId, p)
|
||||
}
|
||||
const pushUnique = (p?: Player | null) => { if (p && !byId.has(p.steamId)) byId.set(p.steamId, p) }
|
||||
|
||||
pushUnique(team.leader ?? undefined)
|
||||
team.activePlayers.forEach(pushUnique)
|
||||
@ -93,19 +81,21 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
||||
const members = Array.from(byId.values())
|
||||
|
||||
const isLeader = (p: Player) => team.leader?.steamId === p.steamId
|
||||
const isActive = (p: Player) =>
|
||||
team.activePlayers.some(a => a.steamId === p.steamId)
|
||||
const isActive = (p: Player) => team.activePlayers.some(a => a.steamId === p.steamId)
|
||||
|
||||
// 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) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card maxWidth="auto">
|
||||
{/* Header */}
|
||||
<div className="mb-5 sm:mb-8 flex items-center gap-4">
|
||||
<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}
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-full object-cover border border-gray-200 dark:border-neutral-700"
|
||||
/>
|
||||
@ -129,14 +119,29 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
||||
{members.map((m) => (
|
||||
<div
|
||||
key={m.steamId}
|
||||
className="flex items-center gap-4 p-3 rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-2xs dark:shadow-neutral-800/40"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${m.name} – Profil öffnen`}
|
||||
onClick={onCardClick(m.steamId)}
|
||||
onKeyDown={onCardKey(m.steamId)}
|
||||
className="
|
||||
group flex items-center gap-4 p-3 rounded-lg border
|
||||
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
|
||||
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
focus:ring-offset-white dark:focus:ring-offset-neutral-800
|
||||
"
|
||||
>
|
||||
<img
|
||||
src={m.avatar}
|
||||
alt={m.name}
|
||||
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
|
||||
onClick={(e) => { e.stopPropagation(); goToProfile(m.steamId) }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-neutral-100 truncate">
|
||||
{m.name}
|
||||
@ -155,6 +160,8 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
||||
>
|
||||
{isActive(m) ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
{/* Badge IMMER anzeigen, auch bei Rank 0 */}
|
||||
<PremierRankBadge rank={m.premierRank ?? 0} />
|
||||
</div>
|
||||
|
||||
{m.location && (
|
||||
@ -163,6 +170,11 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chevron rechts (rein visuell) */}
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /app/team/page.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@ -59,9 +61,9 @@ export default function TeamPageClient() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p>
|
||||
<div>
|
||||
<LoadingSpinner />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user