update
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 36 KiB |
@ -1,57 +1,113 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
// /app/api/user/[steamId]/matches/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
/* optionalen Query-Parameter lesen */
|
||||
const { searchParams } = new URL(req.url)
|
||||
const matchType = searchParams.get('type') // z. B. "community"
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { steamId: string } },
|
||||
) {
|
||||
const steamId = params.steamId
|
||||
if (!steamId) {
|
||||
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
||||
}
|
||||
|
||||
/* falls übergeben ⇒ danach filtern */
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
// Query-Parameter
|
||||
const typesParam = searchParams.get('types') // z. B. "premier,competitive"
|
||||
const types = typesParam
|
||||
? typesParam.split(',').map(t => t.trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
const limit = Math.min(
|
||||
Math.max(parseInt(searchParams.get('limit') || '10', 10), 1),
|
||||
50,
|
||||
) // 1..50 (Default 10)
|
||||
|
||||
const cursor = searchParams.get('cursor') // letzte match.id der vorherigen Page
|
||||
|
||||
try {
|
||||
// Matches, in denen der Spieler vorkommt
|
||||
const matches = await prisma.match.findMany({
|
||||
where : matchType ? { matchType } : undefined,
|
||||
orderBy: { demoDate: 'desc' },
|
||||
include: {
|
||||
teamA : true,
|
||||
teamB : true,
|
||||
players: { include: { user: true, stats: true, team: true } },
|
||||
where: {
|
||||
players: { some: { steamId } },
|
||||
...(types.length ? { matchType: { in: types } } : {}),
|
||||
},
|
||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1, // eine extra zum Prüfen, ob es weiter geht
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
|
||||
select: {
|
||||
id: true,
|
||||
demoDate: true,
|
||||
map: true,
|
||||
roundCount: true,
|
||||
scoreA: true,
|
||||
scoreB: true,
|
||||
matchType: true,
|
||||
teamAId: true,
|
||||
teamBId: true,
|
||||
teamAUsers: { select: { steamId: true } },
|
||||
teamBUsers: { select: { steamId: true } },
|
||||
winnerTeam: true,
|
||||
// nur die MatchPlayer-Zeile des aktuellen Users (für Stats)
|
||||
players: {
|
||||
where: { steamId },
|
||||
select: { stats: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/* … rest bleibt unverändert … */
|
||||
const formatted = matches.map(m => ({
|
||||
id : m.id,
|
||||
map : m.map,
|
||||
demoDate: m.demoDate,
|
||||
matchType: m.matchType,
|
||||
scoreA : m.scoreA,
|
||||
scoreB : m.scoreB,
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
teamA: {
|
||||
id : m.teamA?.id ?? null,
|
||||
name: m.teamA?.name ?? 'CT',
|
||||
logo: m.teamA?.logo ?? null,
|
||||
score: m.scoreA,
|
||||
},
|
||||
teamB: {
|
||||
id : m.teamB?.id ?? null,
|
||||
name: m.teamB?.name ?? 'T',
|
||||
logo: m.teamB?.logo ?? null,
|
||||
score: m.scoreB,
|
||||
},
|
||||
players: m.players.map(p => ({
|
||||
steamId : p.steamId,
|
||||
name : p.user?.name,
|
||||
avatar : p.user?.avatar,
|
||||
stats : p.stats,
|
||||
teamId : p.teamId,
|
||||
teamName: p.team?.name ?? null,
|
||||
})),
|
||||
}))
|
||||
const hasMore = matches.length > limit
|
||||
const page = hasMore ? matches.slice(0, limit) : matches
|
||||
|
||||
return NextResponse.json(formatted)
|
||||
const items = page.map(m => {
|
||||
const stats = m.players[0]?.stats ?? null
|
||||
const kills = stats?.kills ?? 0
|
||||
const deaths = stats?.deaths ?? 0
|
||||
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
|
||||
const rankOld = stats?.rankOld ?? null
|
||||
const rankNew = stats?.rankNew ?? null
|
||||
const aim = stats?.aim ?? null
|
||||
const rankChange =
|
||||
rankNew != null && rankOld != null ? rankNew - rankOld : null
|
||||
|
||||
// Teamzugehörigkeit aus Sicht des Spielers (CT/T)
|
||||
const playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
|
||||
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
|
||||
|
||||
return {
|
||||
id: m.id,
|
||||
map: m.map ?? 'Unknown',
|
||||
date: m.demoDate?.toISOString() ?? '',
|
||||
matchType: m.matchType ?? 'community',
|
||||
|
||||
score,
|
||||
roundCount: m.roundCount,
|
||||
|
||||
rankOld,
|
||||
rankNew,
|
||||
rankChange,
|
||||
|
||||
kills,
|
||||
deaths,
|
||||
kdr,
|
||||
aim,
|
||||
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
team: playerTeam, // 'CT' | 'T'
|
||||
}
|
||||
})
|
||||
|
||||
const nextCursor = hasMore ? page[page.length - 1].id : null
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('GET /matches failed:', err)
|
||||
return NextResponse.json({ error: 'Failed to load matches' }, { status: 500 })
|
||||
console.error('[API] /user/[steamId]/matches Fehler:', err)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,57 +2,64 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises'
|
||||
import { join, dirname } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import sharp from 'sharp'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
|
||||
export const runtime = 'nodejs' // wichtig, falls du irgendwo Edge aktiviert hast
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('logo') as File
|
||||
const teamId = formData.get('teamId') as string
|
||||
const file = formData.get('logo') as File | null
|
||||
const teamId = formData.get('teamId') as string | null
|
||||
|
||||
if (!file || !teamId) {
|
||||
return NextResponse.json({ message: 'Ungültige Daten' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const ext = file.name.split('.').pop()
|
||||
const filename = `${teamId}-${randomUUID()}.${ext}`
|
||||
const filepath = join(process.cwd(), 'public/assets/img/logos', filename)
|
||||
// Bytes lesen
|
||||
const inputBuffer = Buffer.from(await file.arrayBuffer())
|
||||
|
||||
// WebP erzeugen (resize optional – nimm Werte, die für dich passen)
|
||||
const webpBuffer = await sharp(inputBuffer)
|
||||
.resize(512, 512, { fit: 'cover' }) // optional
|
||||
.webp({ quality: 85, effort: 4 }) // effort=Kompressionsaufwand
|
||||
.toBuffer()
|
||||
|
||||
const logosDir = join(process.cwd(), 'public/assets/img/logos')
|
||||
const filename = `${teamId}.webp`
|
||||
const filepath = join(logosDir, filename)
|
||||
|
||||
await mkdir(dirname(filepath), { recursive: true })
|
||||
|
||||
// Prisma laden und altes Logo abfragen
|
||||
// Altes Logo laut DB löschen (falls abweichender Name/Endung)
|
||||
const { prisma } = await import('@/app/lib/prisma')
|
||||
const existingTeam = await prisma.team.findUnique({
|
||||
const existing = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { logo: true },
|
||||
})
|
||||
|
||||
// Altes Logo löschen (falls vorhanden)
|
||||
if (existingTeam?.logo) {
|
||||
const oldPath = join(process.cwd(), 'public/assets/img/logos', existingTeam.logo)
|
||||
try {
|
||||
await unlink(oldPath)
|
||||
} catch (err) {
|
||||
console.warn('Altes Logo konnte nicht gelöscht werden (evtl. nicht vorhanden):', err)
|
||||
}
|
||||
if (existing?.logo && existing.logo !== filename) {
|
||||
try { await unlink(join(logosDir, existing.logo)) } catch {}
|
||||
}
|
||||
|
||||
// Neues Logo speichern
|
||||
await writeFile(filepath, buffer)
|
||||
// Neues Logo schreiben (overwrite ok)
|
||||
await writeFile(filepath, webpBuffer)
|
||||
|
||||
// Team in DB aktualisieren
|
||||
// DB aktualisieren
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: { logo: filename },
|
||||
data : { logo: filename },
|
||||
})
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-logo-updated',
|
||||
title: 'Team-Logo hochgeladen!',
|
||||
message: `Das Teamlogo wurde aktualisiert.`,
|
||||
teamId
|
||||
});
|
||||
const version = Date.now()
|
||||
|
||||
return NextResponse.json({ success: true, filename })
|
||||
// Optional: Clients direkt mit neuem Dateinamen + Version versorgen
|
||||
await sendServerSSEMessage({
|
||||
type : 'team-logo-updated',
|
||||
teamId,
|
||||
// falls dein SSE-Client `payload` liest:
|
||||
payload : { filename: filename, version }
|
||||
})
|
||||
await sendServerSSEMessage({ type: 'team-updated', teamId })
|
||||
|
||||
return NextResponse.json({ success: true, filename, version })
|
||||
}
|
||||
|
||||
@ -1,65 +1,56 @@
|
||||
// /app/api/user/[steamId]/matches/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
req : NextRequest, // ← Request wird gebraucht!
|
||||
req: NextRequest,
|
||||
{ params }: { params: { steamId: string } },
|
||||
) {
|
||||
const steamId = params.steamId
|
||||
if (!steamId) {
|
||||
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
||||
}
|
||||
if (!steamId) return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
||||
|
||||
/* ───────── Query-Parameter „types“ auslesen ───────── */
|
||||
const { searchParams } = new URL(req.url)
|
||||
// ?types=premier,competitive
|
||||
const typesParam = searchParams.get('types') // string | null
|
||||
const types = typesParam
|
||||
? typesParam.split(',').map(t => t.trim()).filter(Boolean)
|
||||
: [] // leer ⇒ kein Filter
|
||||
|
||||
/* ───────── Daten holen ───────── */
|
||||
// Filter: ?types=premier,competitive
|
||||
const typesParam = searchParams.get('types')
|
||||
const types = typesParam ? typesParam.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
|
||||
// Pagination
|
||||
const limit = Math.min(Math.max(parseInt(searchParams.get('limit') || '10', 10), 1), 50)
|
||||
const cursor = searchParams.get('cursor') // letzte Match-ID der vorherigen Seite
|
||||
|
||||
try {
|
||||
const matchPlayers = await prisma.matchPlayer.findMany({
|
||||
// Wir holen Matches, an denen der User teilgenommen hat
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
steamId,
|
||||
|
||||
/* nur wenn Filter gesetzt ist */
|
||||
...(types.length && {
|
||||
match: { matchType: { in: types } },
|
||||
}),
|
||||
players: { some: { steamId } },
|
||||
...(types.length ? { matchType: { in: types } } : {}),
|
||||
},
|
||||
|
||||
// deterministische Ordnung + stabil mit Cursor
|
||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1, // eine extra für hasMore
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
select: {
|
||||
teamId: true,
|
||||
team : true,
|
||||
match : {
|
||||
select: {
|
||||
id : true,
|
||||
demoDate : true,
|
||||
map : true,
|
||||
roundCount : true,
|
||||
scoreA : true,
|
||||
scoreB : true,
|
||||
matchType : true,
|
||||
teamAId : true,
|
||||
teamBId : true,
|
||||
teamAUsers : { select: { steamId: true } },
|
||||
teamBUsers : { select: { steamId: true } },
|
||||
winnerTeam : true,
|
||||
},
|
||||
},
|
||||
stats: true,
|
||||
id: true,
|
||||
demoDate: true,
|
||||
map: true,
|
||||
roundCount: true,
|
||||
scoreA: true,
|
||||
scoreB: true,
|
||||
matchType: true,
|
||||
teamAUsers: { select: { steamId: true } },
|
||||
teamBUsers: { select: { steamId: true } },
|
||||
winnerTeam: true,
|
||||
// nur die Stats des angefragten Spielers
|
||||
players: { where: { steamId }, select: { stats: true } },
|
||||
},
|
||||
|
||||
orderBy: { match: { demoDate: 'desc' } },
|
||||
})
|
||||
|
||||
/* ───────── Aufbereiten fürs Frontend ───────── */
|
||||
const data = matchPlayers.map(mp => {
|
||||
const m = mp.match
|
||||
const stats = mp.stats
|
||||
const hasMore = matches.length > limit
|
||||
const page = hasMore ? matches.slice(0, limit) : matches
|
||||
|
||||
const items = page.map(m => {
|
||||
const stats = m.players[0]?.stats ?? null
|
||||
|
||||
const kills = stats?.kills ?? 0
|
||||
const deaths = stats?.deaths ?? 0
|
||||
@ -67,16 +58,10 @@ export async function GET(
|
||||
|
||||
const rankOld = stats?.rankOld ?? null
|
||||
const rankNew = stats?.rankNew ?? null
|
||||
|
||||
const rankChange = rankNew != null && rankOld != null ? rankNew - rankOld : null
|
||||
const aim = stats?.aim ?? null
|
||||
|
||||
const rankChange =
|
||||
rankNew != null && rankOld != null ? rankNew - rankOld : null
|
||||
|
||||
/* Team des Spielers ermitteln */
|
||||
const playerTeam =
|
||||
m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
|
||||
|
||||
const playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
|
||||
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
|
||||
|
||||
return {
|
||||
@ -84,25 +69,23 @@ export async function GET(
|
||||
map : m.map ?? 'Unknown',
|
||||
date : m.demoDate?.toISOString() ?? '',
|
||||
matchType : m.matchType ?? 'community',
|
||||
|
||||
score,
|
||||
roundCount: m.roundCount,
|
||||
|
||||
rankOld,
|
||||
rankNew,
|
||||
rankChange,
|
||||
|
||||
kills,
|
||||
deaths,
|
||||
kdr,
|
||||
aim,
|
||||
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
team : playerTeam, // „CT“ oder „T“
|
||||
team : playerTeam,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json(data)
|
||||
const nextCursor = hasMore ? page[page.length - 1].id : null
|
||||
|
||||
return NextResponse.json({ items, nextCursor, hasMore })
|
||||
} catch (err) {
|
||||
console.error('[API] Fehler beim Laden der Matches:', err)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { useDroppable, useDndContext } from '@dnd-kit/core'
|
||||
import { Player } from '../types/team'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -19,14 +19,19 @@ export function DroppableZone({
|
||||
saveSuccess = false,
|
||||
}: DroppableZoneProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({ id })
|
||||
const { over } = useDndContext()
|
||||
|
||||
/* ───────────── sichtbare Zone ───────────── */
|
||||
const isOverZone = isOver ||
|
||||
over?.id === id ||
|
||||
// Sortable-Items: containerId steckt unter .sortable.containerId
|
||||
over?.data?.current?.sortable?.containerId === id ||
|
||||
// generische Droppables (z.B. MiniCardDummy): containerId direkt auf data.current
|
||||
over?.data?.current?.containerId === id
|
||||
|
||||
const zoneClasses = clsx(
|
||||
// immer volle Zeilenbreite
|
||||
'w-full rounded-lg p-4 transition-colors',
|
||||
// Mindesthöhe einer MiniCard (damit sie bei leeren Teams nicht einklappt)
|
||||
'min-h-[200px]',
|
||||
isOver
|
||||
'w-full rounded-lg p-4 transition-colors min-h-[200px]',
|
||||
isOverZone
|
||||
? 'border-2 border-dashed border-blue-400 bg-blue-400/10'
|
||||
: 'border border-gray-300 dark:border-neutral-700'
|
||||
)
|
||||
|
||||
@ -9,6 +9,8 @@ import { Player, Team } from '../types/team'
|
||||
import Pagination from './Pagination'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
@ -30,12 +32,14 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const usersPerPage = 9
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [invitedStatus, setInvitedStatus] = useState<Record<string, InviteStatus>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
fetchUsersNotInTeam()
|
||||
setIsSuccess(false)
|
||||
setInvitedIds([])
|
||||
setInvitedStatus({})
|
||||
}
|
||||
}, [show])
|
||||
|
||||
@ -60,12 +64,13 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (selectedIds.length === 0 || !steamId) return
|
||||
const ids = [...selectedIds]
|
||||
|
||||
try {
|
||||
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 }
|
||||
? { teamId: team.id, steamIds: ids }
|
||||
: { teamId: team.id, userIds: ids, invitedBy: steamId }
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@ -73,18 +78,50 @@ 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)
|
||||
// Versuch: partielle Resultate lesen
|
||||
let detail: any = null
|
||||
try { detail = await res.clone().json() } catch {}
|
||||
|
||||
if (res.ok) {
|
||||
const okStatus: InviteStatus = directAdd ? 'added' : 'sent'
|
||||
setInvitedStatus(prev => ({
|
||||
...prev,
|
||||
...Object.fromEntries(ids.map(id => [id, okStatus]))
|
||||
}))
|
||||
setSentCount(ids.length)
|
||||
} else if (detail?.results && Array.isArray(detail.results)) {
|
||||
// erwartetes Schema: [{ steamId, ok }]
|
||||
let okCount = 0
|
||||
const next: Record<string, InviteStatus> = {}
|
||||
for (const r of detail.results) {
|
||||
const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed'
|
||||
next[r.steamId] = st
|
||||
if (r.ok) okCount++
|
||||
}
|
||||
setInvitedStatus(prev => ({ ...prev, ...next }))
|
||||
setSentCount(okCount)
|
||||
} else {
|
||||
setSentCount(selectedIds.length)
|
||||
setInvitedIds(selectedIds) // 👈 Einladungsliste speichern
|
||||
setIsSuccess(true)
|
||||
setSelectedIds([]) // ⛔ nicht zu früh löschen!
|
||||
onSuccess()
|
||||
// alles fehlgeschlagen
|
||||
setInvitedStatus(prev => ({
|
||||
...prev,
|
||||
...Object.fromEntries(ids.map(id => [id, 'failed' as InviteStatus]))
|
||||
}))
|
||||
setSentCount(0)
|
||||
}
|
||||
|
||||
setInvitedIds(ids)
|
||||
setIsSuccess(true)
|
||||
setSelectedIds([])
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Einladen:', err)
|
||||
setInvitedStatus(prev => ({
|
||||
...prev,
|
||||
...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus]))
|
||||
}))
|
||||
setInvitedIds(selectedIds)
|
||||
setSentCount(0)
|
||||
setIsSuccess(true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,15 +251,42 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
: 'Keine Benutzer gefunden.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!isSuccess && paginatedUsers.map((user) => (
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!isSuccess && paginatedUsers.map((user) => (
|
||||
<motion.div
|
||||
key={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}
|
||||
onSelect={handleSelect}
|
||||
draggable={false}
|
||||
currentUserSteamId={steamId!}
|
||||
teamLeaderSteamId={team.leader}
|
||||
hideActions
|
||||
rank={user.premierRank}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{isSuccess && invitedIds.map((id) => {
|
||||
const user = allUsers.find((u) => u.steamId === id)
|
||||
if (!user) return null
|
||||
return (
|
||||
<motion.div
|
||||
key={user.steamId}
|
||||
key={`invited-${user.steamId}`}
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<MiniCard
|
||||
@ -231,57 +295,17 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
avatar={user.avatar}
|
||||
location={user.location}
|
||||
selected={false}
|
||||
onSelect={handleSelect}
|
||||
draggable={false}
|
||||
currentUserSteamId={steamId!}
|
||||
teamLeaderSteamId={team.leader}
|
||||
hideActions={true}
|
||||
hideActions
|
||||
rank={user.premierRank}
|
||||
invitedStatus={invitedStatus[user.steamId]}
|
||||
/>
|
||||
</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}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(page) => setCurrentPage(page)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@ -6,6 +6,8 @@ import Image from 'next/image'
|
||||
import PremierRankBadge from './PremierRankBadge'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
|
||||
|
||||
type MiniCardProps = {
|
||||
title: string
|
||||
avatar: string
|
||||
@ -26,7 +28,7 @@ type MiniCardProps = {
|
||||
hideOverlay?: boolean
|
||||
isSelectable?: boolean
|
||||
isAdmin?: boolean
|
||||
message?: string
|
||||
invitedStatus?: InviteStatus
|
||||
isInvite?: boolean
|
||||
invitationId?: string
|
||||
}
|
||||
@ -51,20 +53,34 @@ export default function MiniCard({
|
||||
hideOverlay = false,
|
||||
isSelectable = true,
|
||||
isAdmin = false,
|
||||
message,
|
||||
invitedStatus,
|
||||
isInvite = false,
|
||||
invitationId
|
||||
}: MiniCardProps) {
|
||||
//const isSelectable = typeof onSelect === 'function'
|
||||
const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId
|
||||
|
||||
const statusBg =
|
||||
invitedStatus === 'sent' ? 'bg-green-500 dark:bg-green-700' :
|
||||
invitedStatus === 'added' ? 'bg-teal-500 dark:bg-teal-700' :
|
||||
invitedStatus === 'failed' ? 'bg-red-500 dark:bg-red-700' :
|
||||
invitedStatus === 'pending' ? 'bg-yellow-500 dark:bg-yellow-700':
|
||||
'bg-white dark:bg-neutral-800'
|
||||
|
||||
// Rand unabhängig vom Status (nur bei Auswahl Blau; sonst neutral oder transparent)
|
||||
const baseBorder =
|
||||
selected
|
||||
? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400'
|
||||
: invitedStatus
|
||||
? 'border-transparent' // kein grüner/roter Rand, Fokus liegt auf dem BG
|
||||
: 'border-gray-200 dark:border-neutral-700'
|
||||
|
||||
const cardClasses = `
|
||||
relative flex flex-col items-center p-4 border rounded-lg transition
|
||||
max-h-[200px] max-w-[160px] overflow-hidden
|
||||
bg-white dark:bg-neutral-800 border shadow-2xs rounded-xl
|
||||
${selected ? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400' : 'border-gray-200 dark:border-neutral-700'}
|
||||
max-h-[200px] max-w-[160px] overflow-hidden shadow-2xs rounded-xl
|
||||
${statusBg} ${baseBorder}
|
||||
${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''}
|
||||
${isSelectable ? 'hover:border-blue-400 dark:hover:border-blue-400 cursor-pointer' : ''}
|
||||
${isSelectable ? 'cursor-pointer' : ''}
|
||||
`
|
||||
|
||||
const avatarWrapper = 'relative w-16 h-16 mb-2'
|
||||
@ -73,6 +89,8 @@ export default function MiniCard({
|
||||
if (isSelectable) onSelect?.(steamId)
|
||||
}
|
||||
|
||||
const stopDrag = (e: React.PointerEvent | React.MouseEvent) => e.stopPropagation()
|
||||
|
||||
const handleRevokeClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@ -91,9 +109,17 @@ export default function MiniCard({
|
||||
onPromote?.(steamId)
|
||||
}
|
||||
|
||||
const stopDrag = (e: React.PointerEvent | React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
const statusLabel =
|
||||
invitedStatus === 'sent' ? 'Eingeladen' :
|
||||
invitedStatus === 'added' ? 'Hinzugefügt' :
|
||||
invitedStatus === 'failed'? 'Fehlgeschlagen' :
|
||||
invitedStatus === 'pending'? 'Wird gesendet…' : null
|
||||
|
||||
const statusPillClasses =
|
||||
invitedStatus === 'sent' ? 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-200' :
|
||||
invitedStatus === 'added' ? 'bg-teal-100 text-teal-600 dark:bg-teal-900/40 dark:text-teal-300' :
|
||||
invitedStatus === 'failed' ? 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-300' :
|
||||
invitedStatus === 'pending' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': '';
|
||||
|
||||
return (
|
||||
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
|
||||
@ -139,10 +165,12 @@ export default function MiniCard({
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{rank ? (
|
||||
<PremierRankBadge rank={rank} />
|
||||
{statusLabel ? (
|
||||
<span className={`mt-1 px-2 py-0.5 text-xs font-medium rounded-full ${statusPillClasses}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
) : (
|
||||
<PremierRankBadge rank={0} />
|
||||
<PremierRankBadge rank={rank ?? 0} />
|
||||
)}
|
||||
|
||||
{ /*
|
||||
@ -152,21 +180,6 @@ 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>
|
||||
)
|
||||
|
||||
@ -1,12 +1,25 @@
|
||||
// MiniCardDummy.tsx
|
||||
'use client'
|
||||
|
||||
import { useDroppable } from "@dnd-kit/core"
|
||||
|
||||
type MiniCardDummyProps = {
|
||||
title: string
|
||||
onClick?: () => void
|
||||
children?: React.ReactNode
|
||||
zoneId?: string
|
||||
}
|
||||
|
||||
export default function MiniCardDummy({ title, onClick, children }: MiniCardDummyProps) {
|
||||
export default function MiniCardDummy({ title, onClick, children, zoneId }: MiniCardDummyProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `${zoneId ?? 'dummy'}-drop`,
|
||||
data: { containerId: zoneId, role: 'dummy' }, // ⬅️ wichtig für Zone-Highlight
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative flex flex-col h-full max-h-[200px] max-w-[160px] items-center p-4 border border-dashed rounded-lg transition
|
||||
|
||||
@ -168,7 +168,7 @@ export default function Sidebar({ children }: { children?: React.ReactNode }) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="sm:ml-64 flex-1 h-screen 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">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -56,7 +56,6 @@ export default function TeamMemberView({
|
||||
setIsDragging,
|
||||
adminMode = false,
|
||||
}: Props) {
|
||||
|
||||
const { team: storeTeam, setTeam } = useTeamStore()
|
||||
const team = teamProp ?? storeTeam
|
||||
if (!team) return null
|
||||
@ -86,15 +85,18 @@ export default function TeamMemberView({
|
||||
const [editedName, setEditedName] = useState(team.name || '')
|
||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||
|
||||
// Upload-Progress für Teamlogo
|
||||
// Cache-Busting fürs Logo
|
||||
const [logoVersion, setLogoVersion] = useState<number | null>(null)
|
||||
|
||||
// Upload-Progress
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
|
||||
const [uploadPct, setUploadPct] = useState(0)
|
||||
const R = 28
|
||||
const S = 64
|
||||
const CIRC = 2 * Math.PI * R
|
||||
const R = 28, S = 64, CIRC = 2 * Math.PI * R
|
||||
const dashOffset = CIRC - (uploadPct / 100) * CIRC
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const isClickable = canManage && !isUploadingLogo
|
||||
|
||||
// 1) SSE-Verbindung sicherstellen
|
||||
// SSE-Verbindung
|
||||
useEffect(() => {
|
||||
if (!currentUserSteamId) return
|
||||
if (!isConnected) connect(currentUserSteamId)
|
||||
@ -107,13 +109,13 @@ export default function TeamMemberView({
|
||||
return aa === bb
|
||||
}
|
||||
|
||||
// Team-Listen lokal synchronisieren
|
||||
useEffect(() => {
|
||||
if (!team) return
|
||||
const nextActive = (team.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||
const nextInactive = (team.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||
const nextInvited = Array.from(
|
||||
new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values()
|
||||
).sort((a,b)=>a.name.localeCompare(b.name))
|
||||
const nextInvited = Array.from(new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values())
|
||||
.sort((a,b)=>a.name.localeCompare(b.name))
|
||||
|
||||
const unchanged =
|
||||
eqByIds(activePlayers, nextActive) &&
|
||||
@ -132,13 +134,26 @@ export default function TeamMemberView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [team?.id, team?.name, team?.logo, team?.leader, team?.activePlayers, team?.inactivePlayers, team?.invitedPlayers])
|
||||
|
||||
// 2) Relevante SSE-Events -> Team neu laden
|
||||
// Relevante SSE-Events
|
||||
useEffect(() => {
|
||||
if (!lastEvent || !team?.id) return
|
||||
if (!isSseEventType(lastEvent.type)) return
|
||||
if (!RELEVANT.has(lastEvent.type)) return
|
||||
|
||||
const payload = lastEvent.payload ?? {}
|
||||
|
||||
// ► Spezialfall: nur Logo aktualisieren (ohne komplettes Reload)
|
||||
if (lastEvent.type === 'team-logo-updated') {
|
||||
if (payload.teamId && payload.teamId !== team.id) return
|
||||
|
||||
const current = useTeamStore.getState().team
|
||||
if (payload?.filename && current) {
|
||||
setTeam({ ...current, logo: payload.filename })
|
||||
}
|
||||
if (payload?.version) setLogoVersion(payload.version)
|
||||
return
|
||||
}
|
||||
|
||||
// andere Team/Self-Events
|
||||
if (!RELEVANT.has(lastEvent.type)) return
|
||||
if (payload.teamId && payload.teamId !== team.id) return
|
||||
|
||||
;(async () => {
|
||||
@ -149,9 +164,8 @@ export default function TeamMemberView({
|
||||
|
||||
const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||
const nextInactive = (updated.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||||
const nextInvited = Array.from(
|
||||
new Map((updated.invitedPlayers ?? []).map(p => [p.steamId, p])).values()
|
||||
).sort((a,b)=>a.name.localeCompare(b.name))
|
||||
const nextInvited = Array.from(new Map((updated.invitedPlayers ?? []).map(p => [p.steamId, p])).values())
|
||||
.sort((a,b)=>a.name.localeCompare(b.name))
|
||||
|
||||
if (isDraggingRef.current) {
|
||||
setPendingRemote({ active: nextActive, inactive: nextInactive, invited: nextInvited })
|
||||
@ -224,7 +238,6 @@ export default function TeamMemberView({
|
||||
const dropToActive =
|
||||
overId === 'active' || activePlayers.some(p => p.steamId === overId)
|
||||
|
||||
// selbe Zone -> nichts speichern
|
||||
if ((wasInActive && dropToActive) || (!wasInActive && !dropToActive)) {
|
||||
if (pendingRemote) {
|
||||
setActivePlayers(pendingRemote.active)
|
||||
@ -323,7 +336,7 @@ export default function TeamMemberView({
|
||||
}
|
||||
}
|
||||
|
||||
// Upload mit Progress via XHR
|
||||
// Upload mit Progress via XHR – setzt filename/version direkt, kein Reload nötig
|
||||
async function uploadTeamLogo(file: File) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const formData = new FormData()
|
||||
@ -331,15 +344,24 @@ export default function TeamMemberView({
|
||||
formData.append('teamId', team!.id)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/team/upload-logo') // ggf. anpassen
|
||||
xhr.open('POST', '/api/team/upload-logo')
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setUploadPct(Math.round((e.loaded / e.total) * 100))
|
||||
if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100))
|
||||
else setUploadPct(p => (p < 90 ? p + 1 : p))
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText)
|
||||
const current = useTeamStore.getState().team
|
||||
if (json?.filename && current) setTeam({ ...current, logo: json.filename })
|
||||
if (json?.version) setLogoVersion(json.version)
|
||||
} catch {}
|
||||
resolve()
|
||||
} else {
|
||||
setUploadPct((p) => (p < 90 ? p + 1 : p))
|
||||
reject(new Error('Upload fehlgeschlagen'))
|
||||
}
|
||||
}
|
||||
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error('Upload fehlgeschlagen')))
|
||||
xhr.onerror = () => reject(new Error('Netzwerkfehler beim Upload'))
|
||||
setIsUploadingLogo(true)
|
||||
setUploadPct(0)
|
||||
@ -390,11 +412,31 @@ export default function TeamMemberView({
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600 cursor-pointer"
|
||||
onClick={() => canManage && !isUploadingLogo && document.getElementById('logoUpload')?.click()}
|
||||
role="button"
|
||||
aria-disabled={!isClickable}
|
||||
aria-busy={isUploadingLogo}
|
||||
tabIndex={isClickable ? 0 : -1}
|
||||
className={[
|
||||
"relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600",
|
||||
isClickable ? "cursor-pointer" : "cursor-not-allowed opacity-70"
|
||||
].join(" ")}
|
||||
onClick={() => { if (isClickable) fileInputRef.current?.click() }}
|
||||
onKeyDown={(e) => {
|
||||
if (!isClickable) return
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
title={isUploadingLogo ? "Upload läuft…" : (canManage ? "Logo hochladen" : undefined)}
|
||||
>
|
||||
<Image
|
||||
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
|
||||
key={`${team.logo ?? 'fallback'}-${logoVersion ?? 0}`}
|
||||
src={
|
||||
team.logo
|
||||
? `/assets/img/logos/${team.logo}${logoVersion ? `?v=${logoVersion}` : ''}`
|
||||
: `/assets/img/logos/cs2.webp`
|
||||
}
|
||||
alt="Teamlogo"
|
||||
fill
|
||||
sizes="64px"
|
||||
@ -403,7 +445,8 @@ export default function TeamMemberView({
|
||||
priority={false}
|
||||
/>
|
||||
|
||||
{canManage && !isUploadingLogo && (
|
||||
{/* Hover-Overlay nur, wenn klickbar */}
|
||||
{canManage && isClickable && (
|
||||
<div className="absolute inset-0 bg-black/50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 mb-1" viewBox="0 0 576 512" fill="currentColor">
|
||||
<path d="M288 109.3L288 352c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-242.7-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352l128 0c0 35.3 28.7 64 64 64s64-28.7 64-64l128 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/>
|
||||
@ -411,25 +454,23 @@ export default function TeamMemberView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress-Kreis (Start bei 12 Uhr via rotate(-90 …)) */}
|
||||
{isUploadingLogo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} className="absolute">
|
||||
<circle
|
||||
cx={S/2} cy={S/2} r={R}
|
||||
stroke="rgba(255,255,255,0.35)"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={S/2} cy={S/2} r={R}
|
||||
stroke="#16a34a"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRC}
|
||||
strokeDashoffset={dashOffset}
|
||||
style={{ transition: 'stroke-dashoffset 120ms linear' }}
|
||||
/>
|
||||
<g transform={`rotate(-90 ${S/2} ${S/2})`}>
|
||||
<circle cx={S/2} cy={S/2} r={R} stroke="rgba(255,255,255,0.35)" strokeWidth="6" fill="none" />
|
||||
<circle
|
||||
cx={S/2} cy={S/2} r={R}
|
||||
stroke="#16a34a"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRC}
|
||||
strokeDashoffset={dashOffset}
|
||||
style={{ transition: 'stroke-dashoffset 120ms linear' }}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-[11px] font-semibold text-white drop-shadow">{uploadPct}%</span>
|
||||
</div>
|
||||
@ -438,16 +479,18 @@ export default function TeamMemberView({
|
||||
|
||||
{canManage && (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
id="logoUpload"
|
||||
className="hidden"
|
||||
disabled={!isClickable}
|
||||
onChange={async (e) => {
|
||||
if (isUploadingLogo) return
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadTeamLogo(file)
|
||||
await handleReload()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Hochladen des Logos:', err)
|
||||
alert('Fehler beim Hochladen des Logos.')
|
||||
@ -459,7 +502,6 @@ export default function TeamMemberView({
|
||||
e.currentTarget.value = ''
|
||||
}
|
||||
}}
|
||||
disabled={isUploadingLogo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -531,20 +573,16 @@ export default function TeamMemberView({
|
||||
|
||||
<div className="flex gap-2">
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
color='red'
|
||||
size='sm'
|
||||
>
|
||||
<Button onClick={() => setShowDeleteModal(true)} color='red' size='sm'>
|
||||
Team löschen
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (isLeader) {
|
||||
setShowLeaveModal(true)
|
||||
} else {
|
||||
try { await leaveTeam(currentUserSteamId) } catch (err) { console.error('Fehler beim Verlassen:', err) }
|
||||
if (isLeader) setShowLeaveModal(true)
|
||||
else {
|
||||
try { await leaveTeam(currentUserSteamId) }
|
||||
catch (err) { console.error('Fehler beim Verlassen:', err) }
|
||||
}
|
||||
}}
|
||||
color='blue'
|
||||
@ -563,17 +601,18 @@ export default function TeamMemberView({
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<DroppableZone id="active" label={`Aktive Spieler (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
|
||||
<SortableContext key={`sc-active-${remountKey}-${activePlayers.map(p=>p.steamId).join(',')}`} items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||||
<SortableContext id="active" key={`sc-active-${remountKey}-${activePlayers.map(p=>p.steamId).join(',')}`} items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||||
{renderMemberList(activePlayers)}
|
||||
</SortableContext>
|
||||
</DroppableZone>
|
||||
|
||||
<DroppableZone id="inactive" label="Inaktive Spieler" activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
|
||||
<SortableContext key={`sc-inactive-${remountKey}-${inactivePlayers.map(p=>p.steamId).join(',')}`} items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||||
<SortableContext id="inactive" key={`sc-inactive-${remountKey}-${inactivePlayers.map(p=>p.steamId).join(',')}`} items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||||
{renderMemberList(inactivePlayers)}
|
||||
{canManage && (
|
||||
<motion.div key="mini-card-dummy" initial={{ opacity: 0 }} animate={{ opacity: 1}} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<MiniCardDummy
|
||||
zoneId="inactive"
|
||||
title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
|
||||
onClick={() => {
|
||||
setShowInviteModal(false)
|
||||
@ -616,10 +655,8 @@ export default function TeamMemberView({
|
||||
rank={player.premierRank}
|
||||
invitationId={(player as any).invitationId}
|
||||
onKick={async (sid) => {
|
||||
// optimistisch entfernen
|
||||
setInvitedPlayers(list => list.filter(p => p.steamId !== sid))
|
||||
try {
|
||||
// API: mit invitationId ODER Fallback teamId+steamId
|
||||
await fetch('/api/user/invitations/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -631,10 +668,8 @@ export default function TeamMemberView({
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Revoke fehlgeschlagen:', e)
|
||||
// bei Fehler wieder einfügen
|
||||
setInvitedPlayers(list => [...list, player].sort((a,b)=>a.name.localeCompare(b.name)))
|
||||
} finally {
|
||||
// sicherheitshalber Team neu laden
|
||||
const updated = await reloadTeam(team.id)
|
||||
if (updated) setTeam(updated)
|
||||
}
|
||||
|
||||
@ -1,148 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import Table from '../../../Table'
|
||||
import PremierRankBadge from '../../../PremierRankBadge'
|
||||
import CompRankBadge from '../../../CompRankBadge'
|
||||
import { mapNameMap } from '@/app/lib/mapNameMap'
|
||||
import Table from '../../../Table'
|
||||
import PremierRankBadge from '../../../PremierRankBadge'
|
||||
import CompRankBadge from '../../../CompRankBadge'
|
||||
import { mapNameMap } from '@/app/lib/mapNameMap'
|
||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||
|
||||
/* ───────── Typen ───────── */
|
||||
interface Match {
|
||||
id : string
|
||||
map : string
|
||||
date : string
|
||||
score : string | null
|
||||
winnerTeam?: 'CT' | 'T' | 'Draw'
|
||||
team? : 'CT' | 'T'
|
||||
matchType : 'premier' | 'competitive' | string
|
||||
rating : string
|
||||
kills : number
|
||||
deaths : number
|
||||
kdr : string
|
||||
rankNew : number
|
||||
rankOld : number
|
||||
rankChange : number | null
|
||||
oneK : number
|
||||
twoK : number
|
||||
threeK : number
|
||||
fourK : number
|
||||
fiveK : number
|
||||
aim : number
|
||||
id: string
|
||||
map: string
|
||||
date: string
|
||||
score: string | null
|
||||
winnerTeam?: 'CT' | 'T' | 'Draw' | null
|
||||
team?: 'CT' | 'T' | null
|
||||
matchType: 'premier' | 'competitive' | string
|
||||
rating: string
|
||||
kills: number
|
||||
deaths: number
|
||||
kdr: string
|
||||
rankNew: number
|
||||
rankOld: number
|
||||
rankChange: number | null
|
||||
oneK: number
|
||||
twoK: number
|
||||
threeK: number
|
||||
fourK: number
|
||||
fiveK: number
|
||||
aim: number | string
|
||||
}
|
||||
|
||||
/* ───────── Hilfsfunktionen ───────── */
|
||||
const parseScore = (raw?: string | null): [number, number] => {
|
||||
if (!raw) return [0, 0]
|
||||
const [a, b] = raw.split(':').map(n => Number(n.trim()))
|
||||
return [Number.isNaN(a) ? 0 : a, Number.isNaN(b) ? 0 : b]
|
||||
}
|
||||
|
||||
/* ───────── Komponente ───────── */
|
||||
// Scroll-Parent des Sentinels finden (falls eigenes overflow-Element)
|
||||
function getScrollParent(el: HTMLElement | null): HTMLElement | Window {
|
||||
if (!el) return window
|
||||
let p: HTMLElement | null = el.parentElement
|
||||
const re = /(auto|scroll)/i
|
||||
while (p && p !== document.body) {
|
||||
const cs = getComputedStyle(p)
|
||||
if (re.test(cs.overflowY) || re.test(cs.overflow)) return p
|
||||
p = p.parentElement
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
export default function UserMatchesList({ steamId }: { steamId: string }) {
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const router = useRouter()
|
||||
const [cursor, setCursor] = useState<string | null>(null)
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Refs halten stets den aktuellen Wert für Callbacks
|
||||
const cursorRef = useRef<string | null>(null)
|
||||
const loadingRef = useRef(false)
|
||||
const hasMoreRef = useRef(true)
|
||||
|
||||
const router = useRouter()
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const setCursorBoth = (c: string | null) => { setCursor(c); cursorRef.current = c }
|
||||
const setLoadingBoth = (v: boolean) => { setLoading(v); loadingRef.current = v }
|
||||
const setHasMoreBoth = (v: boolean) => { setHasMore(v); hasMoreRef.current = v }
|
||||
|
||||
// Seite laden – initial: ersetzen, sonst: anhängen
|
||||
const fetchPage = useCallback(async (opts?: { initial?: boolean }) => {
|
||||
if (!steamId) return
|
||||
fetch(`/api/user/${steamId}/matches?types=premier,competitive`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(setMatches)
|
||||
.catch(console.error)
|
||||
if (loadingRef.current || !hasMoreRef.current) return
|
||||
|
||||
setLoadingBoth(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ types: 'premier,competitive', limit: '10' })
|
||||
const useCursor = opts?.initial ? null : cursorRef.current
|
||||
if (useCursor) params.set('cursor', useCursor)
|
||||
|
||||
const res = await fetch(`/api/user/${steamId}/matches?` + params.toString(), { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error('Failed to fetch')
|
||||
|
||||
const data = await res.json()
|
||||
const pageItems: Match[] = Array.isArray(data) ? data : (data.items ?? [])
|
||||
const pageNextCursor: string | null = Array.isArray(data) ? null : (data.nextCursor ?? null)
|
||||
const pageHasMore: boolean = Array.isArray(data) ? false : !!data.hasMore
|
||||
|
||||
if (opts?.initial) {
|
||||
setMatches(pageItems) // → genau 10 nach (Re-)Öffnen des Tabs
|
||||
} else {
|
||||
setMatches(prev => {
|
||||
const seen = new Set(prev.map(m => m.id))
|
||||
const merged = [...prev]
|
||||
for (const it of pageItems) if (!seen.has(it.id)) merged.push(it)
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
setCursorBoth(pageNextCursor)
|
||||
setHasMoreBoth(pageHasMore)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setLoadingBoth(false)
|
||||
}
|
||||
}, [steamId])
|
||||
|
||||
// Sichtbarkeits-Check des Sentinels im aktuellen Layout/Container
|
||||
const sentinelInView = useCallback(() => {
|
||||
const el = sentinelRef.current
|
||||
if (!el) return false
|
||||
const rect = el.getBoundingClientRect()
|
||||
const vh = window.innerHeight || document.documentElement.clientHeight
|
||||
const vw = window.innerWidth || document.documentElement.clientWidth
|
||||
return rect.top < vh && rect.bottom >= 0 && rect.left < vw && rect.right >= 0
|
||||
}, [])
|
||||
|
||||
const checkAndLoadMore = useCallback(() => {
|
||||
if (!hasMoreRef.current || loadingRef.current) return
|
||||
if (sentinelInView()) fetchPage()
|
||||
}, [fetchPage, sentinelInView])
|
||||
|
||||
// Reset + erste Seite laden (ersetzen)
|
||||
useEffect(() => {
|
||||
setMatches([])
|
||||
setCursorBoth(null)
|
||||
setHasMoreBoth(true)
|
||||
setLoadingBoth(false)
|
||||
if (steamId) fetchPage({ initial: true })
|
||||
}, [steamId, fetchPage])
|
||||
|
||||
// IntersectionObserver mit KORREKTEM root (Scroll-Parent) + Fallback-Events
|
||||
useEffect(() => {
|
||||
const target = sentinelRef.current
|
||||
if (!target) return
|
||||
|
||||
const rootEl = getScrollParent(target)
|
||||
const rootForIO = rootEl instanceof Window ? undefined : rootEl
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
entries => {
|
||||
const first = entries[0]
|
||||
if (first.isIntersecting && hasMoreRef.current && !loadingRef.current) {
|
||||
fetchPage()
|
||||
}
|
||||
},
|
||||
{ root: rootForIO as Element | undefined, rootMargin: '400px 0px 400px 0px' }
|
||||
)
|
||||
|
||||
io.observe(target)
|
||||
|
||||
// Scroll/Resize-Fallback auf dem tatsächlichen Scroll-Container
|
||||
const scrollTarget: any = rootEl instanceof Window ? window : rootEl
|
||||
const onScroll = () => checkAndLoadMore()
|
||||
const onResize = () => checkAndLoadMore()
|
||||
scrollTarget.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
// Kick direkt nach Mount (z. B. wenn Sentinel bereits sichtbar)
|
||||
const id = window.setTimeout(checkAndLoadMore, 60)
|
||||
|
||||
// Reaktion auf Tab-Show/Hide per CSS (class/hidden/style)
|
||||
const mo = new MutationObserver(() => {
|
||||
// Frame abwarten, dann prüfen
|
||||
requestAnimationFrame(checkAndLoadMore)
|
||||
})
|
||||
// bis zur nächsten Panel-Grenze hochhorchen
|
||||
let node: Element | null = target.parentElement
|
||||
const observed: Element[] = []
|
||||
while (node && node !== document.body) {
|
||||
mo.observe(node, { attributes: true, attributeFilter: ['class', 'style', 'hidden'] })
|
||||
observed.push(node)
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(id)
|
||||
io.disconnect()
|
||||
mo.disconnect()
|
||||
scrollTarget.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
}, [checkAndLoadMore, fetchPage])
|
||||
|
||||
return (
|
||||
<Table>
|
||||
{/* Kopf */}
|
||||
<Table.Head>
|
||||
<Table.Row>
|
||||
{['Map','Date','Score','Rank','Aim','Kills','Deaths','K/D'].map(h => (
|
||||
<Table.Cell key={h} as="th">{h}</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Head>
|
||||
<>
|
||||
<Table>
|
||||
<Table.Head>
|
||||
<Table.Row>
|
||||
{['Map','Date','Score','Rank','Aim','Kills','Deaths','K/D'].map(h => (
|
||||
<Table.Cell key={h} as="th">{h}</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Head>
|
||||
|
||||
{/* Daten */}
|
||||
<Table.Body>
|
||||
{matches.map(m => {
|
||||
const mapInfo = mapNameMap[m.map] ?? mapNameMap.lobby_mapveto
|
||||
const [scoreCT, scoreT] = parseScore(m.score)
|
||||
<Table.Body>
|
||||
{matches.map(m => {
|
||||
const mapInfo = mapNameMap[m.map] ?? mapNameMap.lobby_mapveto
|
||||
const [scoreCT, scoreT] = parseScore(m.score)
|
||||
|
||||
/* Score aus Sicht des Spielers drehen */
|
||||
const ownCTSide = m.team !== 'T'
|
||||
const left = ownCTSide ? scoreCT : scoreT
|
||||
const right = ownCTSide ? scoreT : scoreCT
|
||||
const ownCTSide = m.team !== 'T'
|
||||
const left = ownCTSide ? scoreCT : scoreT
|
||||
const right = ownCTSide ? scoreT : scoreCT
|
||||
|
||||
/* Text-Farbe für Score */
|
||||
const scoreColor =
|
||||
left > right ? 'text-green-600 dark:text-green-400'
|
||||
: left < right ? 'text-red-600 dark:text-red-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
const scoreColor =
|
||||
left > right ? 'text-green-600 dark:text-green-400'
|
||||
: left < right ? 'text-red-600 dark:text-red-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
|
||||
return (
|
||||
<Table.Row
|
||||
key={m.id}
|
||||
hoverable
|
||||
onClick={() => router.push(`/match-details/${m.id}`)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{/* Map + Icon */}
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={`/assets/img/mapicons/${m.map}.webp`}
|
||||
alt={mapInfo.name}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
{mapInfo.name}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
return (
|
||||
<Table.Row
|
||||
key={m.id}
|
||||
hoverable
|
||||
onClick={() => router.push(`/match-details/${m.id}`)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={`/assets/img/mapicons/${m.map}.webp`}
|
||||
alt={mapInfo.name}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
{mapInfo.name}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Datum */}
|
||||
<Table.Cell>{new Date(m.date).toLocaleString()}</Table.Cell>
|
||||
<Table.Cell>{new Date(m.date).toLocaleString()}</Table.Cell>
|
||||
|
||||
{/* Score */}
|
||||
<Table.Cell>
|
||||
<span className={`font-medium ${scoreColor}`}>
|
||||
{left} : {right}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={`font-medium ${scoreColor}`}>
|
||||
{left} : {right}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Rank + Delta */}
|
||||
<Table.Cell className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
{m.matchType === 'premier'
|
||||
? <PremierRankBadge rank={m.rankNew} />
|
||||
: <CompRankBadge rank={m.rankNew} />}
|
||||
{m.rankChange !== null && m.matchType === 'premier' && (
|
||||
<span
|
||||
className={
|
||||
m.rankChange > 0 ? 'text-green-500'
|
||||
: m.rankChange < 0 ? 'text-red-500'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{m.rankChange > 0 ? '+' : ''}{m.rankChange}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
{m.matchType === 'premier'
|
||||
? <PremierRankBadge rank={m.rankNew} />
|
||||
: <CompRankBadge rank={m.rankNew} /> }
|
||||
{m.rankChange !== null && m.matchType === 'premier' && (
|
||||
<span className={m.rankChange > 0 ? 'text-green-500' : m.rankChange < 0 ? 'text-red-500' : ''}>
|
||||
{m.rankChange > 0 ? '+' : ''}{m.rankChange}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Stats */}
|
||||
<Table.Cell>
|
||||
{Number.isFinite(Number(m.aim))
|
||||
? `${Number(m.aim).toFixed(0)} %`
|
||||
: '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{m.kills}</Table.Cell>
|
||||
<Table.Cell>{m.deaths}</Table.Cell>
|
||||
<Table.Cell>{m.kdr}</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Table.Cell>
|
||||
{Number.isFinite(Number(m.aim))
|
||||
? `${Number(m.aim).toFixed(0)} %`
|
||||
: '-' }
|
||||
</Table.Cell>
|
||||
<Table.Cell>{m.kills}</Table.Cell>
|
||||
<Table.Cell>{m.deaths}</Table.Cell>
|
||||
<Table.Cell>{m.kdr}</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
|
||||
{/* Sentinel + Loader */}
|
||||
<div ref={sentinelRef} className="h-10 flex items-center justify-center">
|
||||
{loading && (
|
||||
<span className="text-sm text-gray-500">
|
||||
<LoadingSpinner />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import { Player, Team, InvitedPlayer } from '../types/team'
|
||||
// 🔄 Team laden
|
||||
export async function reloadTeam(teamId: string): Promise<Team | null> {
|
||||
try {
|
||||
console.log("reloadTeam");
|
||||
const res = await fetch(
|
||||
`/api/team/${encodeURIComponent(teamId)}?t=${Date.now()}`,
|
||||
{ cache: 'no-store' }
|
||||
@ -19,8 +18,6 @@ export async function reloadTeam(teamId: string): Promise<Team | null> {
|
||||
const sortByName = <T extends Player>(arr: T[]) =>
|
||||
[...arr].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
console.log("reloadTeam:", data);
|
||||
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
// /app/profile/[steamId]/matches/page.tsx
|
||||
import UserMatchesList from '@/app/components/profile/[steamId]/matches/UserMatchesList'
|
||||
import Card from '@/app/components/Card'
|
||||
|
||||
export default function MatchesPage({ params }: { params: { steamId: string } }) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300 mb-6">
|
||||
Deine Matches
|
||||
</h1>
|
||||
|
||||
<Card maxWidth="auto">
|
||||
<UserMatchesList steamId={params.steamId} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||