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 } }, { params }: { params: { teamId: string } },
) { ) {
try { try {
/* ─── 1) Team holen ─────────────────────────────── */ /* ─── 1) Team + Invites holen ───────────────────────────── */
const team = await prisma.team.findUnique({ const team = await prisma.team.findUnique({
where: { id: params.teamId }, where: { id: params.teamId },
include: {
invites: {
include: {
user: true, // ⬅ notwendig für eingeladenen Spieler
},
},
},
}) })
if (!team) { if (!team) {
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 }) 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( const allIds = Array.from(
new Set([...team.activePlayers, ...team.inactivePlayers]), new Set([...team.activePlayers, ...team.inactivePlayers]),
) )
@ -33,7 +40,6 @@ export async function GET(
}, },
}) })
/* Map steamId → Player */
const byId: Record<string, Player> = Object.fromEntries( const byId: Record<string, Player> = Object.fromEntries(
users.map(u => [ users.map(u => [
u.steamId, u.steamId,
@ -47,7 +53,6 @@ export async function GET(
]), ]),
) )
/* ─── 3) Arrays umwandeln + sortieren ───────────── */
const activePlayers = team.activePlayers const activePlayers = team.activePlayers
.map(id => byId[id]) .map(id => byId[id])
.filter(Boolean) .filter(Boolean)
@ -58,7 +63,19 @@ export async function GET(
.filter(Boolean) .filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name)) .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 = { const result = {
id : team.id, id : team.id,
name : team.name, name : team.name,
@ -67,6 +84,7 @@ export async function GET(
createdAt : team.createdAt, createdAt : team.createdAt,
activePlayers, activePlayers,
inactivePlayers, inactivePlayers,
invitedPlayers,
} }
return NextResponse.json(result) return NextResponse.json(result)

View File

@ -3,6 +3,43 @@ import { prisma } from '@/app/lib/prisma'
export async function GET() { export async function GET() {
try { 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({ const allUsers = await prisma.user.findMany({
where: { where: {
team: null, team: null,
@ -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) { } catch (error) {
console.error('Fehler beim Laden der verfügbaren Benutzer:', error) console.error('Fehler beim Laden der verfügbaren Benutzer:', error)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 }) return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })

View File

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

View File

@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth'
import { baseAuthOptions } from '@/app/lib/auth' import { baseAuthOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import type { InvitedPlayer } from '@/app/types/team'
export async function GET() { export async function GET() {
const session = await getServerSession(baseAuthOptions) const session = await getServerSession(baseAuthOptions)
@ -20,19 +21,25 @@ export async function GET() {
], ],
}, },
include: { include: {
leader: true,
matchesAsTeamA: { matchesAsTeamA: {
include: { include: {
teamA: true, teamA: true,
teamB: true, teamB: true,
} },
}, },
matchesAsTeamB: { matchesAsTeamB: {
include: { include: {
teamA: true, teamA: true,
teamB: true, teamB: true,
} },
} },
} invites: {
include: {
user: true,
},
},
},
}) })
if (!team) { if (!team) {
@ -43,7 +50,13 @@ export async function GET() {
const playerData = await prisma.user.findMany({ const playerData = await prisma.user.findMany({
where: { steamId: { in: steamIds } }, 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 const activePlayers = team.activePlayers
@ -54,6 +67,25 @@ export async function GET() {
.map((id) => playerData.find((m) => m.steamId === id)) .map((id) => playerData.find((m) => m.steamId === id))
.filter(Boolean) .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] const matches = [...team.matchesAsTeamA, ...team.matchesAsTeamB]
.filter(m => m.teamA && m.teamB) .filter(m => m.teamA && m.teamB)
.sort((a, b) => new Date(a.matchDate).getTime() - new Date(b.matchDate).getTime()) .sort((a, b) => new Date(a.matchDate).getTime() - new Date(b.matchDate).getTime())
@ -63,9 +95,10 @@ export async function GET() {
id: team.id, id: team.id,
name: team.name, name: team.name,
logo: team.logo, logo: team.logo,
leader: team.leaderId, leader: team.leader?.steamId ?? null,
activePlayers, activePlayers,
inactivePlayers, inactivePlayers,
invitedPlayers,
matches, matches,
}, },
}) })

View File

@ -5,18 +5,49 @@ import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamId, activePlayers, inactivePlayers } = await req.json() const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) { if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 }) return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
} }
// 🟢 Team-Update: aktive & inaktive Spieler
await prisma.team.update({ await prisma.team.update({
where: { id: teamId }, where: { id: teamId },
data: { activePlayers, inactivePlayers }, 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({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',

View File

@ -133,6 +133,17 @@ export async function POST(
return NextResponse.json({ message: 'Einladung abgelehnt' }) 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 }) return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
} catch (error) { } catch (error) {
console.error('Fehler bei Einladung:', error) console.error('Fehler bei Einladung:', error)

View File

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

View File

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

View File

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

View File

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

View File

@ -85,16 +85,16 @@ export function MatchDetails ({ match }: { match: Match }) {
{sorted.map(p => ( {sorted.map(p => (
<Table.Row <Table.Row
key={p.user.steamId} 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 <img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'} src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name} 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'} {p.user.name ?? 'Unbekannt'}
</div>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>

View File

@ -1,8 +1,11 @@
// MiniCard.tsx
'use client' 'use client'
import Button from './Button' import Button from './Button'
import Image from 'next/image' import Image from 'next/image'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
import { revokeInvitation } from '../lib/team-actions'
import { motion, AnimatePresence } from 'framer-motion'
type MiniCardProps = { type MiniCardProps = {
title: string title: string
@ -24,6 +27,9 @@ type MiniCardProps = {
hideOverlay?: boolean hideOverlay?: boolean
isSelectable?: boolean isSelectable?: boolean
isAdmin?: boolean isAdmin?: boolean
message?: string
isInvite?: boolean
invitationId?: string
} }
export default function MiniCard({ export default function MiniCard({
@ -46,6 +52,9 @@ export default function MiniCard({
hideOverlay = false, hideOverlay = false,
isSelectable = true, isSelectable = true,
isAdmin = false, isAdmin = false,
message,
isInvite = false,
invitationId
}: MiniCardProps) { }: MiniCardProps) {
//const isSelectable = typeof onSelect === 'function' //const isSelectable = typeof onSelect === 'function'
const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
@ -65,6 +74,14 @@ export default function MiniCard({
if (isSelectable) onSelect?.(steamId) if (isSelectable) onSelect?.(steamId)
} }
const handleRevokeClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (invitationId) {
revokeInvitation(invitationId)
}
}
const handleKickClick = (e: React.MouseEvent) => { const handleKickClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -84,12 +101,12 @@ export default function MiniCard({
return ( return (
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}> <div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
{canEdit && !hideActions && !hideOverlay && ( {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' 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> <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}> <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> </div>
{typeof onPromote === 'function' && ( {typeof onPromote === 'function' && (
<div className="pointer-events-auto" onPointerDown={stopDrag}> <div className="pointer-events-auto" onPointerDown={stopDrag}>
@ -138,6 +155,21 @@ export default function MiniCard({
<span className="text-xl mt-1" title="Weltweit">🌐</span> <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>
</div> </div>
) )

View File

@ -9,7 +9,7 @@ export default function MiniCardDummy({ title, onClick, children }: MiniCardDumm
<div <div
onClick={onClick} onClick={onClick}
className={` 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 hover:border-blue-400 dark:hover:border-blue-400 hover:cursor-pointer
border-gray-300 dark:border-neutral-700 border-gray-300 dark:border-neutral-700
`} `}

View File

@ -134,7 +134,7 @@ export default function Modal({
</div> </div>
{/* Body */} {/* 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 */} {/* Footer */}
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700"> <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> </div>
</aside> </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} {children}
</main> </main>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
// TeamMemberView.tsx
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -14,7 +15,12 @@ import { Player, Team } from '../types/team'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSE } from '@/app/lib/useSSEStore' import { useSSE } from '@/app/lib/useSSEStore'
import { AnimatePresence, motion } from 'framer-motion' 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 Button from './Button'
import Image from 'next/image' import Image from 'next/image'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
@ -38,6 +44,8 @@ type Props = {
adminMode?: boolean adminMode?: boolean
} }
type InvitedPlayer = Player & { invitationId: string }
export default function TeamMemberView({ export default function TeamMemberView({
team, team,
activePlayers, activePlayers,
@ -64,7 +72,7 @@ export default function TeamMemberView({
const canManage = adminMode || isLeader const canManage = adminMode || isLeader
const canInvite = isLeader && !adminMode const canInvite = isLeader && !adminMode
const canAddDirect = adminMode const canAddDirect = adminMode
const { leaveTeam, reloadTeam, renameTeam, deleteTeam } = useTeamManager({}, null) //const { leaveTeam, reloadTeam, renameTeam, revokeInvitation } = useTeamManager({}, null)
const [showRenameModal, setShowRenameModal] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isEditingName, setIsEditingName] = useState(false) const [isEditingName, setIsEditingName] = useState(false)
@ -74,6 +82,7 @@ export default function TeamMemberView({
const [logoFile, setLogoFile] = useState<File | null>(null) const [logoFile, setLogoFile] = useState<File | null>(null)
const [teamState, setTeamState] = useState<Team | null>(team) const [teamState, setTeamState] = useState<Team | null>(team)
const [saveSuccess, setSaveSuccess] = useState(false) const [saveSuccess, setSaveSuccess] = useState(false)
const [invitedPlayers, setInvitedPlayers] = useState<InvitedPlayer[]>([])
useEffect(() => { useEffect(() => {
if (session?.user?.steamId) { if (session?.user?.steamId) {
@ -85,6 +94,17 @@ export default function TeamMemberView({
setTeamState(team) setTeamState(team)
}, [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(() => { useEffect(() => {
if (!source || !teamState?.id) return if (!source || !teamState?.id) return
@ -108,12 +128,12 @@ export default function TeamMemberView({
/* EIN Aufruf genügt holt Team + Spieler + setzt States */ /* EIN Aufruf genügt holt Team + Spieler + setzt States */
fetch(`/api/team/${encodeURIComponent(data.teamId)}`) fetch(`/api/team/${encodeURIComponent(data.teamId)}`)
.then(r => r.json()) .then(r => r.json())
.then(fresh => { .then(({ team }) => {
setTeamState(fresh) if (!team) return
setactivePlayers((fresh.activePlayers ?? []) setTeamState(team)
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))) setactivePlayers(team.activePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
setInactivePlayers((fresh.inactivePlayers ?? []) setInactivePlayers(team.inactivePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name)))
.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) { } catch (err) {
console.error('SSE parse error:', err) console.error('SSE parse error:', err)
@ -140,7 +160,6 @@ export default function TeamMemberView({
} }
}, [source, teamState?.id, reloadTeam]) }, [source, teamState?.id, reloadTeam])
const handleDragStart = (event: any) => { const handleDragStart = (event: any) => {
const id = event.active.id const id = event.active.id
const item = activePlayers.find(p => p.steamId === id) || inactivePlayers.find(p => p.steamId === 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 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 () => { const confirmKick = async () => {
if (!kickCandidate) return if (!kickCandidate) return
@ -243,7 +273,7 @@ export default function TeamMemberView({
return return
} }
await reloadTeam() await handleReload()
} catch (err) { } catch (err) {
console.error('Fehler bei Leader-Übertragung:', err) console.error('Fehler bei Leader-Übertragung:', err)
} }
@ -252,7 +282,7 @@ export default function TeamMemberView({
if (!teamState) return null if (!teamState) return null
if (!adminMode && !currentUserSteamId) return null if (!adminMode && !currentUserSteamId) return null
const manageSteam = adminMode ? teamState.leader : currentUserSteamId const manageSteam: string = adminMode ? teamState.leader ?? '' : currentUserSteamId
const renderMemberList = (players: Player[]) => ( const renderMemberList = (players: Player[]) => (
<AnimatePresence> <AnimatePresence>
@ -349,7 +379,7 @@ export default function TeamMemberView({
}) })
if (res.ok) { if (res.ok) {
await reloadTeam() await handleReload()
} else { } else {
alert('Fehler beim Hochladen des Logos.') alert('Fehler beim Hochladen des Logos.')
} }
@ -379,7 +409,7 @@ export default function TeamMemberView({
await renameTeam(teamState.id, editedName) await renameTeam(teamState.id, editedName)
setTeamState((prev) => prev ? { ...prev, teamname: editedName } : prev) setTeamState((prev) => prev ? { ...prev, teamname: editedName } : prev)
setIsEditingName(false) setIsEditingName(false)
await reloadTeam() await handleReload()
}} }}
className="h-[34px] px-3 flex items-center justify-center" 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}> <DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8"> <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}> <SortableContext items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(activePlayers)} {renderMemberList(activePlayers)}
</SortableContext> </SortableContext>
</DroppableZone> </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}> <SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(inactivePlayers)} {renderMemberList(inactivePlayers)}
{canManage && ( {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 <MiniCardDummy
title={canAddDirect ? 'Hinzufügen' : 'Einladen'} title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
onClick={() => { onClick={() => {
@ -519,6 +549,46 @@ export default function TeamMemberView({
)} )}
</SortableContext> </SortableContext>
</DroppableZone> </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> </div>
<DragOverlay> <DragOverlay>

View File

@ -99,6 +99,7 @@ export function useTeamManager(
logo: teamData.logo, logo: teamData.logo,
activePlayers : newActive, activePlayers : newActive,
inactivePlayers: newInactive, inactivePlayers: newInactive,
invitedPlayers : teamData.invitedPlayers ?? [],
}) })
setactivePlayers(newActive) setactivePlayers(newActive)
setInactivePlayers(newInactive) 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 () => { const markAllAsRead = async () => {
try { try {
await fetch('/api/notifications/mark-all-read', { method: 'POST' }) await fetch('/api/notifications/mark-all-read', { method: 'POST' })
@ -301,6 +318,7 @@ export function useTeamManager(
setInactivePlayers, setInactivePlayers,
acceptInvitation, acceptInvitation,
rejectInvitation, rejectInvitation,
revokeInvitation,
markAllAsRead, markAllAsRead,
markOneAsRead, markOneAsRead,
handleInviteAction, 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

@ -8,12 +8,17 @@ export type Player = {
isAdmin?: boolean isAdmin?: boolean
} }
export type InvitedPlayer = Player & {
invitationId: string
}
export type Team = { export type Team = {
id: string id: string
name?: string | null name?: string | null
logo?: string | null logo?: string | null
leader?: string | null leader?: string | null
activePlayers? : Player[] activePlayers: Player[]
inactivePlayers?: Player[] inactivePlayers: Player[]
invitedPlayers?: Player[] invitedPlayers: InvitedPlayer[]
} }