This commit is contained in:
Linrador 2025-08-05 23:39:54 +02:00
parent e8e9632512
commit 63c6c9f87a
22 changed files with 764 additions and 195 deletions

View File

@ -8,16 +8,23 @@ export async function GET(
{ params }: { params: { teamId: string } },
) {
try {
/* ─── 1) Team holen ─────────────────────────────── */
/* ─── 1) Team + Invites holen ───────────────────────────── */
const team = await prisma.team.findUnique({
where: { id: params.teamId },
include: {
invites: {
include: {
user: true, // ⬅ notwendig für eingeladenen Spieler
},
},
},
})
if (!team) {
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
}
/* ─── 2) Alle Steam-IDs sammeln und Users laden ─── */
/* ─── 2) Aktive + Inaktive Spieler holen ─────────────────── */
const allIds = Array.from(
new Set([...team.activePlayers, ...team.inactivePlayers]),
)
@ -33,7 +40,6 @@ export async function GET(
},
})
/* Map steamId → Player */
const byId: Record<string, Player> = Object.fromEntries(
users.map(u => [
u.steamId,
@ -47,7 +53,6 @@ export async function GET(
]),
)
/* ─── 3) Arrays umwandeln + sortieren ───────────── */
const activePlayers = team.activePlayers
.map(id => byId[id])
.filter(Boolean)
@ -58,7 +63,19 @@ export async function GET(
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name))
/* ─── 4) Antwort zusammenbauen ───────────────────── */
/* ─── 3) Eingeladene Spieler extrahieren ─────────────────── */
const invitedPlayers: Player[] = team.invites.map(invite => {
const u = invite.user
return {
steamId : u.steamId,
name : u.name ?? 'Unbekannt',
avatar : u.avatar ?? '/assets/img/avatars/default.png',
location : u.location ?? '',
premierRank: u.premierRank ?? 0,
}
}).sort((a, b) => a.name.localeCompare(b.name))
/* ─── 4) Antwort zurückgeben ─────────────────────────────── */
const result = {
id : team.id,
name : team.name,
@ -67,6 +84,7 @@ export async function GET(
createdAt : team.createdAt,
activePlayers,
inactivePlayers,
invitedPlayers,
}
return NextResponse.json(result)

View File

@ -3,15 +3,52 @@ import { prisma } from '@/app/lib/prisma'
export async function GET() {
try {
// 1. Alle offenen Einladungen (inkl. User-Infos)
const pendingInvites = await prisma.teamInvite.findMany({
include: {
user: {
select: {
steamId : true,
name : true,
avatar : true,
location : true,
premierRank: true,
team : true,
},
},
},
})
// 2. Steam-IDs aus Einladungen extrahieren
const invitedSteamIds = new Set(
pendingInvites
.map(inv => inv.user?.steamId)
.filter(Boolean)
)
// 3. Alle Teams laden (nur SteamIDs)
const teams = await prisma.team.findMany({
select: {
activePlayers: true,
inactivePlayers: true,
},
})
// 4. Steam-IDs aller Teammitglieder sammeln
const teamMemberIds = new Set(
teams.flatMap(team => [...team.activePlayers, ...team.inactivePlayers])
)
// 5. Alle Benutzer laden, die kein Team haben
const allUsers = await prisma.user.findMany({
where: {
team: null,
},
select: {
steamId: true,
name: true,
avatar: true,
location: true,
steamId : true,
name : true,
avatar : true,
location : true,
premierRank: true,
},
orderBy: {
@ -19,7 +56,13 @@ export async function GET() {
},
})
return NextResponse.json({ users: allUsers })
// 6. Nur Benutzer behalten, die **nicht** eingeladen und **nicht** bereits im Team sind
const availableUsers = allUsers.filter(user =>
!invitedSteamIds.has(user.steamId) &&
!teamMemberIds.has(user.steamId)
)
return NextResponse.json({ users: availableUsers })
} catch (error) {
console.error('Fehler beim Laden der verfügbaren Benutzer:', error)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })

View File

@ -19,7 +19,7 @@ export async function POST(req: NextRequest) {
/* Team holen */
const team = await prisma.team.findUnique({
where : { id: teamId },
select: { name: true },
select: { name: true, leader: true },
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
@ -62,6 +62,12 @@ export async function POST(req: NextRequest) {
createdAt : notification.createdAt.toISOString(),
})
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: team.leader?.steamId,
})
return invite.id
}),
)

View File

@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth'
import { baseAuthOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { NextResponse } from 'next/server'
import type { InvitedPlayer } from '@/app/types/team'
export async function GET() {
const session = await getServerSession(baseAuthOptions)
@ -14,25 +15,31 @@ export async function GET() {
const team = await prisma.team.findFirst({
where: {
OR: [
{ leader: { steamId: session.user.steamId} },
{ leader: { steamId: session.user.steamId } },
{ activePlayers: { has: session.user.steamId } },
{ inactivePlayers: { has: session.user.steamId } },
],
},
include: {
leader: true,
matchesAsTeamA: {
include: {
teamA: true,
teamB: true,
}
},
},
matchesAsTeamB: {
include: {
teamA: true,
teamB: true,
}
}
}
},
},
invites: {
include: {
user: true,
},
},
},
})
if (!team) {
@ -43,7 +50,13 @@ export async function GET() {
const playerData = await prisma.user.findMany({
where: { steamId: { in: steamIds } },
select: { steamId: true, name: true, avatar: true, location: true, premierRank: true },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
},
})
const activePlayers = team.activePlayers
@ -54,6 +67,25 @@ export async function GET() {
.map((id) => playerData.find((m) => m.steamId === id))
.filter(Boolean)
const invitedPlayers: InvitedPlayer[] = Array.from(
new Map(
team.invites.map(invite => {
const u = invite.user
return [
u.steamId,
{
steamId: u.steamId,
name: u.name ?? 'Unbekannt',
avatar: u.avatar ?? '/assets/img/avatars/default.png',
location: u.location ?? '',
premierRank: u.premierRank ?? 0,
invitationId: invite.id,
}
]
})
).values()
).sort((a, b) => a.name.localeCompare(b.name))
const matches = [...team.matchesAsTeamA, ...team.matchesAsTeamB]
.filter(m => m.teamA && m.teamB)
.sort((a, b) => new Date(a.matchDate).getTime() - new Date(b.matchDate).getTime())
@ -63,9 +95,10 @@ export async function GET() {
id: team.id,
name: team.name,
logo: team.logo,
leader: team.leaderId,
leader: team.leader?.steamId ?? null,
activePlayers,
inactivePlayers,
invitedPlayers,
matches,
},
})

View File

@ -5,18 +5,49 @@ import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
try {
const { teamId, activePlayers, inactivePlayers } = await req.json()
const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
}
// 🟢 Team-Update: aktive & inaktive Spieler
await prisma.team.update({
where: { id: teamId },
data: { activePlayers, inactivePlayers },
})
const allSteamIds = [...activePlayers, ...inactivePlayers]
// 🔄 Einladungen synchronisieren
if (Array.isArray(invitedPlayers)) {
// Zuerst: Bestehende Einladungen für dieses Team laden
const existingInvites = await prisma.teamInvite.findMany({
where: { teamId },
})
const existingSteamIds = existingInvites.map((invite) => invite.steamId)
const toAdd = invitedPlayers.filter((id: string) => !existingSteamIds.includes(id))
const toRemove = existingSteamIds.filter((id) => !invitedPlayers.includes(id))
// Neue Einladungen erstellen
await prisma.teamInvite.createMany({
data: toAdd.map((steamId: string) => ({
teamId,
steamId,
type: 'invite',
})),
skipDuplicates: true,
})
// Nicht mehr gelistete Einladungen löschen
await prisma.teamInvite.deleteMany({
where: {
teamId,
steamId: { in: toRemove },
},
})
}
const allSteamIds = [...activePlayers, ...inactivePlayers, ...(invitedPlayers || [])]
await sendServerSSEMessage({
type: 'team-updated',

View File

@ -133,6 +133,17 @@ export async function POST(
return NextResponse.json({ message: 'Einladung abgelehnt' })
}
if (action === 'revoke') {
await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({
where: { steamId, actionData: invitationId },
data: { read: true, actionType: null, actionData: null },
})
return NextResponse.json({ message: 'Einladung gelöscht' })
}
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
} catch (error) {
console.error('Fehler bei Einladung:', error)

View File

@ -10,7 +10,7 @@ type ButtonProps = {
modalId?: string
color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'transparent'
variant?: 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link'
size?: 'sm' | 'md' | 'lg'
size?: 'xs' |'sm' | 'md' | 'lg'
className?: string
dropDirection?: "up" | "down" | "auto"
disabled?: boolean
@ -47,6 +47,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
: {}
const sizeClasses: Record<string, string> = {
xs: 'py-1 px-2',
sm: 'py-2 px-3',
md: 'py-3 px-4',
lg: 'p-4 sm:p-5',

View File

@ -59,12 +59,7 @@ export function DroppableZone({
{/* Hier sitzt der Droppable-Ref */}
<div ref={setNodeRef} className={zoneClasses}>
<div
className="
grid gap-4 justify-start
[grid-template-columns:repeat(5,minmax(0,160px))]
"
>
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
{children}
</div>
</div>

View File

@ -23,49 +23,48 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const [allUsers, setAllUsers] = useState<Player[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false);
const [invitedIds, setInvitedIds] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [sentCount, setSentCount] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
const usersPerPage = 9
const [currentPage, setCurrentPage] = useState(1)
useEffect(() => {
if (show) {
fetchUsersNotInTeam()
setIsSuccess(false) // Status zurücksetzen beim Öffnen
setIsSuccess(false)
setInvitedIds([])
}
}, [show])
const fetchUsersNotInTeam = async () => {
try {
setIsLoading(true);
setIsLoading(true)
const res = await fetch('/api/team/available-users')
const data = await res.json()
setAllUsers(data.users || [])
} catch (err) {
console.error('Fehler beim Laden der Benutzer:', err)
}
finally {
setIsLoading(false);
} finally {
setIsLoading(false)
}
}
const handleSelect = (steamId: string) => {
setSelectedIds((prev) =>
prev.includes(steamId) ? prev.filter((id) => id !== steamId) : [...prev, steamId]
prev.includes(steamId)
? prev.filter((id) => id !== steamId)
: [...prev, steamId]
)
}
// Entferne das setTimeout aus handleInvite komplett!
const handleInvite = async () => {
if (selectedIds.length === 0 || !steamId) return
try {
const url = directAdd ? '/api/team/add-players'
: '/api/team/invite'
const url = directAdd ? '/api/team/add-players' : '/api/team/invite'
const body = directAdd
? { teamId: team.id, steamIds: selectedIds }
: { teamId: team.id, userIds: selectedIds, invitedBy: steamId }
@ -76,15 +75,15 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
body: JSON.stringify(body),
})
if (!res.ok) {
const error = await res.json()
console.error('Fehler beim Einladen:', error.message)
} else {
setSentCount(selectedIds.length) // 👈 speichere Anzahl
setIsSuccess(true) // ✅ Erfolg markieren
setSelectedIds([]) // Optional: Selektion leeren
onSuccess() // ⚡ Nur Success-Callback, kein Schließen hier!
setSentCount(selectedIds.length)
setInvitedIds(selectedIds) // 👈 Einladungsliste speichern
setIsSuccess(true)
setSelectedIds([]) // ⛔ nicht zu früh löschen!
onSuccess()
}
} catch (err) {
console.error('Fehler beim Einladen:', err)
@ -99,7 +98,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
window.HSOverlay.close(modalEl)
}
onClose()
}, 1500)
}, 2000)
return () => clearTimeout(timeout)
}
@ -119,7 +118,11 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
return aSelected - bSelected
})
const unselectedUsers = filteredUsers.filter((user) => !selectedIds.includes(user.steamId))
const unselectedUsers = filteredUsers.filter((user) =>
!selectedIds.includes(user.steamId) &&
(!isSuccess || !invitedIds.includes(user.steamId))
)
const totalPages = Math.ceil(unselectedUsers.length / usersPerPage)
const startIdx = (currentPage - 1) * usersPerPage
const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + usersPerPage)
@ -127,28 +130,28 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
return (
<Modal
id="invite-members-modal"
title={directAdd ? 'Mitglieder hinzufügen' : 'Mitglieder einladen'}
title={directAdd ? 'Spieler hinzufügen' : 'Spieler einladen'}
show={show}
onClose={onClose}
onSave={handleInvite}
closeButtonColor={isSuccess ? "teal" : "blue"}
closeButtonColor={isSuccess ? 'teal' : 'blue'}
closeButtonTitle={
isSuccess
? directAdd ? 'Mitglieder hinzugefügt' : 'Einladungen versendet'
? directAdd ? 'Spieler hinzugefügt' : 'Einladungen versendet'
: directAdd ? 'Hinzufügen' : 'Einladungen senden'
}
>
<p className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
{directAdd
? 'Wähle Benutzer aus, die du direkt zum Team hinzufügen möchtest:'
: 'Wähle Benutzer aus, die du in dein Team einladen möchtest:'}
? 'Wähle Spieler aus, die du direkt zum Team hinzufügen möchtest:'
: 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}
</p>
{/* Ausgewählte Benutzer anzeigen */}
{selectedIds.length > 0 && (
<>
<div className="col-span-full">
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">
Ausgewählte Mitglieder:
Ausgewählte Spieler:
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
<AnimatePresence initial={false}>
@ -159,9 +162,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
<motion.div
key={user.steamId}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<MiniCard
@ -175,6 +178,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
hideActions={true}
rank={user.premierRank}
/>
</motion.div>
)
@ -182,8 +186,8 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
</AnimatePresence>
</div>
</div>
</>
)}
<input
type="text"
placeholder="Suchen..."
@ -191,6 +195,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
onChange={(e) => setSearchTerm(e.target.value)}
className="mt-2 w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring focus:ring-blue-400 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
/>
{isSuccess && (
<div className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg">
{directAdd
@ -198,6 +203,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`}
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{isLoading ? (
<LoadingSpinner />
@ -212,15 +218,13 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
) : (
<>
<AnimatePresence mode="popLayout" initial={false}>
{paginatedUsers
.filter((user) => !selectedIds.includes(user.steamId))
.map((user) => (
{!isSuccess && paginatedUsers.map((user) => (
<motion.div
key={user.steamId}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<MiniCard
@ -238,7 +242,38 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
/>
</motion.div>
))}
{isSuccess &&
invitedIds.map((id) => {
const user = allUsers.find((u) => u.steamId === id)
if (!user) return null
return (
<motion.div
key={`invited-${user.steamId}`}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<MiniCard
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected={false}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
hideActions={true}
rank={user.premierRank}
message="Eingeladen"
/>
</motion.div>
)
})}
</AnimatePresence>
{ !isSuccess && (
<div className="col-span-full flex justify-center mt-2">
<Pagination
currentPage={currentPage}
@ -246,6 +281,8 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
onPageChange={(page) => setCurrentPage(page)}
/>
</div>
)
}
</>
)}
</div>

View File

@ -5,7 +5,7 @@ import Modal from './Modal'
import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react'
import { Player, Team } from '../types/team'
import { useTeamManager } from '../hooks/useTeamManager'
import { leaveTeam } from '../lib/team-actions'
type Props = {
show: boolean
@ -20,7 +20,6 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
const [newLeaderId, setNewLeaderId] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { leaveTeam } = useTeamManager({}, null)
useEffect(() => {
if (show && team.leader) {

View File

@ -85,16 +85,16 @@ export function MatchDetails ({ match }: { match: Match }) {
{sorted.map(p => (
<Table.Row
key={p.user.steamId}
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
<Table.Cell className="py-1 flex items-center gap-2">
<Table.Cell className="py-1 flex items-center gap-2" hoverable onClick={() => router.push(`/profile/${p.user.steamId}`)} >
<img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name}
className="w-8 h-8 rounded-full"
className="w-8 h-8 rounded-full mr-3"
/>
<div className='font-semibold text-base'>
{p.user.name ?? 'Unbekannt'}
</div>
</Table.Cell>
<Table.Cell>

View File

@ -1,8 +1,11 @@
// MiniCard.tsx
'use client'
import Button from './Button'
import Image from 'next/image'
import PremierRankBadge from './PremierRankBadge'
import { revokeInvitation } from '../lib/team-actions'
import { motion, AnimatePresence } from 'framer-motion'
type MiniCardProps = {
title: string
@ -24,6 +27,9 @@ type MiniCardProps = {
hideOverlay?: boolean
isSelectable?: boolean
isAdmin?: boolean
message?: string
isInvite?: boolean
invitationId?: string
}
export default function MiniCard({
@ -46,6 +52,9 @@ export default function MiniCard({
hideOverlay = false,
isSelectable = true,
isAdmin = false,
message,
isInvite = false,
invitationId
}: MiniCardProps) {
//const isSelectable = typeof onSelect === 'function'
const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
@ -65,6 +74,14 @@ export default function MiniCard({
if (isSelectable) onSelect?.(steamId)
}
const handleRevokeClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (invitationId) {
revokeInvitation(invitationId)
}
}
const handleKickClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
@ -84,12 +101,12 @@ export default function MiniCard({
return (
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
{canEdit && !hideActions && !hideOverlay && (
<div className={`absolute inset-0 bg-white dark:bg-black bg-opacity-50 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${
<div className={`absolute inset-0 bg-white dark:bg-black bg-opacity-80 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${
hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'
}`}>
<span className="text-gray-800 dark:text-neutral-200 font-semibold text-sm mb-1 truncate px-2 max-w-[90%] text-center">{title}</span>
<div className="pointer-events-auto" onPointerDown={stopDrag}>
<Button title="Kicken" color="red" variant="solid" size="sm" onClick={handleKickClick} />
<Button className="max-w-[100px]" title={isInvite ? 'Einladung zurückziehen' : 'Kicken'} color="red" variant="solid" size={isInvite ? 'xs' : `sm`} onClick={isInvite ? handleRevokeClick : handleKickClick} />
</div>
{typeof onPromote === 'function' && (
<div className="pointer-events-auto" onPointerDown={stopDrag}>
@ -138,6 +155,21 @@ export default function MiniCard({
<span className="text-xl mt-1" title="Weltweit">🌐</span>
)}
*/ }
{message && (
<AnimatePresence>
<motion.div
key="miniCardMessage"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 20, opacity: 0 }}
transition={{ duration: 0.3 }}
className="absolute bottom-0 left-0 right-0 z-20 bg-green-700 text-white text-md text-center py-1 rounded-b-lg"
>
{message}
</motion.div>
</AnimatePresence>
)}
</div>
</div>
)

View File

@ -9,7 +9,7 @@ export default function MiniCardDummy({ title, onClick, children }: MiniCardDumm
<div
onClick={onClick}
className={`
relative flex flex-col h-full max-h-[200px] items-center p-4 border border-dashed rounded-lg transition
relative flex flex-col h-full max-h-[200px] max-w-[160px] items-center p-4 border border-dashed rounded-lg transition
hover:border-blue-400 dark:hover:border-blue-400 hover:cursor-pointer
border-gray-300 dark:border-neutral-700
`}

View File

@ -134,7 +134,7 @@ export default function Modal({
</div>
{/* Body */}
<div className="p-4 overflow-visible">{children}</div>
<div className="p-4 overflow-y-auto max-h-[70vh] sm:max-h-[60vh]">{children}</div>
{/* Footer */}
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">

View File

@ -168,7 +168,7 @@ export default function Sidebar({ children }: { children?: React.ReactNode }) {
</div>
</aside>
<main className="sm:ml-64 flex-1 p-6 bg-white dark:bg-black overflow-y-auto">
<main className="sm:ml-64 flex-1 h-screen p-6 bg-white dark:bg-black overflow-y-auto">
{children}
</main>
</div>

View File

@ -1,3 +1,4 @@
// SortableMiniCard.tsx
'use client'
import { useSortable, defaultAnimateLayoutChanges } from '@dnd-kit/sortable'

View File

@ -48,17 +48,21 @@ function Row({
function Cell({
children,
as: Component = 'td',
hoverable = false,
className = '',
...rest
}: {
children?: ReactNode
as?: 'td' | 'th'
hoverable?: boolean
className?: string
}) {
} & React.HTMLAttributes<HTMLTableCellElement>) {
const hoverClass = hoverable ? 'hover:bg-gray-100 dark:hover:bg-neutral-700 cursor-pointer' : ''
const baseClass =
Component === 'th'
? 'px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-neutral-400'
: 'px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200'
return <Component className={`${baseClass} ${className}`}>{children}</Component>
? 'px-6 py-3 text-start font-medium text-xs text-gray-500 uppercase dark:text-neutral-400'
: 'px-6 py-3 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200'
return <Component {...rest} className={`${baseClass} ${hoverClass} ${className}`}>{children}</Component>
}
// 📦 Zusammensetzen:

View File

@ -1,15 +1,21 @@
'use client'
import { forwardRef, useState } from 'react'
import { forwardRef, useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useTeamManager } from '../hooks/useTeamManager'
import TeamInvitationView from './TeamInvitationView'
import TeamMemberView from './TeamMemberView'
import NoTeamView from './NoTeamView'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import CreateTeamButton from './CreateTeamButton'
import {
acceptInvitation,
rejectInvitation,
markOneAsRead
} from '@/app/lib/team-actions'
import { Player, Team } from '../types/team'
type Props = {
refetchKey?: string
}
@ -18,41 +24,86 @@ type Props = {
function TeamCardComponent(props: Props, ref: any) {
const { data: session } = useSession()
const steamId = session?.user?.steamId ?? ''
const [refetchKey, setRefetchKey] = useState<string>()
const [isLoading, setIsLoading] = useState(true)
const teamManager = useTeamManager({ ...props, refetchKey }, ref)
const [team, setTeam] = useState<Team | null>(null)
const [activePlayers, setActivePlayers] = useState<Player[]>([])
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
const [invitedPlayers, setInvitedPlayers] = useState<Player[]>([])
const [pendingInvitation, setPendingInvitation] = useState<any>(null)
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
// 1. Loading
if (teamManager.isLoading) return <LoadingSpinner />
const loadTeam = async () => {
setIsLoading(true)
try {
const res = await fetch('/api/team')
const data = await res.json()
// 2. Pending invitation
if (
!teamManager.team &&
teamManager.pendingInvitation &&
teamManager.pendingInvitation.type === 'team-invite'
) {
const notificationId = teamManager.pendingInvitation.id
if (data.team) {
setTeam(data.team)
setActivePlayers(data.team.activePlayers || [])
setInactivePlayers(data.team.inactivePlayers || [])
setInvitedPlayers(data.team.invitedPlayers || [])
} else {
setTeam(null)
}
// Einladung nur laden, wenn kein Team vorhanden
if (!data.team) {
const inviteRes = await fetch('/api/user/invitations')
if (inviteRes.ok) {
const inviteData = await inviteRes.json()
const teamInvite = inviteData.invitations?.find(
(i: any) => i.type === 'team-invite'
)
setPendingInvitation(teamInvite ?? null)
}
}
} catch (err) {
console.error('Fehler beim Laden des Teams:', err)
} finally {
setIsLoading(false)
}
}
const handleMarkOneAsRead = async (id: string): Promise<void> => {
await markOneAsRead(id)
}
useEffect(() => {
loadTeam()
}, [refetchKey])
if (isLoading) return <LoadingSpinner />
// 1. Pending invitation
if (!team && pendingInvitation) {
const notificationId = pendingInvitation.id
return (
<TeamInvitationView
invitation={teamManager.pendingInvitation}
invitation={pendingInvitation}
notificationId={notificationId}
onMarkAsRead={teamManager.markOneAsRead}
onMarkAsRead={handleMarkOneAsRead}
onAction={async (action, invitationId) => {
if (action === 'accept') {
await teamManager.acceptInvitation(invitationId)
await acceptInvitation(invitationId)
} else {
await teamManager.rejectInvitation(invitationId)
await rejectInvitation(invitationId)
}
await teamManager.markOneAsRead(notificationId)
await markOneAsRead(notificationId)
await loadTeam()
}}
/>
)
}
// 3. Kein Team → Hinweis + CreateTeamButton
if (!teamManager.team) {
// 2. Kein Team
if (!team) {
return (
<div className="p-6 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700 space-y-4">
<NoTeamView />
@ -63,7 +114,7 @@ function TeamCardComponent(props: Props, ref: any) {
)
}
// 4. Team vorhanden → Member view
// 3. Team vorhanden
return (
<div className="p-5 md:p-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700">
<div className="mb-4 xl:mb-8">
@ -77,9 +128,21 @@ function TeamCardComponent(props: Props, ref: any) {
<form>
<TeamMemberView
{...teamManager}
team={team}
activePlayers={activePlayers}
inactivePlayers={inactivePlayers}
setactivePlayers={setActivePlayers}
setInactivePlayers={setInactivePlayers}
currentUserSteamId={steamId}
adminMode={false}
activeDragItem={activeDragItem}
setActiveDragItem={setActiveDragItem}
isDragging={isDragging}
setIsDragging={setIsDragging}
showLeaveModal={showLeaveModal}
setShowLeaveModal={setShowLeaveModal}
showInviteModal={showInviteModal}
setShowInviteModal={setShowInviteModal}
/>
</form>
</div>

View File

@ -1,3 +1,4 @@
// TeamMemberView.tsx
'use client'
import { useEffect, useState } from 'react'
@ -14,7 +15,12 @@ import { Player, Team } from '../types/team'
import { useSession } from 'next-auth/react'
import { useSSE } from '@/app/lib/useSSEStore'
import { AnimatePresence, motion } from 'framer-motion'
import { useTeamManager } from '../hooks/useTeamManager'
import {
leaveTeam,
reloadTeam,
renameTeam,
revokeInvitation,
} from '@/app/lib/team-actions'
import Button from './Button'
import Image from 'next/image'
import TeamPremierRankBadge from './TeamPremierRankBadge'
@ -38,6 +44,8 @@ type Props = {
adminMode?: boolean
}
type InvitedPlayer = Player & { invitationId: string }
export default function TeamMemberView({
team,
activePlayers,
@ -64,7 +72,7 @@ export default function TeamMemberView({
const canManage = adminMode || isLeader
const canInvite = isLeader && !adminMode
const canAddDirect = adminMode
const { leaveTeam, reloadTeam, renameTeam, deleteTeam } = useTeamManager({}, null)
//const { leaveTeam, reloadTeam, renameTeam, revokeInvitation } = useTeamManager({}, null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isEditingName, setIsEditingName] = useState(false)
@ -74,6 +82,7 @@ export default function TeamMemberView({
const [logoFile, setLogoFile] = useState<File | null>(null)
const [teamState, setTeamState] = useState<Team | null>(team)
const [saveSuccess, setSaveSuccess] = useState(false)
const [invitedPlayers, setInvitedPlayers] = useState<InvitedPlayer[]>([])
useEffect(() => {
if (session?.user?.steamId) {
@ -85,6 +94,17 @@ export default function TeamMemberView({
setTeamState(team)
}, [team])
useEffect(() => {
if (team?.invitedPlayers?.length) {
const unique = Array.from(
new Map(team.invitedPlayers.map(p => [p.steamId, p])).values()
)
setInvitedPlayers(unique.sort((a, b) => a.name.localeCompare(b.name)))
} else {
setInvitedPlayers([])
}
}, [team])
useEffect(() => {
if (!source || !teamState?.id) return
@ -108,12 +128,12 @@ export default function TeamMemberView({
/* EIN Aufruf genügt holt Team + Spieler + setzt States */
fetch(`/api/team/${encodeURIComponent(data.teamId)}`)
.then(r => r.json())
.then(fresh => {
setTeamState(fresh)
setactivePlayers((fresh.activePlayers ?? [])
.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
setInactivePlayers((fresh.inactivePlayers ?? [])
.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
.then(({ team }) => {
if (!team) return
setTeamState(team)
setactivePlayers(team.activePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
setInactivePlayers(team.inactivePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
setInvitedPlayers(team.invitedPlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
})
} catch (err) {
console.error('SSE parse error:', err)
@ -140,7 +160,6 @@ export default function TeamMemberView({
}
}, [source, teamState?.id, reloadTeam])
const handleDragStart = (event: any) => {
const id = event.active.id
const item = activePlayers.find(p => p.steamId === id) || inactivePlayers.find(p => p.steamId === id)
@ -210,6 +229,17 @@ export default function TeamMemberView({
setTimeout(() => setSaveSuccess(false), 3000) // 3 Sekunden sichtbar
}
const handleReload = async () => {
if (!teamState?.id) return
const updated = await reloadTeam(teamState.id)
if (!updated) return
setTeamState(updated)
setactivePlayers(updated.activePlayers)
setInactivePlayers(updated.inactivePlayers)
setInvitedPlayers(updated.invitedPlayers)
}
const confirmKick = async () => {
if (!kickCandidate) return
@ -243,7 +273,7 @@ export default function TeamMemberView({
return
}
await reloadTeam()
await handleReload()
} catch (err) {
console.error('Fehler bei Leader-Übertragung:', err)
}
@ -252,7 +282,7 @@ export default function TeamMemberView({
if (!teamState) return null
if (!adminMode && !currentUserSteamId) return null
const manageSteam = adminMode ? teamState.leader : currentUserSteamId
const manageSteam: string = adminMode ? teamState.leader ?? '' : currentUserSteamId
const renderMemberList = (players: Player[]) => (
<AnimatePresence>
@ -349,7 +379,7 @@ export default function TeamMemberView({
})
if (res.ok) {
await reloadTeam()
await handleReload()
} else {
alert('Fehler beim Hochladen des Logos.')
}
@ -379,7 +409,7 @@ export default function TeamMemberView({
await renameTeam(teamState.id, editedName)
setTeamState((prev) => prev ? { ...prev, teamname: editedName } : prev)
setIsEditingName(false)
await reloadTeam()
await handleReload()
}}
className="h-[34px] px-3 flex items-center justify-center"
>
@ -491,17 +521,17 @@ export default function TeamMemberView({
<DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8">
<DroppableZone id="active" label={`Aktives Team (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<DroppableZone id="active" label={`Aktive Spieler (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<SortableContext items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(activePlayers)}
</SortableContext>
</DroppableZone>
<DroppableZone id="inactive" label="Inaktives Team" activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<DroppableZone id="inactive" label="Inaktive Spieler" activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(inactivePlayers)}
{canManage && (
<motion.div key="mini-card-dummy" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.2 }}>
<motion.div key="mini-card-dummy" initial={{ opacity: 0 }} animate={{ opacity: 1}} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<MiniCardDummy
title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
onClick={() => {
@ -519,6 +549,46 @@ export default function TeamMemberView({
)}
</SortableContext>
</DroppableZone>
{invitedPlayers.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-md font-semibold text-gray-700 dark:text-gray-300">Eingeladene Spieler</h3>
</div>
<div className="w-full rounded-lg p-4 transition-colors min-h-[200px] border border-gray-300 dark:border-neutral-700">
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
<AnimatePresence>
{invitedPlayers.map((player: Player) => (
<motion.div
key={player.steamId}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<MiniCard
steamId={player.steamId}
title={player.name}
avatar={player.avatar}
location={player.location}
selected={false}
onSelect={() => {}}
draggable={false}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamState.leader}
isSelectable={false}
isInvite={true}
rank={player.premierRank}
onKick={revokeInvitation}
invitationId={player.invitationId}
/>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
)}
</div>
<DragOverlay>

View File

@ -99,6 +99,7 @@ export function useTeamManager(
logo: teamData.logo,
activePlayers : newActive,
inactivePlayers: newInactive,
invitedPlayers : teamData.invitedPlayers ?? [],
})
setactivePlayers(newActive)
setInactivePlayers(newInactive)
@ -195,6 +196,22 @@ export function useTeamManager(
}
}
const revokeInvitation = async (invitationId: string) => {
const id = invitationId || pendingInvitation?.id
if (!id) return
try {
await fetch('/api/user/invitations/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId: id }),
})
if (pendingInvitation?.id === id) setPendingInvitation(null)
await fetchTeam()
} catch (error) {
console.error('Fehler beim Annehmen der Einladung:', error)
}
}
const markAllAsRead = async () => {
try {
await fetch('/api/notifications/mark-all-read', { method: 'POST' })
@ -301,6 +318,7 @@ export function useTeamManager(
setInactivePlayers,
acceptInvitation,
rejectInvitation,
revokeInvitation,
markAllAsRead,
markOneAsRead,
handleInviteAction,

202
src/app/lib/team-actions.ts Normal file
View File

@ -0,0 +1,202 @@
// lib/team-actions.ts
import { Player, Team } from '../types/team'
// 🔄 Team laden
export async function reloadTeam(teamId: string): Promise<Team | null> {
try {
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}`)
if (!res.ok) throw new Error('Fehler beim Abrufen des Teams')
const data = await res.json()
const team = data.team ?? data
if (!team) return null
const sortByName = (players: Player[]) =>
players.sort((a, b) => a.name.localeCompare(b.name))
return {
id: team.id,
name: team.name,
logo: team.logo,
leader: team.leader,
activePlayers: sortByName(team.activePlayers ?? []),
inactivePlayers: sortByName(team.inactivePlayers ?? []),
invitedPlayers: sortByName(team.invitedPlayers ?? []),
}
} catch (error) {
console.error('reloadTeam:', error)
return null
}
}
// ✅ Einladung annehmen
export async function acceptInvitation(invitationId: string): Promise<boolean> {
try {
const res = await fetch('/api/user/invitations/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId }),
})
return res.ok
} catch (error) {
console.error('acceptInvitation:', error)
return false
}
}
// ❌ Einladung ablehnen
export async function rejectInvitation(invitationId: string): Promise<boolean> {
try {
const res = await fetch('/api/user/invitations/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId }),
})
return res.ok
} catch (error) {
console.error('rejectInvitation:', error)
return false
}
}
// 📩 Einladung zurückziehen
export async function revokeInvitation(invitationId: string): Promise<boolean> {
try {
const res = await fetch('/api/user/invitations/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Zurückziehen der Einladung')
}
return true
} catch (err) {
console.error('revokeInvitation:', err)
return false
}
}
// ✏️ Team umbenennen
export async function renameTeam(teamId: string, newName: string): Promise<boolean> {
try {
const res = await fetch('/api/team/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId, newName }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Umbenennen')
}
return true
} catch (err) {
console.error('renameTeam:', err)
return false
}
}
// 🚪 Team verlassen
export async function leaveTeam(steamId: string, newLeaderId?: string): Promise<boolean> {
try {
const body = newLeaderId ? { steamId, newLeaderId } : { steamId }
const res = await fetch('/api/team/leave', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Verlassen')
}
return true
} catch (err) {
console.error('leaveTeam:', err)
return false
}
}
// 👢 Spieler kicken
export async function kickPlayer(teamId: string, steamId: string): Promise<boolean> {
try {
const res = await fetch('/api/team/kick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId, steamId }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Kicken')
}
return true
} catch (err) {
console.error('kickPlayer:', err)
return false
}
}
// 👑 Leader übertragen
export async function transferLeader(teamId: string, newLeaderSteamId: string): Promise<boolean> {
try {
const res = await fetch('/api/team/transfer-leader', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId, newLeaderSteamId }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Übertragen der Leader-Rolle')
}
return true
} catch (err) {
console.error('transferLeader:', err)
return false
}
}
// 🗑️ Team löschen
export async function deleteTeam(teamId: string): Promise<boolean> {
try {
const res = await fetch('/api/team/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Löschen')
}
return true
} catch (err) {
console.error('deleteTeam:', err)
return false
}
}
// ✅ Einzelne Notification als gelesen markieren
export async function markOneAsRead(id: string): Promise<boolean> {
try {
const res = await fetch(`/api/notifications/mark-read/${id}`, {
method: 'POST',
})
return res.ok
} catch (err) {
console.error(`Fehler beim Markieren von Benachrichtigung ${id}:`, err)
return false
}
}

View File

@ -1,19 +1,24 @@
// /types/team.ts
export type Player = {
steamId : string
name : string
avatar : string
location? : string
steamId: string
name: string
avatar: string
location?: string
premierRank?: number
isAdmin? : boolean
isAdmin?: boolean
}
export type InvitedPlayer = Player & {
invitationId: string
}
export type Team = {
id : string
id: string
name?: string | null
logo?: string | null
leader?: string | null
activePlayers? : Player[]
inactivePlayers?: Player[]
invitedPlayers?: Player[]
activePlayers: Player[]
inactivePlayers: Player[]
invitedPlayers: InvitedPlayer[]
}