This commit is contained in:
Linrador 2025-08-12 12:46:40 +02:00
parent 134955c829
commit 534860a12f
22 changed files with 675 additions and 430 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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