This commit is contained in:
Linrador 2025-09-26 22:08:42 +02:00
parent bcdb2d41d7
commit 25374ef2c0
38 changed files with 1585 additions and 691 deletions

38
package-lock.json generated
View File

@ -86,7 +86,6 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -124,6 +123,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -1578,7 +1578,6 @@
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@ -2069,6 +2068,7 @@
"integrity": "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.19.2"
}
@ -2086,6 +2086,7 @@
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -2183,6 +2184,7 @@
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.30.1",
"@typescript-eslint/types": "8.30.1",
@ -2616,6 +2618,7 @@
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3135,6 +3138,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@ -3284,7 +3288,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@ -3425,6 +3428,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@ -3881,6 +3885,7 @@
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -4055,6 +4060,7 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@ -5403,7 +5409,6 @@
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@ -5832,7 +5837,6 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"peer": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -6020,6 +6024,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.3.0.tgz",
"integrity": "sha512-k0MgP6BsK8cZ73wRjMazl2y2UcXj49ZXLDEgx6BikWuby/CN+nh81qFFI16edgd7xYpe/jj2OZEIwCoqnzz0bQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.3.0",
"@swc/counter": "0.1.3",
@ -6312,8 +6317,7 @@
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
@ -6330,7 +6334,6 @@
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 6"
}
@ -6459,7 +6462,6 @@
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
@ -6746,7 +6748,6 @@
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pretty-format": "^3.8.0"
},
@ -6777,8 +6778,7 @@
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/prisma": {
"version": "6.16.2",
@ -6787,6 +6787,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.16.2",
"@prisma/engines": "6.16.2"
@ -6903,6 +6904,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6922,6 +6924,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -6991,8 +6994,7 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@ -7649,7 +7651,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.2.1",
@ -7706,6 +7709,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7931,6 +7935,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8186,8 +8191,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/yn": {
"version": "3.1.1",

View File

@ -69,6 +69,8 @@
id String @id @default(uuid())
name String @unique
logo String?
logoUpdatedAt DateTime? @default(now())
leaderId String? @unique
createdAt DateTime @default(now())

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -419,7 +419,7 @@ export default function CommunityMatchList({ matchType }: Props) {
<div className="flex flex-col items-center w-1/3">
<Image
src={getTeamLogo(m.teamA.logo)}
alt={m.teamA.name}
alt={m.teamA?.name ?? 'Team A'}
width={56}
height={56}
className="rounded-full border bg-white"
@ -430,7 +430,7 @@ export default function CommunityMatchList({ matchType }: Props) {
<div className="flex flex-col items-center w-1/3">
<Image
src={getTeamLogo(m.teamB.logo)}
alt={m.teamB.name}
alt={m.teamB?.name ?? 'Team B'}
width={56}
height={56}
className="rounded-full border bg-white"
@ -475,7 +475,7 @@ export default function CommunityMatchList({ matchType }: Props) {
onSave={handleCreate}
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
closeButtonColor="blue"
disableCloseButton={!canSave}
hideCloseButton={!canSave}
>
<div className="space-y-4">
{/* Team A */}

View File

@ -105,56 +105,62 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
body: JSON.stringify(body),
})
let detail: any = null
try { detail = await res.clone().json() } catch {}
let json: any = null
try { json = 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)) {
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)
// --- Auswertung: bevorzugt 'results', fallback auf 'invitationIds' ---
let results: { steamId: string; ok: boolean }[] = []
if (json?.results && Array.isArray(json.results)) {
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
} else if (Array.isArray(json?.invitationIds)) {
const okSet = new Set<string>(json.invitationIds)
results = ids.map(id => ({ steamId: id, ok: okSet.has(id) }))
} else {
setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(ids.map(id => [id, 'failed'])) }))
setSentCount(0)
// Keine verwertbaren Details → alles als failed markieren
results = ids.map(id => ({ steamId: id, ok: false }))
}
const nextStatus: Record<string, InviteStatus> = {}
let okCount = 0
for (const r of results) {
const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed'
nextStatus[r.steamId] = st
if (r.ok) okCount++
}
setInvitedStatus(prev => ({ ...prev, ...nextStatus }))
setInvitedIds(ids)
setSentCount(okCount)
setIsSuccess(true)
setSelectedIds([])
onSuccess()
// nur beim Erfolg wenigstens einer Einladung „onSuccess“ und Auto-Close
if (okCount > 0) onSuccess()
} catch (err) {
console.error('Fehler beim Einladen:', err)
setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(selectedIds.map(id => [id, 'failed'])) }))
setInvitedIds(selectedIds)
setSentCount(0)
setIsSuccess(true)
}
finally {
} finally {
setIsInviting(false)
}
}
useEffect(() => {
if (isSuccess) {
const timeout = setTimeout(() => {
if (!isSuccess) return
// nur automatisch schließen, wenn wirklich etwas versendet/ hinzugefügt wurde
if (sentCount > 0) {
const t = setTimeout(() => {
const modalEl = document.getElementById('invite-members-modal')
if (modalEl && (window as any).HSOverlay?.close) {
(window as any).HSOverlay.close(modalEl)
}
onClose()
}, 2000)
return () => clearTimeout(timeout)
return () => clearTimeout(t)
}
}, [isSuccess, onClose])
}, [isSuccess, sentCount, onClose])
useEffect(() => {
setCurrentPage(1)
@ -278,19 +284,19 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
title={directAdd ? 'Spieler hinzufügen' : 'Spieler einladen'}
show={show}
onClose={onClose}
onSave={() => { if (!isInviting) handleInvite() }}
closeButtonColor={isSuccess ? 'teal' : 'blue'}
onSave={!isSuccess ? (() => { if (!isInviting) handleInvite() }) : undefined}
closeButtonColor={!isSuccess ? (isSuccess ? 'teal' : 'blue') : undefined}
closeButtonTitle={
isSuccess
? (directAdd ? 'Spieler hinzugefügt' : 'Einladungen versendet')
: (
!isSuccess
? (
isInviting
? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...')
: (directAdd ? 'Hinzufügen' : 'Einladungen senden')
)
: undefined
}
closeButtonLoading={isInviting}
scrollBody={true}
closeButtonLoading={!isSuccess && isInviting}
scrollBody
>
<p ref={descRef} className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
{directAdd
@ -350,13 +356,19 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
</div>
{isSuccess && (
<div
ref={successRef}
className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg"
>
<div ref={successRef} className="mt-2 px-4 py-2 text-sm rounded-lg border"
style={{ background: sentCount ? '#dcfce7' : '#fee2e2', borderColor: sentCount ? '#bbf7d0' : '#fecaca', color: sentCount ? '#166534' : '#991b1b' }}>
{directAdd
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`}
? (sentCount === 0
? 'Niemand konnte hinzugefügt werden.'
: sentCount === invitedIds.length
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
: `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt, andere fehlgeschlagen.`)
: (sentCount === 0
? 'Keine Einladungen versendet.'
: sentCount === invitedIds.length
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)}
</div>
)}

View File

@ -22,26 +22,22 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (show && team.leader) {
setNewLeaderId(team.leader)
if (show && team.leader?.steamId) {
// ⬅︎ Player -> steamId
setNewLeaderId(team.leader.steamId)
}
}, [show, team.leader])
}, [show, team.leader?.steamId])
const handleLeave = async () => {
if (!steamId) return
setIsSubmitting(true)
try {
const payload = team.leader === steamId
? { steamId, newLeaderId }
: { steamId }
const success = await leaveTeam(steamId, team.leader === steamId ? newLeaderId : undefined)
if (success) {
onSuccess()
onClose()
}
const iAmLeader = team.leader?.steamId === steamId // ⬅︎ Player vergleichen über steamId
const success = await leaveTeam(steamId, iAmLeader ? newLeaderId : undefined)
if (success) {
onSuccess()
onClose()
}
} catch (err) {
console.error('Fehler beim Verlassen:', err)
} finally {
@ -62,28 +58,30 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
<p className="text-sm text-gray-700 dark:text-neutral-300">
Du bist der Teamleader. Bitte wähle ein anderes Mitglied aus, das die Rolle des Leaders übernehmen soll:
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{[
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
]
.filter((player) => player.steamId !== steamId)
.map((player: Player) => (
<MiniCard
key={player.steamId}
steamId={player.steamId}
title={player.name}
avatar={player.avatar}
location={player.location}
selected={newLeaderId === player.steamId}
onSelect={setNewLeaderId}
isLeader={player.steamId === team.leader}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
hideActions={true}
/>
))}
{[
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
]
.filter((player) => player.steamId !== steamId)
.map((player: Player) => (
<MiniCard
key={player.steamId}
steamId={player.steamId}
title={player.name}
avatar={player.avatar}
location={player.location}
selected={newLeaderId === player.steamId}
onSelect={() => setNewLeaderId(player.steamId)}
draggable={false}
rank={player.premierRank}
currentUserSteamId={steamId!}
isLeader={player.steamId === team.leader?.steamId}
teamLeaderSteamId={team.leader?.steamId}
hideActions={true}
/>
))}
</div>
</Modal>
)

View File

@ -277,6 +277,19 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
// → Welche Seite ist "mein Team"?
const mySteamId = session?.user?.steamId
const mySide: 'A' | 'B' | null = mySteamId
? (teamAPlayers.some(p => p.user.steamId === mySteamId) ? 'A'
: teamBPlayers.some(p => p.user.steamId === mySteamId) ? 'B'
: null)
: null
const sideLabel = (side: 'A' | 'B') => {
if (!mySide) return side === 'A' ? 'Team A' : 'Team B'
return side === mySide ? 'Mein Team' : 'Gegnerisches Team'
}
const currentMapKey = normalizeMapKey(match.map)
@ -528,7 +541,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<Button color="gray" variant="outline"> Zurück</Button>
</Link>
{isAdmin && (
{isAdmin && match.matchType === 'community' && (
<div className="flex gap-2">
<Button
onClick={() => setEditMetaOpen(true)}
@ -622,60 +635,72 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
</div>
{/* Teams + Score */}
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:gap-6 px-1">
{/* Team A */}
<div className="min-w-0">
<div className="flex items-center gap-3">
{match.teamA?.logo && (
<img
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamA.name ?? 'Team A'}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
/>
)}
{(() => {
const isCommunity = match.matchType === 'community'
const sideTitleCls = isCommunity
? 'text-xs text-white/80'
: 'text-lg font-bold text-white sm:text-xl'
const teamNameCls = isCommunity
? 'truncate text-lg font-semibold text-white sm:text-xl'
: 'truncate text-sm text-white/80'
return (
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:gap-6 px-1">
{/* Team A */}
<div className="min-w-0">
<div className="text-xs text-white/80">Team A</div>
<div className="truncate text-lg font-semibold text-white sm:text-xl">
{match.teamA?.name ?? 'Unbekannt'}
<div className="flex items-center gap-3">
{isCommunity && match.teamA?.logo && (
<img
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamA.name ?? 'Team A'}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
/>
)}
<div className="min-w-0">
<div className={sideTitleCls}>{sideLabel('A')}</div>
<div className={teamNameCls}>
{match.teamA?.name ?? 'Unbekannt'}
</div>
</div>
</div>
</div>
{/* Score */}
<div className="text-center">
<div className="text-xs text-white/80">Score</div>
<div className="mx-auto mt-1 inline-flex items-center gap-3 rounded-lg bg-black/30 px-3 py-1.5 ring-1 ring-white/10">
<span className="animate-[pop_350ms_ease-out] text-2xl font-bold text-white drop-shadow-sm sm:text-3xl">
{match.scoreA ?? 0}
</span>
<span className="font-semibold text-white/60">:</span>
<span className="animate-[pop_350ms_ease-out_120ms] text-2xl font-bold text-white drop-shadow-sm sm:text-3xl">
{match.scoreB ?? 0}
</span>
</div>
<div className="mt-2 text-xs text-white/75">{`on ${mapLabel}`}</div>
</div>
{/* Team B */}
<div className="min-w-0 justify-self-end">
<div className="flex items-center justify-end gap-3">
<div className="min-w-0 text-right">
<div className={sideTitleCls}>{sideLabel('B')}</div>
<div className={teamNameCls}>
{match.teamB?.name ?? 'Unbekannt'}
</div>
</div>
{isCommunity && match.teamB?.logo && (
<img
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamB.name ?? 'Team B'}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
/>
)}
</div>
</div>
</div>
</div>
{/* Score */}
<div className="text-center">
<div className="text-xs text-white/80">Score</div>
<div className="mx-auto mt-1 inline-flex items-center gap-3 rounded-lg bg-black/30 px-3 py-1.5 ring-1 ring-white/10">
<span className="animate-[pop_350ms_ease-out] text-2xl font-bold text-white drop-shadow-sm sm:text-3xl">
{match.scoreA ?? 0}
</span>
<span className="font-semibold text-white/60">:</span>
<span className="animate-[pop_350ms_ease-out_120ms] text-2xl font-bold text-white drop-shadow-sm sm:text-3xl">
{match.scoreB ?? 0}
</span>
</div>
<div className="mt-2 text-xs text-white/75">{`on ${mapLabel}`}</div>
</div>
{/* Team B */}
<div className="min-w-0 justify-self-end">
<div className="flex items-center justify-end gap-3">
<div className="min-w-0 text-right">
<div className="text-xs text-white/80">Team B</div>
<div className="truncate text-lg font-semibold text-white sm:text-xl">
{match.teamB?.name ?? 'Unbekannt'}
</div>
</div>
{match.teamB?.logo && (
<img
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamB.name ?? 'Team B'}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
/>
)}
</div>
</div>
</div>
)
})()}
{/* Map-Tabs bei Serie */}
{bestOf > 1 && (
@ -713,7 +738,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
)}
{/* Teams / Tabellen */}
<div className="mt-4 space-y-10 border-t pt-4">
<div className="mt-4 space-y-10 pt-4">
{/* Team A */}
<div>
<div className="mb-2 flex items-center justify-between">
@ -730,7 +755,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
/>
</span>
)}
{match.teamA?.name ?? 'Team A'}
{match.teamA?.name || sideLabel('A')}
</h2>
{showEditA && (
@ -774,7 +799,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
/>
</span>
)}
{match.teamB?.name ?? 'Team B'}
{match.teamB?.name || sideLabel('B')}
</h2>
{showEditB && (

View File

@ -130,7 +130,7 @@ export default function MiniCard({
}`}>
<span className="text-gray-800 dark:text-neutral-200 font-semibold text-sm mb-1 truncate px-2 max-w-[90%] text-center">{title}</span>
<div className="pointer-events-auto" onPointerDown={stopDrag}>
<Button className="max-w-[100px]" title={isInvite ? 'Einladung zurückziehen' : 'Kicken'} color="red" variant="solid" size={isInvite ? 'xs' : `sm`} onClick={isInvite ? handleRevokeClick : handleKickClick} />
<Button className="max-w-[100px]" title={isInvite ? 'Zurückziehen' : 'Kicken'} color="red" variant="solid" size="sm" onClick={isInvite ? handleRevokeClick : handleKickClick} />
</div>
{typeof onPromote === 'function' && (
<div className="pointer-events-auto" onPointerDown={stopDrag}>

View File

@ -23,31 +23,52 @@ type Props = {
/* ---------- kleine Helper ---------- */
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
return true
}
const eqTeam = (a?: Team | null, b?: Team | null) => {
if (!a && !b) return true
if (!a || !b) return false
if (a.id !== b.id) return false
if ((a.name ?? '') !== (b.name ?? '')) return false
if ((a.logo ?? '') !== (b.logo ?? '')) return false
const la = a.leader?.steamId ?? (a as any).leaderId ?? null
const lb = b.leader?.steamId ?? (b as any).leaderId ?? null
if (la !== lb) return false
// >>> hier neu:
const va = (a as any).logoUpdatedAt ?? (a as any).updatedAt ?? null
const vb = (b as any).logoUpdatedAt ?? (b as any).updatedAt ?? null
if ((va ? String(va) : '') !== (vb ? String(vb) : '')) return false
return (
eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
)
}
const eqInviteList = (a: Invitation[] = [], b: Invitation[] = []) => {
if (a.length !== b.length) return false
const A = a.map((x) => x.id).sort().join(',')
const B = b.map((x) => x.id).sort().join(',')
return A === B
}
const logoVer = (t: Team, vmap: Record<string, number>) =>
vmap[t.id] ?? (t as any).logoUpdatedAt ? new Date((t as any).logoUpdatedAt).getTime()
: (t as any).updatedAt ? new Date((t as any).updatedAt).getTime()
: 0;
async function loadTeamFull(teamId: string) {
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
if (!res.ok) return null
return await res.json()
}
/* ---------- Komponente ---------- */
function TeamCardComponent(
@ -59,6 +80,8 @@ function TeamCardComponent(
const { lastEvent } = useSSEStore()
const [initialLoading, setInitialLoading] = useState(true)
const [logoVersionByTeam, setLogoVersionByTeam] = useState<Record<string, number>>({});
// Alle Teams, in denen ich Mitglied bin (Leader/aktiv/inaktiv)
const [myTeams, setMyTeams] = useState<Team[]>([])
@ -144,6 +167,14 @@ function TeamCardComponent(
setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all))
}
}
if (selectedTeam) {
const full = await loadTeamFull(selectedTeam.id)
if (full) {
setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t))
setSelectedTeam(full)
}
}
} finally {
softReloadInFlight.current = false
}
@ -167,16 +198,18 @@ function TeamCardComponent(
lastHandledRef.current = key
// Logo-Event: nur lokal updaten, KEIN /api/user-Reload
if (type === 'team-logo-updated' && payload?.teamId && payload?.filename) {
setMyTeams(prev => prev.map(t => (t.id === payload.teamId ? { ...t, logo: payload.filename } as Team : t)))
if (selectedTeam?.id === payload.teamId) {
setSelectedTeam(prev =>
prev && prev.id === payload.teamId
? { ...prev, logo: payload.filename }
: prev
)
if (type === 'team-logo-updated' && payload?.teamId) {
// Filename bleibt oft gleich -> trotzdem Teams updaten (ok)
if (payload?.filename) {
setMyTeams(prev => prev.map(t => t.id === payload.teamId ? { ...t, logo: payload.filename } : t));
if (selectedTeam?.id === payload.teamId) {
setSelectedTeam(prev => (prev ? { ...prev, logo: payload.filename } : prev));
}
}
return
if (payload?.version) {
setLogoVersionByTeam(prev => ({ ...prev, [payload.teamId]: payload.version }));
}
return;
}
// Invite revoked: Liste anpassen, dann gedrosseltes Reload
@ -203,6 +236,20 @@ function TeamCardComponent(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEvent, myTeams.length]) // bewusst schlanke Dependencies
useEffect(() => {
if (!selectedTeam) return
if (Array.isArray(selectedTeam.invitedPlayers)) return // schon voll
(async () => {
const full = await loadTeamFull(selectedTeam.id)
if (!full) return
// in myTeams ersetzen …
setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t))
// … und als selectedTeam setzen
setSelectedTeam(full)
})()
}, [selectedTeam?.id])
/* ------- Render-Zweige ------- */
if (initialLoading) return <LoadingSpinner />
@ -310,10 +357,14 @@ function TeamCardComponent(
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
key={`${team.logo ?? 'fallback'}-${logoVer(team, logoVersionByTeam)}`}
src={
team.logo
? `/assets/img/logos/${team.logo}${logoVer(team, logoVersionByTeam) ? `?v=${logoVer(team, logoVersionByTeam)}` : ''}`
: `/assets/img/logos/cs2.webp`
}
alt={team.name ?? 'Teamlogo'}
className="h-12 w-12 rounded-full border object-cover
border-gray-200 dark:border-neutral-600"
className="h-12 w-12 rounded-full border object-cover border-gray-200 dark:border-neutral-600"
/>
<div className="flex flex-col">
<span className="truncate font-medium text-gray-800 dark:text-neutral-200">

View File

@ -10,11 +10,11 @@ import SortableMiniCard from './SortableMiniCard'
import LeaveTeamModal from './LeaveTeamModal'
import InvitePlayersModal from './InvitePlayersModal'
import Modal from './Modal'
import { Player } from '../../../types/team'
import type { Player, InvitedPlayer } from '@/types/team'
import { AnimatePresence, motion } from 'framer-motion'
import { leaveTeam, reloadTeam, renameTeam } from '@/lib/sse-actions'
import Button from './Button'
import Image from 'next/image'
import NextImage from 'next/image'
import TeamPremierRankBadge from './TeamPremierRankBadge'
import Link from 'next/link'
import { Team } from '../../../types/team'
@ -41,8 +41,6 @@ type Props = {
adminMode?: boolean
}
type InvitedPlayer = Player & { invitationId?: string }
export default function TeamMemberView({
team: teamProp,
activeDragItem,
@ -86,7 +84,13 @@ export default function TeamMemberView({
const [saveSuccess, setSaveSuccess] = useState(false)
// Cache-Busting fürs Logo
const [logoVersion, setLogoVersion] = useState<number | null>(null)
const initialLogoVersion =
(team as any).logoUpdatedAt
? new Date((team as any).logoUpdatedAt).getTime()
: (team as any).updatedAt
? new Date((team as any).updatedAt).getTime()
: 0;
const [logoVersion, setLogoVersion] = useState<number | null>(initialLogoVersion);
// Upload-Progress
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
@ -336,6 +340,133 @@ export default function TeamMemberView({
}
}
type DownscaleOpts = {
size?: number; // Zielkante (px)
quality?: number; // 0..1
mime?: string; // Wunschformat, default 'image/webp'
square?: boolean; // center-crop auf Quadrat
};
async function canEncode(mime: string): Promise<boolean> {
try {
// OffscreenCanvas hat die zuverlässigste Blob-API
if ('OffscreenCanvas' in window) {
const c = new OffscreenCanvas(2, 2);
const b = await (c as any).convertToBlob?.({ type: mime, quality: 0.8 });
return !!b;
}
const c = document.createElement('canvas');
c.width = 2; c.height = 2;
const url = c.toDataURL(mime);
return typeof url === 'string' && url.startsWith(`data:${mime}`);
} catch {
return false;
}
}
async function downscaleImage(file: File, opts: DownscaleOpts = {}): Promise<Blob> {
const {
size = 512,
quality = 0.85,
mime: wantedMime = 'image/webp',
square = true,
} = opts;
// 1) Bild laden (ImageBitmap bevorzugt)
let url: string | null = null;
let img: ImageBitmap | HTMLImageElement;
const useBitmap = 'createImageBitmap' in window;
if (useBitmap) {
try {
img = await (createImageBitmap as any)(file, { imageOrientation: 'from-image' });
} catch {
url = URL.createObjectURL(file);
img = await new Promise<HTMLImageElement>((res, rej) => {
const im = new window.Image();
im.onload = () => res(im);
im.onerror = rej;
im.src = url!;
});
}
} else {
url = URL.createObjectURL(file);
img = await new Promise<HTMLImageElement>((res, rej) => {
const im = new window.Image();
im.onload = () => res(im);
im.onerror = rej;
im.src = url!;
});
}
const srcW = (img as any).width as number;
const srcH = (img as any).height as number;
if (!srcW || !srcH) {
if (url) URL.revokeObjectURL(url);
if ('close' in (img as any)) try { (img as ImageBitmap).close(); } catch {}
throw new Error('Invalid image dimensions');
}
// 2) Zielgröße + optionaler Center-Crop
let sx = 0, sy = 0, sw = srcW, sh = srcH;
if (square) {
const side = Math.min(srcW, srcH);
sx = Math.max(0, Math.floor((srcW - side) / 2));
sy = Math.max(0, Math.floor((srcH - side) / 2));
sw = side; sh = side;
}
const scale = Math.min(size / sw, size / sh, 1);
const dw = Math.max(1, Math.round(sw * scale));
const dh = Math.max(1, Math.round(sh * scale));
// 3) Canvas wählen (Offscreen bevorzugt)
const offscreen = 'OffscreenCanvas' in window;
let blob: Blob | null = null;
if (offscreen) {
const c = new OffscreenCanvas(dw, dh);
const ctx = c.getContext('2d', { alpha: true })!;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img as any, sx, sy, sw, sh, 0, 0, dw, dh);
// 4) Format mit Fallbacks
const canWebp = await canEncode('image/webp');
const canJpeg = await canEncode('image/jpeg');
const targetMime =
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
canWebp ? 'image/webp' :
canJpeg ? 'image/jpeg' : 'image/png';
blob = await (c as any).convertToBlob({ type: targetMime, quality: targetMime === 'image/png' ? undefined : quality });
} else {
const c = document.createElement('canvas');
c.width = dw; c.height = dh;
const ctx = c.getContext('2d')!;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img as any, sx, sy, sw, sh, 0, 0, dw, dh);
const canWebp = await canEncode('image/webp');
const canJpeg = await canEncode('image/jpeg');
const targetMime =
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
canWebp ? 'image/webp' :
canJpeg ? 'image/jpeg' : 'image/png';
blob = await new Promise<Blob | null>((res) =>
c.toBlob(b => res(b), targetMime, targetMime === 'image/png' ? undefined : quality)
);
}
// Cleanup
if (url) URL.revokeObjectURL(url);
if ('close' in (img as any)) { try { (img as ImageBitmap).close(); } catch {} }
if (!blob) throw new Error('Canvas encoding failed (toBlob returned null)');
return blob;
}
// Upload mit Progress via XHR setzt filename/version direkt, kein Reload nötig
async function uploadTeamLogo(file: File) {
return new Promise<void>((resolve, reject) => {
@ -430,7 +561,7 @@ export default function TeamMemberView({
}}
title={isUploadingLogo ? "Upload läuft…" : (canManage ? "Logo hochladen" : undefined)}
>
<Image
<NextImage
key={`${team.logo ?? 'fallback'}-${logoVersion ?? 0}`}
src={
team.logo
@ -443,6 +574,7 @@ export default function TeamMemberView({
quality={75}
className={`object-cover ${isUploadingLogo ? 'opacity-70' : ''}`}
priority={false}
unoptimized
/>
{/* Hover-Overlay nur, wenn klickbar */}
@ -486,20 +618,24 @@ export default function TeamMemberView({
className="hidden"
disabled={!isClickable}
onChange={async (e) => {
if (isUploadingLogo) return
const file = e.target.files?.[0]
if (!file) return
if (isUploadingLogo) return;
const file = e.target.files?.[0];
if (!file) return;
try {
await uploadTeamLogo(file)
const blob = await downscaleImage(file, { size: 512, quality: 0.85, mime: 'image/webp', square: true });
// Dateiendung passend zum MIME bestimmen (nur kosmetisch)
const mime = blob.type || 'image/webp';
const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp';
const processed = new File([blob], `${team!.id}.${ext}`, { type: mime });
await uploadTeamLogo(processed);
} catch (err) {
console.error('Fehler beim Hochladen des Logos:', err)
alert('Fehler beim Hochladen des Logos.')
console.error('Fehler beim Hochladen des Logos:', err);
alert('Fehler beim Hochladen des Logos.');
} finally {
setTimeout(() => {
setIsUploadingLogo(false)
setUploadPct(0)
}, 300)
e.currentTarget.value = ''
setTimeout(() => { setIsUploadingLogo(false); setUploadPct(0); }, 300);
e.currentTarget.value = '';
}
}}
/>
@ -747,6 +883,7 @@ export default function TeamMemberView({
selected={false}
onSelect={() => {}}
draggable={false}
rank={promoteCandidate.premierRank}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader?.steamId}
hideActions
@ -778,6 +915,7 @@ export default function TeamMemberView({
selected={false}
onSelect={() => {}}
draggable={false}
rank={kickCandidate.premierRank}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader?.steamId}
hideActions
@ -805,7 +943,7 @@ export default function TeamMemberView({
setShowDeleteModal(false)
window.location.href = '/team'
}}
closeButtonTitle="Löschen"
closeButtonTitle="Team löschen"
closeButtonColor="red"
>
<p className="text-sm text-gray-700 dark:text-neutral-300">

View File

@ -7,7 +7,7 @@ import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
import { usePresenceStore } from '@/lib/usePresenceStore'
import { useTelemetryStore } from '@/lib/useTelemetryStore'
import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
import TelemetryBanner from './GameBanner'
import GameBanner from './GameBanner'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import { useSSEStore } from '@/lib/useSSEStore'
@ -246,6 +246,10 @@ export default function TelemetrySocket() {
}
}, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, hideOverlay, mySteamId])
// Wenn die API { matchId: null } liefert → KEIN Banner
if (!currentMatchId) return null
// ----- banner logic
const myId = mySteamId ? String(mySteamId) : null
const roster =
@ -297,11 +301,11 @@ export default function TelemetrySocket() {
}
const variant: 'connected' | 'disconnected' = iAmOnline ? 'connected' : 'disconnected'
const visible = iAmExpected // 👈 Banner nur für zugeordnete Spieler
const visible = iAmExpected && !!currentMatchId
const zIndex = iAmOnline ? 9998 : 9999
const bannerEl = (
<TelemetryBanner
<GameBanner
variant={variant}
visible={visible}
zIndex={zIndex}

View File

@ -15,6 +15,21 @@ type ApiStats = {
}>
}
/* ───────── helpers ───────── */
const fmtInt = (n: number) => new Intl.NumberFormat('de-DE').format(n)
const fmtDateTime = (iso: string) =>
new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso))
const kdRaw = (k: number, d: number) => (d <= 0 ? Infinity : k / d)
const kdLabel = (k: number, d: number) => (d <= 0 ? '∞' : (k / d).toFixed(2))
const kdTint = (k: number, d: number) => {
const v = kdRaw(k, d)
if (v === Infinity || v >= 1.3) return 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
if (v >= 1.0) return 'text-blue-400 bg-blue-500/10 ring-blue-500/20'
if (v >= 0.8) return 'text-amber-400 bg-amber-500/10 ring-amber-500/20'
return 'text-rose-400 bg-rose-500/10 ring-rose-500/20'
}
async function getStats(steamId: string): Promise<ApiStats | null> {
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store' })
@ -22,6 +37,100 @@ async function getStats(steamId: string): Promise<ApiStats | null> {
return res.json()
}
/* ───────── UI atoms ───────── */
function Pill({
label,
value,
icon,
className = '',
}: {
label: string
value: string
icon?: React.ReactNode
className?: string
}) {
return (
<div
className={[
'flex items-center justify-between rounded-xl border border-white/5 bg-neutral-900/40',
'px-4 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,.04)] backdrop-blur',
className,
].join(' ')}
>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-white/60">{label}</div>
<div className="mt-0.5 text-2xl font-semibold text-white">{value}</div>
</div>
{icon && (
<div className="ml-3 grid h-10 w-10 place-items-center rounded-lg ring-1 ring-inset ring-white/10 bg-white/5 text-white/80">
{icon}
</div>
)}
</div>
)
}
function CtaCard({
title,
desc,
href,
tone = 'blue',
}: {
title: string
desc: string
href: string
tone?: 'blue' | 'neutral'
}) {
const button =
tone === 'blue'
? 'bg-blue-600 hover:bg-blue-700 ring-blue-500/30'
: 'bg-neutral-700 hover:bg-neutral-600 ring-white/10'
return (
<Card>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-1 text-sm text-white/60">{desc}</p>
</div>
<Link
href={href}
className={[
'shrink-0 rounded-lg px-3 py-1.5 text-sm font-medium text-white ring-1 ring-inset transition',
button,
].join(' ')}
>
Öffnen
</Link>
</div>
</Card>
)
}
/** sehr kleine Inline-Sparkline (ohne Lib) */
function Sparkline({ values }: { values: number[] }) {
const W = 160
const H = 40
const pad = 4
const n = Math.max(1, values.length)
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const range = Math.max(0.05, max - min)
const step = (W - pad * 2) / Math.max(1, n - 1)
const pts = values
.map((v, i) => {
const x = pad + i * step
const y = H - pad - ((v - min) / range) * (H - pad * 2)
return `${x},${y}`
})
.join(' ')
return (
<svg viewBox={`0 0 ${W} ${H}`} className="h-10 w-40">
<polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.9" strokeWidth="2" />
</svg>
)
}
/* ───────── page ───────── */
export default async function Profile({ steamId }: Props) {
const data = await getStats(steamId)
const matches = data?.stats ?? []
@ -31,117 +140,181 @@ export default async function Profile({ steamId }: Props) {
const deaths = matches.reduce((s, m) => s + (m.deaths ?? 0), 0)
const assists = matches.reduce((s, m) => s + (m.assists ?? 0), 0)
const dmg = matches.reduce((s, m) => s + (m.totalDamage ?? 0), 0)
const kd = deaths === 0 ? '∞' : (kills / Math.max(1, deaths)).toFixed(2)
const kdTxt = kdLabel(kills, deaths)
// letzten 10 für Sparkline (K/D)
const last10 = matches.slice(0, 10).reverse()
const kdSeries = last10.map((m) => (m.deaths > 0 ? (m.kills ?? 0) / m.deaths : 2)) // 2 als „gut“ bei 0 Toden
const lastKD = kdSeries.length ? kdSeries[kdSeries.length - 1] : 0
const prevKD =
kdSeries.length > 1 ? kdSeries.slice(0, -1).reduce((a, b) => a + b, 0) / (kdSeries.length - 1) : lastKD
const deltaKD = lastKD - prevKD
const recent = matches.slice(0, 5)
return (
<div className="space-y-6">
{/* Quick KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-neutral-400">Matches</div>
<div className="mt-1 text-2xl font-bold">{games}</div>
</div>
<div className="rounded-lg bg-blue-500/15 p-2 ring-1 ring-blue-400/20">🎮</div>
</div>
</Card>
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-neutral-400">Kills</div>
<div className="mt-1 text-2xl font-bold">{kills}</div>
</div>
<div className="rounded-lg bg-emerald-500/15 p-2 ring-1 ring-emerald-400/20">🎯</div>
</div>
</Card>
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-neutral-400">K/D</div>
<div className="mt-1 text-2xl font-bold">{kd}</div>
</div>
<div className="rounded-lg bg-fuchsia-500/15 p-2 ring-1 ring-fuchsia-400/20"></div>
</div>
</Card>
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-neutral-400">Damage (sum)</div>
<div className="mt-1 text-2xl font-bold">{Math.round(dmg)}</div>
</div>
<div className="rounded-lg bg-amber-500/15 p-2 ring-1 ring-amber-400/20">🔥</div>
</div>
</Card>
</div>
{/* Callouts to subpages */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Statistiken</h3>
<p className="mt-1 text-sm text-neutral-400">
Charts, Verläufe und Map-Auswertungen.
</p>
</div>
<Link
href={`/profile/${steamId}/stats`}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Öffnen
</Link>
</div>
</Card>
<Card>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Matches</h3>
<p className="mt-1 text-sm text-neutral-400">
Alle Spiele in einer übersichtlichen Liste.
</p>
</div>
<Link
href={`/profile/${steamId}/matches`}
className="rounded-lg bg-neutral-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-600"
>
Öffnen
</Link>
</div>
</Card>
</div>
{/* Letzte 5 Matches */}
<div className="space-y-7">
{/* Performance Panel */}
<Card>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-base font-semibold">Letzte Matches</h3>
<Link href={`/profile/${steamId}/matches`} className="text-sm text-blue-400 hover:underline">
<div className="grid items-center gap-4 md:grid-cols-[1fr_auto_auto]">
<div>
<div className="text-[11px] uppercase tracking-wide text-white/60">Aktuelle Form</div>
<div className="mt-1 flex items-center gap-2">
<div
className={[
'rounded-md px-2 py-1 text-sm font-semibold ring-1 ring-inset',
kdTint(kills, deaths),
].join(' ')}
title="Gesamt K/D"
>
Ø K/D&nbsp;&nbsp;{kdTxt}
</div>
<div
className={[
'rounded-md px-2 py-1 text-xs ring-1 ring-inset',
deltaKD >= 0
? 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
: 'text-rose-400 bg-rose-500/10 ring-rose-500/20',
].join(' ')}
title="Veränderung zur vorherigen Form"
>
{deltaKD >= 0 ? '▲' : '▼'} {Math.abs(deltaKD).toFixed(2)}
</div>
</div>
</div>
<div className="justify-self-center text-blue-400">
<Sparkline values={kdSeries} />
</div>
<div className="justify-self-end grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg bg-white/5 px-3 py-2 ring-1 ring-white/10">
<div className="text-[11px] uppercase tracking-wide text-white/60">Assists</div>
<div className="mt-0.5 font-semibold text-white">{fmtInt(assists)}</div>
</div>
<div className="rounded-lg bg-white/5 px-3 py-2 ring-1 ring-white/10">
<div className="text-[11px] uppercase tracking-wide text-white/60">Damage</div>
<div className="mt-0.5 font-semibold text-white">{fmtInt(Math.round(dmg))}</div>
</div>
</div>
</div>
</Card>
{/* KPIs */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Pill
label="Matches"
value={fmtInt(games)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
}
/>
<Pill
label="Kills"
value={fmtInt(kills)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M12 19V5M5 12h14" />
</svg>
}
/>
<Pill
label="K/D"
value={kdTxt}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 3v18M21 21H9" />
</svg>
}
/>
<Pill
label="Damage (Summe)"
value={fmtInt(Math.round(dmg))}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M6 20 18 4M14 20h6v-6" />
</svg>
}
/>
</div>
{/* CTAs */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CtaCard
title="Statistiken"
desc="Charts, Verläufe und Map-Auswertungen."
href={`/profile/${steamId}/stats`}
tone="blue"
/>
<CtaCard
title="Matches"
desc="Alle Spiele in einer übersichtlichen Liste."
href={`/profile/${steamId}/matches`}
tone="neutral"
/>
</div>
{/* Letzte Matches */}
<Card>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-white">Letzte Matches</h3>
<Link
href={`/profile/${steamId}/matches`}
className="text-sm font-medium text-blue-400 hover:text-blue-300 hover:underline"
>
Alle ansehen
</Link>
</div>
{matches.slice(0, 5).length === 0 ? (
<p className="text-sm text-neutral-400">Noch keine Daten.</p>
{recent.length === 0 ? (
<div className="rounded-lg border border-dashed border-white/10 p-10 text-center">
<p className="text-sm text-white/60">Noch keine Daten.</p>
</div>
) : (
<ul className="divide-y divide-neutral-800/70">
{matches.slice(0, 5).map((m, i) => (
<li key={i} className="py-2 flex items-center justify-between">
<div className="min-w-0">
<div className="text-xs text-neutral-500">{new Date(m.date).toLocaleString()}</div>
<div className="text-sm">
{m.matchType ? <span className="mr-2 text-neutral-400">{m.matchType} </span> : null}
<span>K {m.kills} / D {m.deaths} {Number.isFinite(m.assists) ? `/ A ${m.assists}` : ''}</span>
<ul className="divide-y divide-white/5">
{recent.map((m, i) => {
const kdTxtRow = kdLabel(m.kills ?? 0, m.deaths ?? 0)
const kdCls = kdTint(m.kills ?? 0, m.deaths ?? 0)
return (
<li key={i} className="py-3.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs text-white/50">{fmtDateTime(m.date)}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-sm">
{m.matchType && (
<span className="rounded-md bg-white/5 px-2 py-0.5 text-[12px] font-medium text-white/80 ring-1 ring-inset ring-white/10">
{m.matchType}
</span>
)}
<span className="text-white">
<span className="text-white/70">K</span> {m.kills}&nbsp;&nbsp;
<span className="text-white/70">D</span> {m.deaths}
{Number.isFinite(m.assists) ? (
<>
&nbsp;&nbsp;<span className="text-white/70">A</span> {m.assists}
</>
) : null}
</span>
</div>
</div>
<div className="shrink-0">
<span
className={[
'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset',
kdCls,
].join(' ')}
title="Kill/Death Ratio"
>
K/D&nbsp;{kdTxtRow}
</span>
</div>
</div>
</div>
<div className="shrink-0 text-right">
<div className="rounded-md bg-neutral-800 px-2 py-0.5 text-xs">
K/D&nbsp;
{m.deaths === 0 ? '∞' : ((m.kills ?? 0) / Math.max(1, m.deaths ?? 0)).toFixed(2)}
</div>
</div>
</li>
))}
</li>
)
})}
</ul>
)}
</Card>

View File

@ -1,6 +1,8 @@
// /src/app/profile/[steamId]/matches/MatchesList.tsx
import Link from 'next/link'
import Card from '../../../Card'
import PremierRankBadge from '../../../PremierRankBadge'
import CompRankBadge from '../../../CompRankBadge'
import { MAP_OPTIONS } from '@/lib/mapOptions'
type Props = { steamId: string }
@ -17,30 +19,119 @@ type MatchRow = {
roundCount?: number | null
scoreA?: number | null
scoreB?: number | null
score?: string | null
team?: 'A' | 'B' | null
result?: 'win' | 'loss' | 'draw' | null
matchType?: string | null
matchType?: 'premier' | 'competitive' | string | null
rankNew?: number | null
rankChange?: number | null
aim?: number | string | null
}
function labelForMap(key: string) {
const k = key.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
return MAP_OPTIONS.find(o => o.key === k)?.label ?? k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
/* helpers (unverändert) */
const normKey = (raw: string) => (raw || '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
const labelForMap = (raw: string) => {
const k = normKey(raw)
return (
MAP_OPTIONS.find(o => o.key === k)?.label ??
k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
)
}
function kdr(k: number, d: number) {
if (!Number.isFinite(k) || !Number.isFinite(d)) return '-'
return d === 0 ? '∞' : (k / d).toFixed(2)
const iconForMap = (raw: string) => {
const k = normKey(raw)
const known = MAP_OPTIONS.some(o => o.key === k || o.key === `de_${k}`)
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg`
}
const bgForMap = (raw: string) => {
const k = normKey(raw)
const opt: any = MAP_OPTIONS.find(o => o.key === k)
if (opt?.images?.length) return String(opt.images[0])
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return `/assets/img/maps/${withPrefix}.webp`
}
const fmtDateTime = (iso: string) =>
new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso))
const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
const kdrLabel = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number' ? (d === 0 ? '∞' : (k / d).toFixed(2)) : '-'
const parseScoreString = (raw?: string | null): [number | null, number | null] => {
if (!raw) return [null, null]
const [a, b] = raw.split(':').map(s => Number(s.trim()))
return [Number.isFinite(a) ? a : null, Number.isFinite(b) ? b : null]
}
const scoreOf = (m: MatchRow): [number | null, number | null] => {
// zuerst der String (historisch CT:T)
const [sa, sb] = parseScoreString(m.score)
if (sa !== null && sb !== null) return [sa, sb]
// Fallback auf neue Felder
const a = isFiniteNum(m.scoreA) ? m.scoreA! : null
const b = isFiniteNum(m.scoreB) ? m.scoreB! : null
return [a, b]
}
/** Versuch, die eigene Seite (A|B) herzuleiten */
const inferOwnSide = (m: MatchRow, a: number | null, b: number | null): 'A' | 'B' | null => {
if (m.team === 'A' || m.team === 'B') return m.team
if (a === null || b === null) return null
// 1) Explizites Resultat
if (m.result === 'win') return a > b ? 'A' : a < b ? 'B' : null
if (m.result === 'loss') return a < b ? 'A' : a > b ? 'B' : null
if (m.result === 'draw') return null
// 2) Rank-Änderung als Signal
if (typeof m.rankChange === 'number') {
if (m.rankChange > 0) return a > b ? 'A' : a < b ? 'B' : null
if (m.rankChange < 0) return a < b ? 'A' : a > b ? 'B' : null
}
return null
}
/** Score so anordnen, dass eigene Punkte links stehen */
const normalizeScore = (m: MatchRow, a: number | null, b: number | null): [number | null, number | null] => {
if (a === null || b === null) return [a, b]
const side = inferOwnSide(m, a, b)
if (side === 'A') return [a, b]
if (side === 'B') return [b, a]
return [a, b] // nicht bestimmbar → unverändert
}
const computeResultFromOwn = (own: number | null, opp: number | null): 'win' | 'loss' | 'draw' | 'match' => {
if (own === null || opp === null) return 'match'
if (own > opp) return 'win'
if (own < opp) return 'loss'
return 'draw'
}
/* kleine Pill */
function Pill({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-black/30 px-2 py-0.5 text-xs text-neutral-200">
<span className="text-white/70">{label}</span>
<span className="tabular-nums">{value}</span>
</span>
)
}
/* data */
async function getData(steamId: string) {
// greift auf die gleiche API wie die Stats-Seite zu
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store' })
const res = await fetch(`${base}/api/user/${steamId}/matches?types=premier,competitive`, { cache: 'no-store' })
if (!res.ok) return { matches: [] as MatchRow[] }
const json = await res.json()
return { matches: (json?.stats ?? []) as MatchRow[] }
const matches = (Array.isArray(json) ? json : json.items) as MatchRow[]
matches.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
return { matches }
}
/* component */
export default async function MatchesList({ steamId }: Props) {
const { matches } = await getData(steamId)
@ -56,42 +147,120 @@ export default async function MatchesList({ steamId }: Props) {
<div className="space-y-3">
{matches.map((m, idx) => {
const linkId = String(m.matchId ?? m.id ?? '')
const href = linkId ? `/match/${linkId}` : undefined
const href = linkId ? `/match-details/${linkId}` : undefined
const ADR =
Number.isFinite(m.totalDamage) && Number.isFinite(m.roundCount) && (m.roundCount ?? 0) > 0
isFiniteNum(m.totalDamage) && isFiniteNum(m.roundCount) && (m.roundCount ?? 0) > 0
? ((m.totalDamage as number) / (m.roundCount as number)).toFixed(1)
: '-'
const resultPill =
m.result === 'win' ? 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30' :
m.result === 'loss' ? 'bg-red-500/15 text-red-300 ring-1 ring-red-500/30' :
'bg-neutral-500/15 text-neutral-300 ring-1 ring-neutral-500/30'
const [scA, scB] = scoreOf(m)
const [ownScore, oppScore] = normalizeScore(m, scA, scB)
const result = m.result ?? computeResultFromOwn(ownScore, oppScore)
const content = (
<div className="flex items-center justify-between gap-4 rounded-lg border border-neutral-700/60 bg-neutral-900/40 p-3 hover:bg-neutral-900/70 transition">
<div className="min-w-0">
<div className="text-xs text-neutral-400">{new Date(m.date).toLocaleString()}</div>
<div className="truncate text-sm font-medium">
{labelForMap(m.map)}
{m.matchType ? <span className="ml-2 text-xs text-neutral-400"> {m.matchType}</span> : null}
const rowTint =
result === 'win'
? 'bg-emerald-500/[0.04] hover:bg-emerald-500/[0.07] border-emerald-700/40 hover:border-emerald-600/50'
: result === 'loss'
? 'bg-red-500/[0.04] hover:bg-red-500/[0.07] border-red-700/40 hover:border-red-600/50'
: result === 'draw'
? 'bg-amber-500/[0.04] hover:bg-amber-500/[0.07] border-amber-700/40 hover:border-amber-600/50'
: 'bg-neutral-900/40 hover:bg-neutral-900/70 border-neutral-700/60 hover:border-neutral-600'
const scoreColor =
result === 'win' ? 'text-emerald-300'
: result === 'loss' ? 'text-red-300'
: result === 'draw' ? 'text-amber-300'
: 'text-white/80'
const mapLabel = labelForMap(m.map)
const iconSrc = iconForMap(m.map)
const bgUrl = bgForMap(m.map)
const row = (
<div
className={`relative cursor-pointer rounded-lg border p-3 transition ${rowTint}
grid items-center gap-4
/* vorher: md:grid-cols-[1fr_minmax(340px,auto)_96px] */
md:grid-cols-[280px_minmax(360px,1fr)_max-content]`}
>
{/* Background + Vignette */}
<div aria-hidden className="pointer-events-none absolute inset-0 rounded-lg opacity-[0.06]"
style={{ backgroundImage: `url(${bgUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }} />
<div aria-hidden className="pointer-events-none absolute inset-0 rounded-lg"
style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.35) 100%)' }} />
{/* LINKS: Map + Meta */}
<div className="relative z-[1] flex items-center gap-3 w-[280px] min-w-[280px] shrink-0">
<div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden">
<img src={iconSrc} alt={mapLabel} className="h-10 w-10 object-contain" loading="lazy" />
</div>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-neutral-300">
<span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">K: {m.kills}</span>
<span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">D: {m.deaths}</span>
{Number.isFinite(m.assists) && <span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">A: {m.assists}</span>}
<span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">K/D: {kdr(m.kills, m.deaths)}</span>
<span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">ADR: {ADR}</span>
<div className="min-w-0"> {/* ← erlaubt Truncation innerhalb der fixen 280px */}
<div className="text-xs text-neutral-300/90">{fmtDateTime(m.date)}</div>
<div className="truncate text-sm font-medium">
{mapLabel}
{m.matchType && <span className="ml-2 text-xs text-neutral-300/80"> {m.matchType}</span>}
</div>
</div>
</div>
<div className="shrink-0 text-right">
{(Number.isFinite(m.scoreA) || Number.isFinite(m.scoreB)) && (
<div className="text-lg font-semibold">
{m.scoreA ?? 0} : {m.scoreB ?? 0}
{/* MITTE: links Score + Pills • rechts Rank ganz rechts */}
<div className="relative z-[1] flex w-full items-center gap-4">
{/* Links: Score + Pills (feste Breite, keine Baseline-Sprünge) */}
<div className="flex min-w-0 items-center gap-2 whitespace-nowrap">
{/* ⬇️ Score: fixe Breiten pro Teil, keine andere Font */}
<div className="inline-flex items-baseline leading-none">
<span
className={`tabular-nums text-base font-semibold text-center ${scoreColor} w-[3ch]`}
aria-label="Own score"
>
{ownScore ?? '-'}
</span>
<span className="text-white/50 text-center w-[1ch]" aria-hidden>
:
</span>
<span
className={`tabular-nums text-base font-semibold text-center ${scoreColor} w-[3ch]`}
aria-label="Opponent score"
>
{oppScore ?? '-'}
</span>
</div>
)}
<div className={`mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[11px] ${resultPill}`}>
{m.result === 'win' ? 'Win' : m.result === 'loss' ? 'Loss' : 'Match'}
{/* Divider */}
<span className="mx-2 h-4 w-px bg-white/10" />
<Pill label="K:" value={String(m.kills)} />
<Pill label="D:" value={String(m.deaths)} />
<Pill label="K/D:" value={kdrLabel(m.kills, m.deaths)} />
<Pill label="ADR:" value={ADR} />
</div>
{/* Rechts: Rank-Block an den rechten Rand geschoben */}
<div className="ml-auto flex items-center justify-end gap-2 shrink-0 pr-1">
{m.matchType === 'premier' ? (
<>
<PremierRankBadge rank={m.rankNew ?? 0} />
<span
className={[
'w-[46px] text-center tabular-nums',
(m.rankChange ?? 0) > 0
? 'text-emerald-300'
: (m.rankChange ?? 0) < 0
? 'text-red-300'
: 'text-neutral-200',
].join(' ')}
title="Punkteänderung"
>
{m.rankChange != null ? `${m.rankChange > 0 ? '+' : ''}${m.rankChange}` : '\u00A0'}
</span>
</>
) : (
<>
<CompRankBadge rank={m.rankNew ?? 0} />
<span className="w-[46px]">&nbsp;</span>
</>
)}
</div>
</div>
</div>
@ -100,9 +269,11 @@ export default async function MatchesList({ steamId }: Props) {
return (
<div key={`${linkId || 'row'}-${idx}`}>
{href ? (
<Link href={href} className="block">{content}</Link>
<Link href={href} className="block focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg">
{row}
</Link>
) : (
content
row
)}
</div>
)

View File

@ -1,192 +1,355 @@
// /src/app/profile/[steamId]/stats/StatsView.tsx
'use client'
import { useMemo } from 'react'
import { useSession } from 'next-auth/react'
import Chart from '../../../Chart'
import Card from '../../../Card'
import { MatchStats } from '@/types/match'
type Props = {
stats: { matches: MatchStats[] }
type Props = { stats: { matches: MatchStats[] } }
// ── helpers ──────────────────────────────────────────────────────────────
const fmtInt = (n: number) => new Intl.NumberFormat('de-DE').format(n)
const fmtDate = (iso: string) =>
new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }).format(
new Date(iso),
)
const normMapKey = (raw?: string) =>
(raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
const humanizeMap = (key: string) =>
key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
const kd = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number' ? (d === 0 ? Infinity : k / d) : NaN
const kdLabel = (k?: number, d?: number) => {
const v = kd(k, d)
if (!Number.isFinite(v)) return '∞'
return v.toFixed(2)
}
const kdr = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number'
? d === 0 ? '∞' : (k / d).toFixed(2)
: '-'
const tintForKD = (value: number) => {
if (!Number.isFinite(value)) return 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
if (value >= 1.3) return 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
if (value >= 1.0) return 'text-blue-400 bg-blue-500/10 ring-blue-500/20'
if (value >= 0.8) return 'text-amber-400 bg-amber-500/10 ring-amber-500/20'
return 'text-rose-400 bg-rose-500/10 ring-rose-500/20'
}
// einheitliche Farbtöne (passen zu Dark UI)
const tone = {
blue: 'rgba(54, 162, 235, 0.6)',
blueBg: 'rgba(54, 162, 235, 0.15)',
red: 'rgba(255, 99, 132, 0.6)',
redBg: 'rgba(255, 99, 132, 0.2)',
amber: 'rgba(255, 206, 86, 0.6)',
amberBg: 'rgba(255, 206, 86, 0.2)',
violet: 'rgba(153, 102, 255, 0.6)',
violetBg: 'rgba(153, 102, 255, 0.2)',
teal: 'rgba(75, 192, 192, 0.6)',
tealBg: 'rgba(75, 192, 192, 0.2)',
orange: 'rgba(255, 159, 64, 0.6)',
orangeBg: 'rgba(255, 159, 64, 0.2)',
}
function Pill({
label,
value,
icon,
className = '',
}: {
label: string
value: string
icon?: React.ReactNode
className?: string
}) {
return (
<div
className={[
'flex items-center justify-between rounded-xl border border-white/5 bg-neutral-900/40',
'px-4 py-3 shadow-[inset_0_1px_0_0_rgba(255,255,255,.04)] backdrop-blur',
className,
].join(' ')}
>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-white/60">{label}</div>
<div className="mt-0.5 text-2xl font-semibold text-white">{value}</div>
</div>
{icon && (
<div className="ml-3 grid h-10 w-10 place-items-center rounded-lg ring-1 ring-inset ring-white/10 bg-white/5 text-white/80">
{icon}
</div>
)}
</div>
)
}
// ── component ────────────────────────────────────────────────────────────
export default function StatsView({ stats }: Props) {
const { data: session } = useSession()
const steamId = session?.user?.steamId ?? ''
// const steamId = session?.user?.steamId // ggf. später für Highlights etc.
const { matches } = stats
const matches = stats.matches ?? []
const totalKills = matches.reduce((sum, m) => sum + (m.kills ?? 0), 0)
const totalDeaths = matches.reduce((sum, m) => sum + (m.deaths ?? 0), 0)
const totalAssists = matches.reduce((sum, m) => sum + (m.assists ?? 0), 0)
const avgKDR = kdr(totalKills, totalDeaths)
const totalDamage = matches.reduce((sum, m) => sum + (m.totalDamage ?? 0), 0)
const overallKD = kd(totalKills, totalDeaths)
const premierMatches = matches.filter((m) => m.rankNew !== null && m.matchType === 'premier')
const compMatches = matches.filter((m) => m.rankNew !== null && m.matchType !== 'premier')
const killsPerMap = matches.reduce<Record<string, number>>((acc, m) => {
const key = (m.map || '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
acc[key] = (acc[key] || 0) + (m.kills ?? 0)
return acc
}, {})
const dateLabels = matches.map((m) => fmtDate(m.date))
const matchesPerMap = matches.reduce<Record<string, number>>((acc, m) => {
const key = (m.map || '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
acc[key] = (acc[key] || 0) + 1
return acc
}, {})
const killsPerMap = useMemo(() => {
return matches.reduce<Record<string, number>>((acc, m) => {
const k = normMapKey(m.map)
acc[k] = (acc[k] || 0) + (m.kills ?? 0)
return acc
}, {})
}, [matches])
const gamesPerMap = useMemo(() => {
return matches.reduce<Record<string, number>>((acc, m) => {
const k = normMapKey(m.map)
acc[k] = (acc[k] || 0) + 1
return acc
}, {})
}, [matches])
const mapKeys = Object.keys(killsPerMap)
const mapNames = mapKeys.map(humanizeMap)
return (
<div className="grid grid-cols-4 gap-4">
{/* linke Spalte */}
<div className="space-y-4">
<Card>
<div className="relative mx-auto">
<Chart
type="doughnut"
title="Ø Gesamt-K/D"
labels={['Kills', 'Deaths']}
datasets={[{
label: 'Anzahl',
data: [totalKills, totalDeaths],
backgroundColor: ['rgba(54, 162, 235, 0.6)','rgba(255, 99, 132, 0.6)'],
}]}
hideLabels
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold">{avgKDR}</p>
</div>
</div>
</div>
</Card>
<Card>
<Chart
type="doughnut"
title="Kills vs Assists vs Deaths"
labels={['Kills', 'Assists', 'Deaths']}
datasets={[{
label: 'Anteile',
data: [totalKills, totalAssists, totalDeaths],
backgroundColor: [
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(255, 99, 132, 0.6)',
],
}]}
/>
</Card>
{/* Highlights Beispiel (auskommentiert) */}
{/* {steamId && (
<Card>
<h3 className="text-lg font-semibold mb-2">Highlights</h3>
<UserClips steamId={steamId} />
</Card>
)} */}
<div className="space-y-6">
{/* KPI row */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Pill
label="Matches"
value={fmtInt(matches.length)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
}
/>
<Pill
label="Kills"
value={fmtInt(totalKills)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M12 19V5M5 12h14" />
</svg>
}
/>
<Pill
label="K/D"
value={overallKD === Infinity ? '∞' : overallKD.toFixed(2)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 3v18M21 21H9" />
</svg>
}
className={`ring-1 ring-inset ${tintForKD(overallKD)}`}
/>
<Pill
label="Damage (Summe)"
value={fmtInt(Math.round(totalDamage))}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M6 20 18 4M14 20h6v-6" />
</svg>
}
/>
</div>
{/* rechte breite Spalte */}
<div className="col-span-3 space-y-6">
<Chart
type="bar"
title="Kills pro Match"
labels={matches.map((m) => m.date)}
datasets={[{ label: 'Kills', data: matches.map((m) => m.kills), backgroundColor: 'rgba(54, 162, 235, 0.6)' }]}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
{/* left column (compact circles) */}
<div className="space-y-4 lg:col-span-1">
<Card>
<div className="relative mx-auto">
<Chart
type="doughnut"
title="Ø Gesamt-K/D"
labels={['Kills', 'Deaths']}
datasets={[
{
label: 'Anzahl',
data: [totalKills, totalDeaths],
backgroundColor: [tone.blue, tone.red],
},
]}
hideLabels
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
className={[
'rounded-md px-2 py-1 text-lg font-semibold ring-1 ring-inset',
tintForKD(overallKD),
].join(' ')}
>
{overallKD === Infinity ? '∞' : overallKD.toFixed(2)}
</div>
</div>
</div>
</Card>
<Chart
type="line"
title="K/D Ratio pro Match"
labels={matches.map((m) => m.date)}
datasets={[{
label: 'K/D',
data: matches.map((m) => (m.deaths === 0 ? m.kills : (m.kills ?? 0) / Math.max(1, m.deaths ?? 0))),
borderColor: 'rgba(255, 99, 132, 0.6)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderWidth: 2,
}]}
/>
<Card>
<Chart
type="doughnut"
title="Kills vs Assists vs Deaths"
labels={['Kills', 'Assists', 'Deaths']}
datasets={[
{
label: 'Anteile',
data: [totalKills, totalAssists, totalDeaths],
backgroundColor: [tone.blue, tone.amber, tone.red],
},
]}
/>
</Card>
</div>
<Chart
type="line"
title="Headshot % pro Match"
labels={matches.map((m) => m.date)}
datasets={[{
label: 'HS%',
data: matches.map((m) => m.headshotPct ?? 0),
borderColor: 'rgba(153, 102, 255, 0.6)',
backgroundColor: 'rgba(153, 102, 255, 0.2)',
borderWidth: 2,
}]}
/>
{/* right column (wide charts) */}
<div className="space-y-6 lg:col-span-3">
<Card>
<Chart
type="bar"
title="Kills pro Match"
labels={dateLabels}
datasets={[
{
label: 'Kills',
data: matches.map((m) => m.kills ?? 0),
backgroundColor: tone.blue,
},
]}
/>
</Card>
<Chart
type="bar"
title="Gesamtdamage pro Match"
labels={matches.map((m) => m.date)}
datasets={[{
label: 'Damage',
data: matches.map((m) => m.totalDamage ?? 0),
backgroundColor: 'rgba(255, 206, 86, 0.6)',
}]}
/>
<Card>
<Chart
type="line"
title="K/D Ratio pro Match"
labels={dateLabels}
datasets={[
{
label: 'K/D',
data: matches.map((m) => kd(m.kills, m.deaths) === Infinity ? (m.kills ?? 0) : (m.kills ?? 0) / Math.max(1, m.deaths ?? 0)),
borderColor: tone.red,
backgroundColor: tone.redBg,
borderWidth: 2,
},
]}
/>
</Card>
{premierMatches.length > 0 && (
<Chart
type="line"
title="Premier Rank-Verlauf"
labels={premierMatches.map((m) => m.date)}
datasets={[{
label: 'Premier Rank',
data: premierMatches.map((m) => m.rankNew ?? 0),
borderColor: 'rgba(75, 192, 192, 0.6)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderWidth: 2,
}]}
/>
)}
<Card>
<Chart
type="line"
title="Headshot % pro Match"
labels={dateLabels}
datasets={[
{
label: 'HS%',
data: matches.map((m) => m.headshotPct ?? 0),
borderColor: tone.violet,
backgroundColor: tone.violetBg,
borderWidth: 2,
},
]}
/>
</Card>
{compMatches.length > 0 && (
<Chart
type="line"
title="Competitive Rank-Verlauf"
labels={compMatches.map((m) => m.date)}
datasets={[{
label: 'Comp Rank',
data: compMatches.map((m) => m.rankNew ?? 0),
borderColor: 'rgba(255, 159, 64, 0.6)',
backgroundColor: 'rgba(255, 159, 64, 0.2)',
borderWidth: 2,
}]}
/>
)}
<Card>
<Chart
type="bar"
title="Gesamtdamage pro Match"
labels={dateLabels}
datasets={[
{
label: 'Damage',
data: matches.map((m) => m.totalDamage ?? 0),
backgroundColor: tone.amber,
},
]}
/>
</Card>
<Chart
type="bar"
title="Kills pro Map"
labels={Object.keys(killsPerMap)}
datasets={[{ label: 'Kills', data: Object.values(killsPerMap), backgroundColor: 'rgba(255, 159, 64, 0.6)' }]}
/>
{premierMatches.length > 0 && (
<Card>
<Chart
type="line"
title="Premier Rank-Verlauf"
labels={premierMatches.map((m) => fmtDate(m.date))}
datasets={[
{
label: 'Premier Rank',
data: premierMatches.map((m) => m.rankNew ?? 0),
borderColor: tone.teal,
backgroundColor: tone.tealBg,
borderWidth: 2,
},
]}
/>
</Card>
)}
<Chart
type="radar"
title="Matches pro Map"
labels={Object.keys(matchesPerMap)}
datasets={[{
label: 'Matches',
data: Object.values(matchesPerMap),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
}]}
/>
{compMatches.length > 0 && (
<Card>
<Chart
type="line"
title="Competitive Rank-Verlauf"
labels={compMatches.map((m) => fmtDate(m.date))}
datasets={[
{
label: 'Comp Rank',
data: compMatches.map((m) => m.rankNew ?? 0),
borderColor: tone.orange,
backgroundColor: tone.orangeBg,
borderWidth: 2,
},
]}
/>
</Card>
)}
<Card>
<Chart
type="bar"
title="Kills pro Map"
labels={mapNames}
datasets={[
{
label: 'Kills',
data: mapKeys.map((k) => killsPerMap[k]),
backgroundColor: tone.orange,
},
]}
/>
</Card>
<Card>
<Chart
type="radar"
title="Matches pro Map"
labels={mapNames}
datasets={[
{
label: 'Matches',
data: mapKeys.map((k) => gamesPerMap[k]),
backgroundColor: tone.blueBg,
borderColor: tone.blue,
borderWidth: 2,
},
]}
/>
</Card>
</div>
</div>
</div>
)

View File

@ -260,8 +260,8 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
// Trennung für Response (gleich wie bisher)
const setA = new Set(updated.teamAUsers.map(u => u.steamId))
const setB = new Set(updated.teamBUsers.map(u => u.steamId))
const playersA = updated.players.filter(p => setA.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'Team A' }))
const playersB = updated.players.filter(p => setB.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'Team B' }))
const playersA = updated.players.filter(p => setA.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name }))
const playersB = updated.players.filter(p => setB.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name }))
return NextResponse.json({
id: updated.id,
@ -273,14 +273,14 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
map: updated.map,
teamA: {
id: updated.teamAId ?? null,
name: updated.teamAUsers[0]?.team?.name ?? updated.teamA?.name ?? 'Team A',
name: updated.teamAUsers[0]?.team?.name ?? updated.teamA?.name,
logo: updated.teamAUsers[0]?.team?.logo ?? updated.teamA?.logo ?? null,
score: updated.scoreA,
players: playersA,
},
teamB: {
id: updated.teamBId ?? null,
name: updated.teamBUsers[0]?.team?.name ?? updated.teamB?.name ?? 'Team B',
name: updated.teamBUsers[0]?.team?.name ?? updated.teamB?.name,
logo: updated.teamBUsers[0]?.team?.logo ?? updated.teamB?.logo ?? null,
score: updated.scoreB,
players: playersB,

View File

@ -9,7 +9,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
}
// 1) Dieses Team laden, um bestehende Mitglieder zu kennen
// 1) Team laden
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { activePlayers: true, inactivePlayers: true }
@ -17,35 +17,34 @@ export async function GET(req: NextRequest) {
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
const membersOfThisTeam = new Set([
...team.activePlayers,
...team.inactivePlayers
])
// 2) Pending-Invites DIESES Teams
const members = new Set([...team.activePlayers, ...team.inactivePlayers])
// 2) Pending-Invites dieses Teams
const pendingInvites = await prisma.teamInvite.findMany({
where: { teamId },
select: { steamId: true }
})
const invitedByThisTeam = new Set(pendingInvites.map(i => i.steamId))
const invited = new Set(pendingInvites.map(i => i.steamId))
// 3) Alle User (oder mit Suche filtern, wenn du willst)
const allUsers = await prisma.user.findMany({
// 3) Kandidaten: nur canBeInvited === true, nicht Mitglied, nicht eingeladen
const excludeIds = Array.from(new Set([...members, ...invited]))
const availableUsers = await prisma.user.findMany({
where: {
canBeInvited: true, // << nur einladbare
steamId: { notIn: excludeIds } // << nicht schon Mitglied/geladen
},
select: {
steamId : true,
name : true,
avatar : true,
location : true,
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true
},
orderBy: { name: 'asc' }
})
// 4) Verfügbar = NICHT schon Mitglied DIESES Teams + NICHT von DIESEM Team eingeladen
const availableUsers = allUsers.filter(u =>
!membersOfThisTeam.has(u.steamId) && !invitedByThisTeam.has(u.steamId)
)
return NextResponse.json({ users: availableUsers })
} catch (error) {
console.error('Fehler beim Laden der verfügbaren Benutzer:', error)

View File

@ -1,24 +0,0 @@
// /pages/api/team/change-logo.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { teamId, logoUrl } = req.body
if (!teamId || !logoUrl) return res.status(400).json({ error: 'Team-ID oder Logo-URL fehlt' })
try {
await prisma.team.update({ where: { id: teamId }, data: { logo: logoUrl } })
// 🔔 spezifisch
await sendServerSSEMessage({ type: 'team-logo-updated', teamId })
// 🔔 generisch
await sendServerSSEMessage({ type: 'team-updated', teamId })
return res.status(200).json({ success: true })
} catch (err) {
console.error(err)
return res.status(500).json({ error: 'Logo konnte nicht geändert werden' })
}
}

View File

@ -1,86 +1,128 @@
// /api/team/invite/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
type ResultReason =
| 'not-invitable'
| 'already-member'
| 'already-invited'
| 'self'
| 'duplicate'
| 'ok'
export async function POST(req: NextRequest) {
try {
const { teamId, userIds: rawUserIds, invitedBy } = await req.json()
/* ------------------------------------------------------------ */
/* Eingaben prüfen */
/* ------------------------------------------------------------ */
if (!teamId || !Array.isArray(rawUserIds) || rawUserIds.length === 0) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
}
/* Eingeladener darf nicht sich selbst einladen */
const steamIds = rawUserIds.filter((id: string) => id !== invitedBy)
/* Team holen */
const team = await prisma.team.findUnique({
where : { id: teamId },
select: { name: true, leader: true },
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
const teamName = team.name ?? 'Unbekanntes Team'
/* ------------------------------------------------------------ */
/* Einladungen + Benachrichtigungen erzeugen */
/* ------------------------------------------------------------ */
const invitationIds = await Promise.all(
steamIds.map(async (steamId: string) => {
/* TeamInvite anlegen FELD-NAMEN ans Schema anpassen! */
const invite = await prisma.teamInvite.create({
data: {
teamId,
steamId,
type: 'team-invite',
},
})
/* Notification anlegen */
const notification = await prisma.notification.create({
data: {
steamId,
title : 'Teameinladung',
message : `Du wurdest in das Team "${teamName}" eingeladen.`,
actionType: 'team-invite',
actionData: invite.id,
},
})
/* SSE pushen */
await sendServerSSEMessage({
type : notification.actionType ?? 'notification',
targetUserIds: [steamId],
message : notification.message,
id : notification.id,
actionType : notification.actionType ?? undefined,
actionData : notification.actionData ?? undefined,
createdAt : notification.createdAt.toISOString(),
})
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: team.leader?.steamId,
})
return invite.id
}),
// 1) Grundbereinigung: keine Selbst-Invite, keine Duplikate, nur Strings
const initialIds = Array.from(
new Set(
rawUserIds
.map((x: unknown) => String(x ?? ''))
.filter((id: string) => id.length > 0 && id !== invitedBy)
)
)
if (initialIds.length === 0) {
return NextResponse.json({ message: 'Keine gültigen Empfänger' }, { status: 409 })
}
// 2) Team + Mitglieder laden
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { name: true, leader: true, activePlayers: true, inactivePlayers: true }
})
if (!team) return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
const teamName = team.name ?? 'Unbekanntes Team'
const members = new Set([...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])])
// 3) Bereits ausstehende Einladungen dieses Teams (für die initialIds)
const pending = await prisma.teamInvite.findMany({
where: { teamId, steamId: { in: initialIds } },
select: { steamId: true }
})
const alreadyInvited = new Set(pending.map(p => p.steamId))
// 4) Nur User, die eingeladen werden dürfen (canBeInvited === true)
const invitables = await prisma.user.findMany({
where: { steamId: { in: initialIds }, canBeInvited: true },
select: { steamId: true }
})
const invitablesSet = new Set(invitables.map(u => u.steamId))
// 5) Vorab-Resultate für alle initialIds berechnen
const preResults = initialIds.map((id): { steamId: string; ok: boolean; reason: ResultReason } => {
if (!invitablesSet.has(id)) return { steamId: id, ok: false, reason: 'not-invitable' }
if (members.has(id)) return { steamId: id, ok: false, reason: 'already-member' }
if (alreadyInvited.has(id))return { steamId: id, ok: false, reason: 'already-invited' }
return { steamId: id, ok: true, reason: 'ok' }
})
// 6) Nur tatsächlich einzuladende IDs
const targetIds = preResults.filter(r => r.ok).map(r => r.steamId)
// 7) Einladungen + Benachrichtigungen für targetIds
const invitationIds: string[] = []
for (const steamId of targetIds) {
const invite = await prisma.teamInvite.create({
data: { teamId, steamId, type: 'team-invite' }
})
invitationIds.push(invite.id)
const notification = await prisma.notification.create({
data: {
steamId,
title: 'Teameinladung',
message: `Du wurdest in das Team "${teamName}" eingeladen.`,
actionType: 'team-invite',
actionData: invite.id
}
})
await sendServerSSEMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [steamId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString()
})
}
// Optional: Team-Update an Leader
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: team.leader?.steamId
})
const okCount = targetIds.length
const status = okCount > 0 ? 200 : 409
return NextResponse.json(
{ message: 'Einladungen versendet', invitationIds },
{ status: 200 },
{
message:
okCount === 0
? 'Keine gültigen Empfänger (bereits Mitglied/eingeladen oder nicht einladbar).'
: okCount === initialIds.length
? 'Einladungen versendet'
: 'Teilweise versendet',
invitationIds,
count: okCount,
results: preResults // <-- pro-User Ergebnisliste für das Frontend
},
{ status }
)
} catch (err) {
console.error('[TEAM-INVITE] Fehler:', err)
return NextResponse.json(
{ message: 'Fehler beim Einladen' },
{ status: 500 },
)
return NextResponse.json({ message: 'Fehler beim Einladen' }, { status: 500 })
}
}

View File

@ -47,7 +47,7 @@ export async function POST(req: NextRequest) {
// DB aktualisieren
await prisma.team.update({
where: { id: teamId },
data : { logo: filename },
data : { logo: filename, logoUpdatedAt: new Date() }, // <<<
})
const version = Date.now()
@ -57,7 +57,7 @@ export async function POST(req: NextRequest) {
type : 'team-logo-updated',
teamId,
// falls dein SSE-Client `payload` liest:
payload : { filename: filename, version }
payload : { filename, version }
})
await sendServerSSEMessage({ type: 'team-updated', teamId })

View File

@ -7,28 +7,65 @@ export const dynamic = 'force-dynamic'
export async function GET() {
try {
// 1) Teams laden (ohne leaderId im späteren Response)
const teams = await prisma.team.findMany({
select: {
id: true,
name: true,
logo: true,
leaderId: true,
createdAt: true,
activePlayers: true,
inactivePlayers: true
inactivePlayers: true,
// Leader direkt als User-Objekt laden
leader: {
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
},
},
},
orderBy: { name: 'asc' }
orderBy: { name: 'asc' },
})
// Alle benötigten SteamIDs sammeln (aktive, inaktive, Leader)
if (teams.length === 0) {
return NextResponse.json(
{ teams: [], hasMore: false },
{ headers: { 'Cache-Control': 'no-store' } },
)
}
const teamIds = teams.map(t => t.id)
// 2) Ausstehende Einladungen pro Team holen
// (falls du "revoked/accepted" Flags hast, hier mitfiltern)
const invites = await prisma.teamInvite.findMany({
where: { teamId: { in: teamIds } },
select: { id: true, teamId: true, steamId: true },
})
// Map: teamId -> [{steamId, invitationId}]
const invitedByTeam = new Map<string, { steamId: string; invitationId: string }[]>()
for (const inv of invites) {
const arr = invitedByTeam.get(inv.teamId) ?? []
arr.push({ steamId: inv.steamId, invitationId: inv.id })
invitedByTeam.set(inv.teamId, arr)
}
// 3) Alle benötigten SteamIDs sammeln (aktive, inaktive, invited)
const uniqueIds = new Set<string>()
for (const t of teams) {
t.activePlayers.forEach(id => uniqueIds.add(id))
t.inactivePlayers.forEach(id => uniqueIds.add(id))
if (t.leaderId) uniqueIds.add(t.leaderId)
const invited = invitedByTeam.get(t.id) ?? []
invited.forEach(i => uniqueIds.add(i.steamId))
}
// Leader müssen nicht in uniqueIds, da oben bereits als Objekt geladen.
// (könnten aber optional dazu; ist hier nicht nötig)
// Nutzer-Daten für alle IDs holen
// 4) Nutzer-Daten für alle IDs holen
const users = await prisma.user.findMany({
where: { steamId: { in: [...uniqueIds] } },
select: {
@ -36,14 +73,14 @@ export async function GET() {
name: true,
avatar: true,
location: true,
premierRank: true
}
premierRank: true,
},
})
// Lookup-Map aufbauen
const byId: Record<string, Player> = {}
// Lookup-Map
const byId: Record<string, Player | undefined> = {}
const DEFAULT_AVATAR = '/assets/img/avatars/default.png'
const UNKNOWN_NAME = 'Unbekannt'
const UNKNOWN_NAME = 'Unbekannt'
for (const u of users) {
byId[u.steamId] = {
@ -51,38 +88,66 @@ export async function GET() {
name: u.name ?? UNKNOWN_NAME,
avatar: u.avatar ?? DEFAULT_AVATAR,
location: u.location ?? '',
premierRank: u.premierRank ?? 0
premierRank: u.premierRank ?? 0,
}
}
// Ergebnis formen Leader als komplettes Player-Objekt mitsenden
// 5) Ergebnis formen
const result = teams.map(t => {
const leaderPlayer: Player | undefined =
t.leaderId
? (byId[t.leaderId] ?? {
steamId: t.leaderId,
// Leader (bereits komplett geladen); mit Defaults absichern
const leader: Player | undefined = t.leader
? {
steamId: t.leader.steamId,
name: t.leader.name ?? UNKNOWN_NAME,
avatar: t.leader.avatar ?? DEFAULT_AVATAR,
location: t.leader.location ?? '',
premierRank: t.leader.premierRank ?? 0,
}
: undefined
// Aktive & Inaktive Spieler aus Map befüllen
const activePlayers: Player[] = t.activePlayers
.map(id => byId[id])
.filter(Boolean) as Player[]
const inactivePlayers: Player[] = t.inactivePlayers
.map(id => byId[id])
.filter(Boolean) as Player[]
// Eingeladene Spieler inkl. invitationId
const invitedRaw = invitedByTeam.get(t.id) ?? []
const invitedPlayers: (Player & { invitationId?: string })[] = invitedRaw
.map(({ steamId, invitationId }) => {
const base = byId[steamId]
// Falls User (noch) nicht existiert, mit Fallbacks liefern
if (!base) {
return {
steamId,
name: UNKNOWN_NAME,
avatar: DEFAULT_AVATAR,
location: '',
premierRank: 0
})
: undefined
premierRank: 0,
invitationId,
}
}
return { ...base, invitationId }
})
return {
id: t.id,
name: t.name,
logo: t.logo,
createdAt: t.createdAt,
leaderId: t.leaderId,
leader: leaderPlayer, // ⬅️ voll befüllt
activePlayers: t.activePlayers.map(id => byId[id]).filter(Boolean) as Player[],
inactivePlayers: t.inactivePlayers.map(id => byId[id]).filter(Boolean) as Player[]
leader, // ✅ voll befüllt
activePlayers, // ✅ Player[]
inactivePlayers, // ✅ Player[]
invitedPlayers, // ✅ Player[] mit invitationId
}
})
return NextResponse.json(
{ teams: result, hasMore: false },
{ headers: { 'Cache-Control': 'no-store' } }
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) {
console.error('GET /api/teams failed:', err)

File diff suppressed because one or more lines are too long

View File

@ -20,11 +20,11 @@ exports.Prisma = Prisma
exports.$Enums = {}
/**
* Prisma Client JS version: 6.16.1
* Prisma Client JS version: 6.16.2
* Query Engine version: 1c57fdcd7e44b29b9313256c76699e91c3ac3c43
*/
Prisma.prismaVersion = {
client: "6.16.1",
client: "6.16.2",
engine: "1c57fdcd7e44b29b9313256c76699e91c3ac3c43"
}
@ -143,6 +143,7 @@ exports.Prisma.TeamScalarFieldEnum = {
id: 'id',
name: 'name',
logo: 'logo',
logoUpdatedAt: 'logoUpdatedAt',
leaderId: 'leaderId',
createdAt: 'createdAt',
activePlayers: 'activePlayers',

View File

@ -460,7 +460,7 @@ export namespace Prisma {
export import Exact = $Public.Exact
/**
* Prisma Client JS version: 6.16.1
* Prisma Client JS version: 6.16.2
* Query Engine version: 1c57fdcd7e44b29b9313256c76699e91c3ac3c43
*/
export type PrismaVersion = {
@ -4130,6 +4130,7 @@ export namespace Prisma {
id: string | null
name: string | null
logo: string | null
logoUpdatedAt: Date | null
leaderId: string | null
createdAt: Date | null
}
@ -4138,6 +4139,7 @@ export namespace Prisma {
id: string | null
name: string | null
logo: string | null
logoUpdatedAt: Date | null
leaderId: string | null
createdAt: Date | null
}
@ -4146,6 +4148,7 @@ export namespace Prisma {
id: number
name: number
logo: number
logoUpdatedAt: number
leaderId: number
createdAt: number
activePlayers: number
@ -4158,6 +4161,7 @@ export namespace Prisma {
id?: true
name?: true
logo?: true
logoUpdatedAt?: true
leaderId?: true
createdAt?: true
}
@ -4166,6 +4170,7 @@ export namespace Prisma {
id?: true
name?: true
logo?: true
logoUpdatedAt?: true
leaderId?: true
createdAt?: true
}
@ -4174,6 +4179,7 @@ export namespace Prisma {
id?: true
name?: true
logo?: true
logoUpdatedAt?: true
leaderId?: true
createdAt?: true
activePlayers?: true
@ -4257,6 +4263,7 @@ export namespace Prisma {
id: string
name: string
logo: string | null
logoUpdatedAt: Date | null
leaderId: string | null
createdAt: Date
activePlayers: string[]
@ -4284,6 +4291,7 @@ export namespace Prisma {
id?: boolean
name?: boolean
logo?: boolean
logoUpdatedAt?: boolean
leaderId?: boolean
createdAt?: boolean
activePlayers?: boolean
@ -4304,6 +4312,7 @@ export namespace Prisma {
id?: boolean
name?: boolean
logo?: boolean
logoUpdatedAt?: boolean
leaderId?: boolean
createdAt?: boolean
activePlayers?: boolean
@ -4315,6 +4324,7 @@ export namespace Prisma {
id?: boolean
name?: boolean
logo?: boolean
logoUpdatedAt?: boolean
leaderId?: boolean
createdAt?: boolean
activePlayers?: boolean
@ -4326,13 +4336,14 @@ export namespace Prisma {
id?: boolean
name?: boolean
logo?: boolean
logoUpdatedAt?: boolean
leaderId?: boolean
createdAt?: boolean
activePlayers?: boolean
inactivePlayers?: boolean
}
export type TeamOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "name" | "logo" | "leaderId" | "createdAt" | "activePlayers" | "inactivePlayers", ExtArgs["result"]["team"]>
export type TeamOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "name" | "logo" | "logoUpdatedAt" | "leaderId" | "createdAt" | "activePlayers" | "inactivePlayers", ExtArgs["result"]["team"]>
export type TeamInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
leader?: boolean | Team$leaderArgs<ExtArgs>
members?: boolean | Team$membersArgs<ExtArgs>
@ -4369,6 +4380,7 @@ export namespace Prisma {
id: string
name: string
logo: string | null
logoUpdatedAt: Date | null
leaderId: string | null
createdAt: Date
activePlayers: string[]
@ -4808,6 +4820,7 @@ export namespace Prisma {
readonly id: FieldRef<"Team", 'String'>
readonly name: FieldRef<"Team", 'String'>
readonly logo: FieldRef<"Team", 'String'>
readonly logoUpdatedAt: FieldRef<"Team", 'DateTime'>
readonly leaderId: FieldRef<"Team", 'String'>
readonly createdAt: FieldRef<"Team", 'DateTime'>
readonly activePlayers: FieldRef<"Team", 'String[]'>
@ -20985,6 +20998,7 @@ export namespace Prisma {
id: 'id',
name: 'name',
logo: 'logo',
logoUpdatedAt: 'logoUpdatedAt',
leaderId: 'leaderId',
createdAt: 'createdAt',
activePlayers: 'activePlayers',
@ -21548,6 +21562,7 @@ export namespace Prisma {
id?: StringFilter<"Team"> | string
name?: StringFilter<"Team"> | string
logo?: StringNullableFilter<"Team"> | string | null
logoUpdatedAt?: DateTimeNullableFilter<"Team"> | Date | string | null
leaderId?: StringNullableFilter<"Team"> | string | null
createdAt?: DateTimeFilter<"Team"> | Date | string
activePlayers?: StringNullableListFilter<"Team">
@ -21567,6 +21582,7 @@ export namespace Prisma {
id?: SortOrder
name?: SortOrder
logo?: SortOrderInput | SortOrder
logoUpdatedAt?: SortOrderInput | SortOrder
leaderId?: SortOrderInput | SortOrder
createdAt?: SortOrder
activePlayers?: SortOrder
@ -21590,6 +21606,7 @@ export namespace Prisma {
OR?: TeamWhereInput[]
NOT?: TeamWhereInput | TeamWhereInput[]
logo?: StringNullableFilter<"Team"> | string | null
logoUpdatedAt?: DateTimeNullableFilter<"Team"> | Date | string | null
createdAt?: DateTimeFilter<"Team"> | Date | string
activePlayers?: StringNullableListFilter<"Team">
inactivePlayers?: StringNullableListFilter<"Team">
@ -21608,6 +21625,7 @@ export namespace Prisma {
id?: SortOrder
name?: SortOrder
logo?: SortOrderInput | SortOrder
logoUpdatedAt?: SortOrderInput | SortOrder
leaderId?: SortOrderInput | SortOrder
createdAt?: SortOrder
activePlayers?: SortOrder
@ -21624,6 +21642,7 @@ export namespace Prisma {
id?: StringWithAggregatesFilter<"Team"> | string
name?: StringWithAggregatesFilter<"Team"> | string
logo?: StringNullableWithAggregatesFilter<"Team"> | string | null
logoUpdatedAt?: DateTimeNullableWithAggregatesFilter<"Team"> | Date | string | null
leaderId?: StringNullableWithAggregatesFilter<"Team"> | string | null
createdAt?: DateTimeWithAggregatesFilter<"Team"> | Date | string
activePlayers?: StringNullableListFilter<"Team">
@ -22971,6 +22990,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -22989,6 +23009,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -23007,6 +23028,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -23025,6 +23047,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -23043,6 +23066,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -23053,6 +23077,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -23062,6 +23087,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -24680,6 +24706,7 @@ export namespace Prisma {
id?: SortOrder
name?: SortOrder
logo?: SortOrder
logoUpdatedAt?: SortOrder
leaderId?: SortOrder
createdAt?: SortOrder
activePlayers?: SortOrder
@ -24690,6 +24717,7 @@ export namespace Prisma {
id?: SortOrder
name?: SortOrder
logo?: SortOrder
logoUpdatedAt?: SortOrder
leaderId?: SortOrder
createdAt?: SortOrder
}
@ -24698,6 +24726,7 @@ export namespace Prisma {
id?: SortOrder
name?: SortOrder
logo?: SortOrder
logoUpdatedAt?: SortOrder
leaderId?: SortOrder
createdAt?: SortOrder
}
@ -27631,6 +27660,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -27648,6 +27678,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -27670,6 +27701,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -27687,6 +27719,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -28150,6 +28183,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -28167,6 +28201,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -28195,6 +28230,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -28212,6 +28248,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -29296,6 +29333,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -29313,6 +29351,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -29419,6 +29458,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -29436,6 +29476,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -29593,6 +29634,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -29610,6 +29652,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -29632,6 +29675,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -29649,6 +29693,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -29985,6 +30030,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -30002,6 +30048,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -30030,6 +30077,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -30047,6 +30095,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -30255,6 +30304,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -30272,6 +30322,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -30506,6 +30557,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -30523,6 +30575,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -31083,6 +31136,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -31100,6 +31154,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -31122,6 +31177,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -31139,6 +31195,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -31371,6 +31428,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -31388,6 +31446,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -31416,6 +31475,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -31433,6 +31493,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
@ -32265,6 +32326,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
inactivePlayers?: TeamCreateinactivePlayersInput | string[]
@ -32282,6 +32344,7 @@ export namespace Prisma {
id?: string
name: string
logo?: string | null
logoUpdatedAt?: Date | string | null
leaderId?: string | null
createdAt?: Date | string
activePlayers?: TeamCreateactivePlayersInput | string[]
@ -32417,6 +32480,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]
inactivePlayers?: TeamUpdateinactivePlayersInput | string[]
@ -32434,6 +32498,7 @@ export namespace Prisma {
id?: StringFieldUpdateOperationsInput | string
name?: StringFieldUpdateOperationsInput | string
logo?: NullableStringFieldUpdateOperationsInput | string | null
logoUpdatedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leaderId?: NullableStringFieldUpdateOperationsInput | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
activePlayers?: TeamUpdateactivePlayersInput | string[]

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-f83ffd6f15d5d09bef7e96b9dc3d4dfe3004d8583cd7a36804111c84705fa416",
"name": "prisma-client-6613cd631161519d0c8efe85070eea9ac4807e6b7e9b83666d4cbc9630bfbbf8",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",
@ -151,7 +151,7 @@
},
"./*": "./*"
},
"version": "6.16.1",
"version": "6.16.2",
"sideEffects": false,
"imports": {
"#wasm-engine-loader": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -66,9 +66,11 @@ enum UserStatus {
}
model Team {
id String @id @default(uuid())
name String @unique
logo String?
id String @id @default(uuid())
name String @unique
logo String?
logoUpdatedAt DateTime? @default(now())
leaderId String? @unique
createdAt DateTime @default(now())

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
// lib/sse-actions.ts
// /src/lib/sse-actions.ts
import { Player, Team, InvitedPlayer } from '@/types/team'

View File

@ -1,15 +1,16 @@
// lib/stores.ts
'use client'
// /src/lib/stores.ts
import { create } from 'zustand'
import { Team } from '@/types/team'
import type { Team } from '@/types/team'
type TeamState = {
team: Team | null
setTeam: (t: Team) => void
setTeam: (t: Team | null) => void
}
export const useTeamStore = create<TeamState>((set) => ({
team: null,
setTeam: (team) => set({ team }),
setTeam: (t) => {
if (!t) return set({ team: null })
set({ team: t })
},
}))

View File

@ -1,6 +1,5 @@
import { MatchPlayer } from "@/generated/prisma"
// /types/team.ts
export type Player = {
steamId: string
name: string