updated
This commit is contained in:
parent
e93c00154a
commit
33b91ceb4b
@ -1,6 +1,7 @@
|
|||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
allowedDevOrigins: ['ironieopen.local', '*.ironieopen.local'],
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -3,78 +3,54 @@ import { NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import type { Player } from '@/app/types/team'
|
import type { Player } from '@/app/types/team'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic' // optional: Caching aus
|
||||||
|
// export const revalidate = 0
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
/* 1) Alle Teams mit reinen Steam-ID-Arrays holen ---------------- */
|
|
||||||
const teams = await prisma.team.findMany({
|
const teams = await prisma.team.findMany({
|
||||||
select: {
|
select: { id: true, name: true, logo: true, leaderId: true, createdAt: true,
|
||||||
id : true,
|
activePlayers: true, inactivePlayers: true },
|
||||||
name : true,
|
|
||||||
logo : true,
|
|
||||||
leaderId : true,
|
|
||||||
createdAt : true,
|
|
||||||
activePlayers : true, // string[]
|
|
||||||
inactivePlayers: true, // string[]
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* 2) Einmalig ALLE vorkommenden Steam-IDs sammeln --------------- */
|
|
||||||
const uniqueIds = new Set<string>()
|
const uniqueIds = new Set<string>()
|
||||||
teams.forEach(t => {
|
teams.forEach(t => {
|
||||||
t.activePlayers.forEach(id => uniqueIds.add(id))
|
t.activePlayers.forEach(id => uniqueIds.add(id))
|
||||||
t.inactivePlayers.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({
|
const users = await prisma.user.findMany({
|
||||||
where : { steamId: { in: [...uniqueIds] } },
|
where: { steamId: { in: [...uniqueIds] } },
|
||||||
select: {
|
select: { steamId: true, name: true, avatar: true, location: true, premierRank: true },
|
||||||
steamId : true,
|
|
||||||
name : true,
|
|
||||||
avatar : true,
|
|
||||||
location : true,
|
|
||||||
premierRank: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* 4) steamId → Player: Null-Werte abfangen ------------------------ */
|
|
||||||
const byId: Record<string, Player> = {}
|
const byId: Record<string, Player> = {}
|
||||||
|
const DEFAULT_AVATAR = '/assets/img/avatars/default.png'
|
||||||
/* Fallbacks definieren */
|
|
||||||
const DEFAULT_AVATAR = '/assets/img/avatars/default.png' // oder was du nutzt
|
|
||||||
const UNKNOWN_NAME = 'Unbekannt'
|
const UNKNOWN_NAME = 'Unbekannt'
|
||||||
|
|
||||||
users.forEach(u => {
|
users.forEach(u => {
|
||||||
byId[u.steamId] = {
|
byId[u.steamId] = {
|
||||||
steamId : u.steamId,
|
steamId: u.steamId,
|
||||||
name : u.name ?? UNKNOWN_NAME,
|
name: u.name ?? UNKNOWN_NAME,
|
||||||
avatar : u.avatar ?? DEFAULT_AVATAR,
|
avatar: u.avatar ?? DEFAULT_AVATAR,
|
||||||
location : u.location ?? '',
|
location: u.location ?? '',
|
||||||
premierRank: u.premierRank ?? 0,
|
premierRank: u.premierRank ?? 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/* 5) Teams zurückgeben – jetzt mit aufgelösten Spielern --------- */
|
|
||||||
const result = teams.map(t => ({
|
const result = teams.map(t => ({
|
||||||
id : t.id,
|
id: t.id,
|
||||||
name : t.name,
|
name: t.name,
|
||||||
logo : t.logo,
|
logo: t.logo,
|
||||||
leader: t.leaderId, // Steam-ID des Leaders
|
leader: t.leaderId,
|
||||||
createdAt: t.createdAt,
|
createdAt: t.createdAt,
|
||||||
activePlayers : t.activePlayers
|
activePlayers: t.activePlayers .map(id => byId[id]).filter(Boolean) as Player[],
|
||||||
.map(id => byId[id])
|
inactivePlayers:t.inactivePlayers.map(id => byId[id]).filter(Boolean) as Player[],
|
||||||
.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) {
|
} catch (err) {
|
||||||
console.error('GET /api/teams failed:', err)
|
console.error('GET /api/teams failed:', err)
|
||||||
return NextResponse.json(
|
return NextResponse.json({ message: 'Interner Serverfehler' }, { status: 500 })
|
||||||
{ message: 'Interner Serverfehler' },
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /app/components/LoadingSpinner.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
export default function LoadingSpinner() {
|
export default function LoadingSpinner() {
|
||||||
|
|||||||
@ -444,45 +444,43 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
{/* Team A */}
|
{/* Team A */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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>
|
||||||
|
|
||||||
|
{canEditA && !mapvoteStarted && (
|
||||||
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{canEditA && !mapvoteStarted ? (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
<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"/>
|
<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>
|
||||||
) : (
|
<span className="text-gray-300">
|
||||||
<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'>
|
|
||||||
{canEditA && !mapvoteStarted ? (
|
|
||||||
<>
|
|
||||||
Du kannst die Aufstellung noch bis{' '}
|
Du kannst die Aufstellung noch bis{' '}
|
||||||
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>Die Aufstellung kann nicht mehr bearbeitet werden.</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => (canEditA && !mapvoteStarted) && setEditSide('A')}
|
onClick={() => setEditSide('A')}
|
||||||
disabled={!(canEditA && !mapvoteStarted)}
|
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
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
|
Spieler bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderTable(teamAPlayers)}
|
{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`}
|
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
alt="Teamlogo"
|
alt="Teamlogo"
|
||||||
fill
|
fill
|
||||||
sizes="64px"
|
sizes="96px"
|
||||||
quality={75}
|
quality={75}
|
||||||
priority={false}
|
priority={false}
|
||||||
/>
|
/>
|
||||||
@ -507,43 +505,27 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
{match.teamB?.name ?? 'Team B'}
|
{match.teamB?.name ?? 'Team B'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{canEditB && !mapvoteStarted && (
|
||||||
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{canEditB && !mapvoteStarted ? (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
<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"/>
|
<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>
|
||||||
) : (
|
<span className="text-gray-300">
|
||||||
<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'>
|
|
||||||
{canEditB && !mapvoteStarted ? (
|
|
||||||
<>
|
|
||||||
Du kannst die Aufstellung noch bis{' '}
|
Du kannst die Aufstellung noch bis{' '}
|
||||||
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>Die Aufstellung kann nicht mehr bearbeitet werden.</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => (canEditB && !mapvoteStarted) && setEditSide('B')}
|
onClick={() => setEditSide('B')}
|
||||||
disabled={!(canEditB && !mapvoteStarted)}
|
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
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
|
Spieler bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderTable(teamBPlayers)}
|
{renderTable(teamBPlayers)}
|
||||||
|
|||||||
@ -3,18 +3,17 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import TeamCard from './TeamCard'
|
import TeamCard from './TeamCard'
|
||||||
import { Team, Player } from '../types/team'
|
import type { Team, Player } from '../types/team'
|
||||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
import { TEAM_EVENTS, INVITE_EVENTS } from '../lib/sseEvents'
|
import { TEAM_EVENTS, INVITE_EVENTS } from '../lib/sseEvents'
|
||||||
import Button from './Button'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialTeams: Team[]
|
initialTeams: Team[]
|
||||||
initialInvitationMap: Record<string, string>
|
initialInvitationMap: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** flache Vergleiche */
|
/* helpers */
|
||||||
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a,b)=>a.steamId.localeCompare(b.steamId))
|
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
|
||||||
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false
|
||||||
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) 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) => {
|
const eqTeam = (a: Team, b: Team) => {
|
||||||
if (a.id !== b.id) return false
|
if (a.id !== b.id) return false
|
||||||
if (a.name !== b.name) return false
|
if ((a.name ?? '') !== (b.name ?? '')) return false
|
||||||
if (a.logo !== b.logo) return false
|
if ((a.logo ?? '') !== (b.logo ?? '')) return false
|
||||||
if (a.leader !== b.leader) return false
|
if ((a.leader as any) !== (b.leader as any)) return false
|
||||||
return eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
|
return (
|
||||||
|
eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
|
||||||
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
|
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const eqTeamList = (a: Team[], b: Team[]) => {
|
const eqTeamList = (a: Team[], b: Team[]) => {
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false
|
||||||
@ -37,21 +38,22 @@ const eqTeamList = (a: Team[], b: Team[]) => {
|
|||||||
}
|
}
|
||||||
return true
|
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) {
|
export default function NoTeamView({ initialTeams, initialInvitationMap }: Props) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const currentSteamId = session?.user?.steamId || ''
|
const currentSteamId = session?.user?.steamId || ''
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
// Daten
|
|
||||||
const [teams, setTeams] = useState<Team[]>(initialTeams)
|
const [teams, setTeams] = useState<Team[]>(initialTeams)
|
||||||
const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>(initialInvitationMap)
|
const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>(initialInvitationMap)
|
||||||
|
|
||||||
// UI-States
|
|
||||||
const [query, setQuery] = useState('')
|
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 () => {
|
const fetchTeamsAndInvitations = async () => {
|
||||||
try {
|
try {
|
||||||
const [teamRes, invitesRes] = await Promise.all([
|
const [teamRes, invitesRes] = await Promise.all([
|
||||||
@ -59,16 +61,15 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
fetch('/api/user/invitations', { cache: 'no-store' }),
|
fetch('/api/user/invitations', { cache: 'no-store' }),
|
||||||
])
|
])
|
||||||
if (!teamRes.ok || !invitesRes.ok) return
|
if (!teamRes.ok || !invitesRes.ok) return
|
||||||
const teamData = await teamRes.json()
|
const rawTeams = await teamRes.json()
|
||||||
const inviteData = await invitesRes.json()
|
const rawInv = await invitesRes.json()
|
||||||
|
const nextTeams: Team[] = parseTeamsResponse(rawTeams)
|
||||||
|
|
||||||
const nextTeams: Team[] = teamData.teams || []
|
|
||||||
const mapping: Record<string, string> = {}
|
const mapping: Record<string, string> = {}
|
||||||
for (const inv of inviteData?.invitations || []) {
|
for (const inv of rawInv?.invitations || []) {
|
||||||
if (inv.type === 'team-join-request') {
|
if (inv.type === 'team-join-request') mapping[inv.teamId] = inv.id
|
||||||
mapping[inv.teamId] = inv.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams))
|
setTeams(prev => (eqTeamList(prev, nextTeams) ? prev : nextTeams))
|
||||||
setTeamToInvitationId(prev => {
|
setTeamToInvitationId(prev => {
|
||||||
const same =
|
const same =
|
||||||
@ -81,63 +82,52 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchTeamsAndInvitations() }, [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
const { type, payload } = lastEvent
|
const { type, payload } = lastEvent
|
||||||
|
|
||||||
if (TEAM_EVENTS.has(type)) {
|
if (TEAM_EVENTS.has(type)) {
|
||||||
if (!payload?.teamId || teams.some(t => t.id === payload.teamId)) {
|
if (!payload?.teamId || teams.some(t => t.id === payload.teamId)) fetchTeamsAndInvitations()
|
||||||
fetchTeamsAndInvitations()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (INVITE_EVENTS.has(type)) {
|
if (INVITE_EVENTS.has(type)) fetchTeamsAndInvitations()
|
||||||
fetchTeamsAndInvitations()
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}
|
|
||||||
}, [lastEvent, teams])
|
}, [lastEvent, teams])
|
||||||
|
|
||||||
// Ableiten: gefiltert & sortiert
|
|
||||||
const visibleTeams = useMemo(() => {
|
const visibleTeams = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
let list = q
|
let list = q ? teams.filter(t => (t.name ?? '').toLowerCase().includes(q)) : teams.slice()
|
||||||
? teams.filter(t => (t.name ?? '').toLowerCase().includes(q))
|
|
||||||
: teams.slice()
|
|
||||||
|
|
||||||
if (sortBy === 'name-asc') {
|
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 {
|
} else {
|
||||||
const count = (t: Team) => (t.activePlayers?.length ?? 0) + (t.inactivePlayers?.length ?? 0)
|
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
|
return list
|
||||||
}, [teams, query, sortBy])
|
}, [teams, query, sortBy])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Empty-State / Header-Panel */}
|
{/* Header-Panel */}
|
||||||
<section
|
<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">
|
||||||
className="
|
{/* Linker Rand: Orbit um linken Panelrand, rotiert vorwärts */}
|
||||||
rounded-xl border border-gray-200 dark:border-neutral-700
|
<div className="edge-orbit edge-orbit-left" aria-hidden>
|
||||||
bg-white dark:bg-neutral-800 p-4 sm:p-5 shadow-sm
|
<div className="blob blob-left" />
|
||||||
relative overflow-hidden
|
</div>
|
||||||
"
|
{/* Rechter Rand: Orbit um rechten Panelrand, rotiert rückwärts */}
|
||||||
>
|
<div className="edge-orbit edge-orbit-right" aria-hidden>
|
||||||
{/* leichte Deko */}
|
<div className="blob blob-right" />
|
||||||
<div className="pointer-events-none absolute -right-10 -top-10 w-40 h-40 rounded-full bg-blue-500/10 blur-2xl" />
|
</div>
|
||||||
<div className="pointer-events-none absolute -left-10 -bottom-10 w-40 h-40 rounded-full bg-indigo-500/10 blur-2xl" />
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<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="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">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Finde dein Team</h2>
|
||||||
Finde dein Team
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
<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.
|
Stöbere durch die Teams oder starte selbst eins – du kannst später jederzeit wechseln.
|
||||||
</p>
|
</p>
|
||||||
@ -145,7 +135,6 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Sortierung */}
|
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={e => setSortBy(e.target.value as any)}
|
onChange={e => setSortBy(e.target.value as any)}
|
||||||
@ -163,28 +152,16 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
placeholder="Teams suchen …"
|
placeholder="Teams suchen …"
|
||||||
className="
|
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"
|
||||||
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
|
<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">
|
||||||
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>
|
<circle cx="11" cy="11" r="7"></circle>
|
||||||
<path d="m21 21-4.3-4.3"></path>
|
<path d="m21 21-4.3-4.3"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* kleine Meta-Zeile */}
|
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-neutral-400">
|
<div className="mt-2 text-xs text-gray-500 dark:text-neutral-400">
|
||||||
{visibleTeams.length} Team{visibleTeams.length === 1 ? '' : 's'} gefunden
|
{visibleTeams.length} Team{visibleTeams.length === 1 ? '' : 's'} gefunden
|
||||||
</div>
|
</div>
|
||||||
@ -218,6 +195,84 @@ export default function NoTeamView({ initialTeams, initialInvitationMap }: Props
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import NotificationDropdown from './NotificationDropdown'
|
import Button from './Button'
|
||||||
import { useSession } from 'next-auth/react'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { useRouter } from 'next/navigation'
|
import { de } from 'date-fns/locale'
|
||||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
|
||||||
import { NOTIFICATION_EVENTS, isSseEventType } from '../lib/sseEvents'
|
|
||||||
|
|
||||||
type Notification = {
|
type Notification = {
|
||||||
id: string
|
id: string
|
||||||
@ -16,289 +14,136 @@ type Notification = {
|
|||||||
createdAt?: string
|
createdAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionData =
|
type Props = {
|
||||||
| { kind: 'invite'; inviteId: string; teamId: string; redirectUrl?: string }
|
notifications: Notification[]
|
||||||
| { kind: 'join-request'; requestId: string; teamId: string; redirectUrl?: string }
|
markAllAsRead: () => void
|
||||||
|
onSingleRead: (id: string) => void
|
||||||
// --- API Helper ---
|
onClose: () => void
|
||||||
async function apiJSON(url: string, body?: any, method = 'POST') {
|
onAction: (action: 'accept' | 'reject', invitationId: string) => void
|
||||||
const res = await fetch(url, {
|
onClickNotification?: (notification: Notification) => void
|
||||||
method,
|
anchorRef?: React.RefObject<HTMLElement> // Glocke
|
||||||
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 NotificationCenter() {
|
export default function NotificationCenter({
|
||||||
const { data: session } = useSession()
|
notifications,
|
||||||
const router = useRouter()
|
markAllAsRead,
|
||||||
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
|
onSingleRead,
|
||||||
|
onClose,
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
onAction,
|
||||||
const [open, setOpen] = useState(false)
|
onClickNotification,
|
||||||
const [previewText, setPreviewText] = useState<string | null>(null)
|
anchorRef
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
}: Props) {
|
||||||
const [animateBell, setAnimateBell] = useState(false)
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
// EIN Outside-Click Listener, der sowohl Panel als auch Anchor ignoriert
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
const onDocPointerDown = (e: PointerEvent) => {
|
||||||
if (!isSseEventType(lastEvent.type)) return
|
const t = e.target as Node
|
||||||
|
if (panelRef.current?.contains(t)) return
|
||||||
const data = lastEvent.payload
|
if (anchorRef?.current?.contains(t)) return
|
||||||
|
onClose()
|
||||||
// ⬅️ 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
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div className="fixed bottom-6 right-6 z-50">
|
<div
|
||||||
<button
|
ref={panelRef}
|
||||||
type="button"
|
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"
|
||||||
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 && (
|
{/* Kopfzeile */}
|
||||||
<span className="truncate text-sm text-gray-800 dark:text-white">
|
<div className="p-2 flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
|
||||||
{previewText}
|
<span className="font-semibold text-gray-800 dark:text-white">
|
||||||
|
Benachrichtigungen
|
||||||
</span>
|
</span>
|
||||||
)}
|
<Button
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center z-20">
|
title="Alle als gelesen markieren"
|
||||||
<svg
|
onClick={markAllAsRead}
|
||||||
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
|
variant="solid"
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
className="p-2"
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
|
||||||
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" />
|
<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>
|
</svg>
|
||||||
{notifications.some(n => !n.read) && (
|
</Button>
|
||||||
<span className="flex absolute top-0 end-0 -mt-1 -me-1 z-30">
|
</div>
|
||||||
<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">
|
{/* Liste */}
|
||||||
{notifications.filter(n => !n.read).length}
|
<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>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-neutral-500">
|
||||||
|
{n.createdAt && formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: de })}
|
||||||
</span>
|
</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>
|
||||||
</button>
|
</div>
|
||||||
|
)
|
||||||
{open && (
|
})
|
||||||
<NotificationDropdown
|
|
||||||
notifications={notifications}
|
|
||||||
markAllAsRead={markAllAsRead}
|
|
||||||
onSingleRead={markOneAsRead}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
onAction={handleInviteAction}
|
|
||||||
onClickNotification={onNotificationClick}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</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'
|
'use client'
|
||||||
|
|
||||||
import { forwardRef, useEffect, useRef, useState } from 'react'
|
import { forwardRef, useEffect, useRef, useState } from 'react'
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Providers } from './components/Providers';
|
|||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import ThemeProvider from "@/theme/theme-provider";
|
import ThemeProvider from "@/theme/theme-provider";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import NotificationCenter from './components/NotificationCenter'
|
import NotificationBell from './components/NotificationBell'
|
||||||
import Navbar from "./components/Navbar";
|
import Navbar from "./components/Navbar";
|
||||||
import SSEHandler from "./lib/SSEHandler";
|
import SSEHandler from "./lib/SSEHandler";
|
||||||
import UserActivityTracker from "./components/UserActivityTracker";
|
import UserActivityTracker from "./components/UserActivityTracker";
|
||||||
@ -53,7 +53,7 @@ export default function RootLayout({
|
|||||||
<Sidebar>
|
<Sidebar>
|
||||||
{children}
|
{children}
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<NotificationCenter />
|
<NotificationBell />
|
||||||
</Providers>
|
</Providers>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<PrelineScriptWrapper />
|
<PrelineScriptWrapper />
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
// src/app/team/[teamId]/page.tsx
|
// src/app/team/[teamId]/page.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, KeyboardEvent, MouseEvent } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||||
import Card from '@/app/components/Card'
|
import Card from '@/app/components/Card'
|
||||||
|
import PremierRankBadge from '@/app/components/PremierRankBadge'
|
||||||
|
|
||||||
type Player = {
|
type Player = {
|
||||||
steamId: string
|
steamId: string
|
||||||
@ -62,29 +63,16 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadTeam()
|
loadTeam()
|
||||||
return () => {
|
return () => { isMounted = false }
|
||||||
isMounted = false
|
|
||||||
}
|
|
||||||
}, [params.teamId, router])
|
}, [params.teamId, router])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) return <div className="p-4"><LoadingSpinner /></div>
|
||||||
return <div className="p-4"><LoadingSpinner /></div>
|
if (error) return <div className="p-4 text-red-600">{error}</div>
|
||||||
}
|
if (!team) return null
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="p-4 text-red-600">{error}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Mitglieder zusammenstellen (ohne invited) ---
|
// --- Mitglieder zusammenstellen (ohne invited) ---
|
||||||
const byId = new Map<string, Player>()
|
const byId = new Map<string, Player>()
|
||||||
const pushUnique = (p?: Player | null) => {
|
const pushUnique = (p?: Player | null) => { if (p && !byId.has(p.steamId)) byId.set(p.steamId, p) }
|
||||||
if (!p) return
|
|
||||||
if (!byId.has(p.steamId)) byId.set(p.steamId, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
pushUnique(team.leader ?? undefined)
|
pushUnique(team.leader ?? undefined)
|
||||||
team.activePlayers.forEach(pushUnique)
|
team.activePlayers.forEach(pushUnique)
|
||||||
@ -93,19 +81,21 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
|||||||
const members = Array.from(byId.values())
|
const members = Array.from(byId.values())
|
||||||
|
|
||||||
const isLeader = (p: Player) => team.leader?.steamId === p.steamId
|
const isLeader = (p: Player) => team.leader?.steamId === p.steamId
|
||||||
const isActive = (p: Player) =>
|
const isActive = (p: Player) => team.activePlayers.some(a => a.steamId === p.steamId)
|
||||||
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 (
|
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-8 flex items-center gap-4">
|
||||||
<img
|
<img
|
||||||
src={
|
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
@ -129,14 +119,29 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
|||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.steamId}
|
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
|
<img
|
||||||
src={m.avatar}
|
src={m.avatar}
|
||||||
alt={m.name}
|
alt={m.name}
|
||||||
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<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}
|
||||||
@ -155,6 +160,8 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
|||||||
>
|
>
|
||||||
{isActive(m) ? 'Aktiv' : 'Inaktiv'}
|
{isActive(m) ? 'Aktiv' : 'Inaktiv'}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Badge IMMER anzeigen, auch bei Rank 0 */}
|
||||||
|
<PremierRankBadge rank={m.premierRank ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m.location && (
|
{m.location && (
|
||||||
@ -163,6 +170,11 @@ export default function TeamDetailPage({ params }: { params: { teamId: string }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /app/team/page.tsx
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@ -59,9 +61,9 @@ export default function TeamPageClient() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<p>
|
<div>
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</p>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user