This commit is contained in:
Linrador 2025-09-06 15:19:09 +02:00
parent e93c00154a
commit 33b91ceb4b
12 changed files with 680 additions and 676 deletions

View File

@ -1,6 +1,7 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
allowedDevOrigins: ['ironieopen.local', '*.ironieopen.local'],
images: {
remotePatterns: [
{

View File

@ -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 })
}
}

View File

@ -1,3 +1,5 @@
// /app/components/LoadingSpinner.tsx
'use client'
export default function LoadingSpinner() {

View File

@ -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)}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -1,3 +1,5 @@
// /app/components/TeamCardComponent.tsx
'use client'
import { forwardRef, useEffect, useRef, useState } from 'react'

View File

@ -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 />

View File

@ -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>

View File

@ -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>
);
}