updated mappool

This commit is contained in:
Linrador 2025-09-29 15:28:50 +02:00
parent aff1f090c1
commit 074fa4d666
18 changed files with 282 additions and 151 deletions

38
package-lock.json generated
View File

@ -86,6 +86,7 @@
"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"
},
@ -123,7 +124,6 @@
"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,6 +1578,7 @@
"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"
}
@ -2068,7 +2069,6 @@
"integrity": "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.19.2"
}
@ -2086,7 +2086,6 @@
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -2184,7 +2183,6 @@
"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",
@ -2618,7 +2616,6 @@
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3138,7 +3135,6 @@
"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"
},
@ -3288,6 +3284,7 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@ -3428,7 +3425,6 @@
"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"
@ -3885,7 +3881,6 @@
"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",
@ -4060,7 +4055,6 @@
"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",
@ -5409,6 +5403,7 @@
"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"
}
@ -5837,6 +5832,7 @@
"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"
},
@ -6024,7 +6020,6 @@
"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",
@ -6317,7 +6312,8 @@
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/object-assign": {
"version": "4.1.1",
@ -6334,6 +6330,7 @@
"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"
}
@ -6462,6 +6459,7 @@
"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"
}
@ -6748,6 +6746,7 @@
"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"
},
@ -6778,7 +6777,8 @@
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/prisma": {
"version": "6.16.2",
@ -6787,7 +6787,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.16.2",
"@prisma/engines": "6.16.2"
@ -6904,7 +6903,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6924,7 +6922,6 @@
"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"
},
@ -6994,7 +6991,8 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@ -7651,8 +7649,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.1",
@ -7709,7 +7706,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7935,7 +7931,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8191,7 +8186,8 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
"license": "ISC",
"peer": true
},
"node_modules/yn": {
"version": "3.1.1",

View File

@ -27,6 +27,8 @@ type CardProps = {
}
export default function Card({
title,
description,
children,
align = 'center',
maxWidth = 'lg',
@ -60,20 +62,28 @@ export default function Card({
<div
style={style}
className={[
'flex flex-col rounded-xl border border-gray-200 bg-white shadow-2xs p-3',
// ⬇️ kein Außen-Padding; Box misst inkl. Border (verhindert Extra-Scroll)
'box-border flex flex-col rounded-xl border border-gray-200 bg-white shadow-2xs overflow-hidden max-h-full',
'dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70',
alignClasses,
widthClasses[maxWidth],
// wenn parent `h-full` gibt, füllt die Card die Höhe
'min-h-0'
].join(' ')}
>
<div
className={[
'flex-1 min-h-0 p-3',
bodyScrollable ? 'overflow-auto' : 'overflow-hidden',
className ?? '',
].join(' ')}
>
{children}
{(title || description) && (
<div className="px-4 py-3 border-b border-gray-200/70 dark:border-neutral-700/60">
{title && <h3 className="text-base font-semibold">{title}</h3>}
{description && (
<p className="mt-0.5 text-sm text-gray-500 dark:text-neutral-400">{description}</p>
)}
</div>
)}
<div className="flex-1 min-h-0 overflow-auto">
<div className="p-4 sm:p-6">
{children}
</div>
</div>
</div>
)

View File

@ -80,6 +80,12 @@ type ChartProps = {
radarIcons?: string[] | Record<string, string>
radarIconSize?: number // px
radarIconOffset?: number // Abstand über dem äußersten Ring (Skaleneinheiten)
/** Unter jedem Radar-Icon den Label-Text rendern */
radarIconLabels?: boolean // default: false
radarIconLabelFont?: string // CSS canvas font, z.B. '12px Inter, sans-serif'
radarIconLabelColor?: string // z.B. '#fff'
radarIconLabelMargin?: number // px Abstand unterhalb des Icons
}
export default function Chart({
@ -108,6 +114,12 @@ export default function Chart({
radarIcons,
radarIconSize = 28,
radarIconOffset = 6,
// ⬇️ neu
radarIconLabels = false,
radarIconLabelFont = '12px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, sans-serif',
radarIconLabelColor = '#fff',
radarIconLabelMargin = 4,
}: ChartProps) {
const isRadar = type === 'radar'
const isAutoHeight = height === 'auto'
@ -194,6 +206,27 @@ export default function Chart({
ctx.shadowOffsetY = 1
ctx.drawImage(img, x, y, size, size)
ctx.restore()
// ⬇️ Label unter dem Icon (optional)
if (radarIconLabels) {
const text = label
const tx = pos.x
const ty = y + size + radarIconLabelMargin
ctx.save()
ctx.font = radarIconLabelFont
ctx.fillStyle = radarIconLabelColor
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
// leichte Kontur/Shadow für bessere Lesbarkeit
ctx.shadowColor = 'rgba(0,0,0,0.45)'
ctx.shadowBlur = 3
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 1
ctx.fillText(text, tx, ty)
ctx.restore()
}
}
},
}
@ -204,8 +237,9 @@ export default function Chart({
const options = useMemo(() => {
const base: any = {
responsive: true,
maintainAspectRatio: isAutoHeight,
aspectRatio: isAutoHeight ? aspectRatio : undefined,
maintainAspectRatio: !isAutoHeight,
aspectRatio: !isAutoHeight ? aspectRatio : undefined,
layout: { padding: { top: 40, right: 24, bottom: 40, left: 24 } },
plugins: {
legend: { display: !hideLabels, position: 'top' as const },
title: { display: !!title, text: title },
@ -263,15 +297,19 @@ export default function Chart({
// -------- Render (typsicher) --------
const wrapperStyle: React.CSSProperties = isAutoHeight
? { width: '100%', ...style }
: { height: typeof height === 'number' ? height : undefined, width: '100%', ...style }
? { width: '100%', height: '100%', position: 'relative', ...style }
: { width: '100%', height: typeof height === 'number' ? height : undefined, ...style }
if (isRadar) {
// Nur hier das streng typisierte Radar-Plugin übergeben
const radarPlugins = radarIconsPlugin ? [radarIconsPlugin] as Plugin<'radar'>[] : undefined
return (
<div className={className} style={wrapperStyle}>
<Radar data={data} options={options} plugins={radarPlugins} />
<Radar
data={data}
options={options}
plugins={radarPlugins}
style={{ height: '100%', width: '100%' }} // <— Canvas füllt Parent
/>
</div>
)
}
@ -291,7 +329,11 @@ export default function Chart({
return (
<div className={className} style={wrapperStyle}>
<NonRadar data={data} options={options} />
<NonRadar
data={data}
options={options}
style={{ height: '100%', width: '100%' }} // <— auch für andere Typen
/>
</div>
)
}

View File

@ -145,6 +145,29 @@ export default function CommunityMatchList({ matchType }: Props) {
const [now, setNow] = useState(() => Date.now())
const mySteamId = session?.user?.steamId
const isOwnMatch = useCallback((m: any) => {
if (!mySteamId) return false
// a) Neues Shape: teamA.players / teamB.players -> p.user.steamId
const inTeamA = m?.teamA?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false
const inTeamB = m?.teamB?.players?.some((p: any) => p?.user?.steamId === mySteamId) ?? false
if (inTeamA || inTeamB) return true
// b) Manchmal flaches players-Array (falls noch vorhanden)
const inFlat = m?.players?.some((p: any) =>
p?.user?.steamId === mySteamId || p?.steamId === mySteamId
) ?? false
if (inFlat) return true
// c) Fallback (nur wenn du es noch möchtest): Team-Mitgliedschaft
const byTeamMembership =
!!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team)
return byTeamMembership
}, [mySteamId, session?.user?.team])
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
@ -313,17 +336,20 @@ export default function CommunityMatchList({ matchType }: Props) {
}
// Gruppieren
const grouped = (() => {
const sorted = [...matches].sort(
const grouped = useMemo(() => {
// optional filtern
const base = onlyOwn ? matches.filter(isOwnMatch) : matches
const sorted = [...base].sort(
(a, b) => new Date(a.demoDate).getTime() - new Date(b.demoDate).getTime(),
);
const map = new Map<string, Match[]>();
)
const map = new Map<string, Match[]>()
for (const m of sorted) {
const key = dateKeyInTZ(m.demoDate, userTZ);
map.set(key, [...(map.get(key) ?? []), m]);
const key = dateKeyInTZ(m.demoDate, userTZ)
map.set(key, [...(map.get(key) ?? []), m])
}
return Array.from(map.entries());
})();
return Array.from(map.entries())
}, [matches, onlyOwn, isOwnMatch, userTZ])
return (
<div className="max-w-7xl mx-auto py-8 px-4 space-y-6">
@ -365,9 +391,8 @@ export default function CommunityMatchList({ matchType }: Props) {
const started = new Date(m.demoDate).getTime() <= Date.now()
const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null
const isLive = started && unfinished
const isOwnTeam = !!session?.user?.team &&
(m.teamA.id === session.user.team || m.teamB.id === session.user.team)
const dimmed = onlyOwn && !isOwnTeam
const isOwn = isOwnMatch(m)
const dimmed = onlyOwn ? false : !isOwn
// 👇 Map-Vote Status berechnen
const mv = getMapVoteState(m, now)

View File

@ -890,29 +890,26 @@ export default function MapVotePanel({ match }: Props) {
</main>
) : (
// Winrate-Tab
<div className="w-full max-w-xl justify-self-center">
<div className="rounded-lg border border-white/10 bg-neutral-900/40 backdrop-blur-sm p-3" style={{ height: 360 }}>
<div className="h-full w-full max-w-xl justify-self-center">
<Chart
type="radar"
labels={activeMapLabels} // z.B. ['Ancient','Anubis', ...]
height={"auto"}
datasets={[
{ label: teamLeft?.name ?? 'Links', data: teamRadarLeft,
borderColor: 'rgba(54,162,235,0.9)', backgroundColor: 'rgba(54,162,235,0.20)', borderWidth: 2 },
{ label: teamRight?.name ?? 'Rechts', data: teamRadarRight,
borderColor: 'rgba(255,99,132,0.9)', backgroundColor: 'rgba(255,99,132,0.20)', borderWidth: 2 },
]}
// Icons (array passt 1:1 zu labels) ODER als Mapping label->url
radarIcons={activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`)}
radarIconSize={28}
radarIconOffset={24}
// Skala + Offset (weil wir werte um +20 schieben)
radarMax={120}
radarStepSize={20}
radarAddRingOffset
/>
</div>
<div className="flex-1 min-h-0 grid place-items-center">
<div className="w-full max-w-xl h-[55vh] min-h-[420px]"> {/* feste Container-Höhe */}
<Chart
type="radar"
labels={activeMapLabels}
height="auto"
datasets={[ /* ... */ ]}
radarIcons={activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`)}
radarIconSize={32}
radarIconOffset={20}
radarHideTicks={true}
// ⬇️ Mapnamen unter den Icons zeigen
radarIconLabels={true}
radarIconLabelFont="12px Inter, system-ui, sans-serif"
radarIconLabelColor="#ffffff"
radarIconLabelMargin={4}
radarMax={120}
radarStepSize={20}
radarAddRingOffset={false} // <- aus, wenn du echte % zeigst
/>
</div>
</div>
)}

View File

@ -423,9 +423,21 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
// Tabellen-Layout
const ColGroup = () => (
<colgroup>
<col style={{width: '24%'}} />
<col style={{width: '8%'}} />
{Array.from({length: 13}).map((_, i) => <col key={i} style={{width: '5.666%'}} />)}
<col style={{ width: '25%' }} /> {/* Spieler */}
<col style={{ width: '8.5%' }} /> {/* Rank */}
<col style={{ width: '7%' }} /> {/* Aim */}
<col style={{ width: '5%' }} /> {/* K */}
<col style={{ width: '5%' }} /> {/* A */}
<col style={{ width: '5%' }} /> {/* D */}
<col style={{ width: '4%' }} /> {/* 1K */}
<col style={{ width: '4%' }} /> {/* 2K */}
<col style={{ width: '4%' }} /> {/* 3K */}
<col style={{ width: '4%' }} /> {/* 4K */}
<col style={{ width: '4%' }} /> {/* 5K */}
<col style={{ width: '5%' }} /> {/* K/D */}
<col style={{ width: '5%' }} /> {/* ADR */}
<col style={{ width: '7%' }} /> {/* HS% */}
<col style={{ width: '7.5%' }} /> {/* Damage (↑) */}
</colgroup>
)
@ -471,7 +483,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
{sorted.map((p) => (
<Table.Row key={p.user.steamId}>
<Table.Cell
className="flex items-center gap-2 py-1"
className="flex items-center"
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
@ -523,7 +535,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<Table.Cell>{p.stats?.fiveK ?? '-'}</Table.Cell>
<Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)} %</Table.Cell>
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
</Table.Row>
))}
@ -755,7 +767,9 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
/>
</span>
)}
{match.teamA?.name || sideLabel('A')}
{match.matchType === 'community'
? (match.teamA?.name ?? sideLabel('A'))
: sideLabel('A')}
</h2>
{showEditA && (
@ -799,7 +813,9 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
/>
</span>
)}
{match.teamB?.name || sideLabel('B')}
{match.matchType === 'community'
? (match.teamB?.name ?? sideLabel('B'))
: sideLabel('B')}
</h2>
{showEditB && (

View File

@ -1,17 +1,21 @@
'use client'
import {useEffect, useMemo, useRef, useState} from 'react'
import type {RefObject} from 'react'
import {useEffect, useRef, useState, type RefObject} from 'react'
export type SpyItem = { id: string; label: string }
type Props = {
items: SpyItem[]
/** Scroll-Container; wenn nicht gesetzt, wird `document` beobachtet */
containerRef?: RefObject<HTMLElement | null>
className?: string
activeClassName?: string
inactiveClassName?: string
/** Fixer Pixel-Offset (z. B. fixe Headerhöhe). Default 0 */
offset?: number
/** Hash der URL synchronisieren, wenn Section aktiv wird */
updateHash?: boolean
/** Dauer des programmatic scrolls (ms) sperrt solange den Observer */
smoothMs?: number
}
export default function ScrollSpyTabs({
@ -19,73 +23,93 @@ export default function ScrollSpyTabs({
containerRef,
className = 'flex flex-col gap-1',
activeClassName = 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white',
inactiveClassName = 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700'
inactiveClassName = 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700',
offset = 0,
updateHash = false,
smoothMs = 500,
}: Props) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
const observerRef = useRef<IntersectionObserver | null>(null)
const isProgrammaticRef = useRef(false)
const progTimerRef = useRef<number | null>(null)
// Helper: sichere Query (mit CSS.escape Fallback)
const qs = (root: Document | HTMLElement, id: string) => {
const esc = (window as any).CSS?.escape?.(id) ?? id.replace(/([ #.;?+*~\\':"!^$[\]()=>|\/@])/g, '\\$1')
return root.querySelector<HTMLElement>(`#${esc}`)
}
// Sichtbarkeits-Beobachtung
useEffect(() => {
const rootEl = containerRef?.current ?? null
const sections = items
.map(i => (rootEl ?? document).querySelector<HTMLElement>(`#${CSS.escape(i.id)}`))
.filter(Boolean) as HTMLElement[]
const rootNode: Document | HTMLElement = rootEl ?? document
const sections = items.map(i => qs(rootNode, i.id)).filter(Boolean) as HTMLElement[]
if (sections.length === 0) return
// etwas Toleranz: Sektion gilt als aktiv, wenn ~40% sichtbar sind
observerRef.current?.disconnect()
observerRef.current = new IntersectionObserver(
(entries) => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
() => {
if (isProgrammaticRef.current) return // während Smooth-Scroll keine Auto-Umschaltung
if (visible[0]) {
const id = (visible[0].target as HTMLElement).id
setActiveId(id)
} else {
// Fallback: oberste Sektion, wenn nichts “offiziell” intersected
const first = sections.find(s => {
const rect = s.getBoundingClientRect()
const top = rect.top - (rootEl?.getBoundingClientRect().top ?? 0)
return top >= -10 // nahe am Anfang
})
if (first) setActiveId(first.id)
const rootTop = rootEl ? rootEl.getBoundingClientRect().top : 0
const targetLine = rootTop + offset + 1
let best: { id: string; dist: number } | null = null
for (const s of sections) {
const top = s.getBoundingClientRect().top
const dist = Math.abs(top - targetLine)
const isCandidate = top <= targetLine + 1
if (isCandidate && (best === null || dist < best.dist)) best = { id: s.id, dist }
}
const nextActive = best?.id ?? sections[0].id
if (nextActive !== activeId) {
setActiveId(nextActive)
if (updateHash) history.replaceState(null, '', `#${nextActive}`)
}
},
{
root: rootEl ?? null,
// top padding, damit schon etwas früher aktiv markiert wird:
rootMargin: '-20% 0px -40% 0px',
threshold: [0.2, 0.4, 0.6, 0.8],
rootMargin: `-${Math.max(offset, 0)}px 0px -40% 0px`,
threshold: [0, 0.25, 0.5, 0.75, 1],
}
)
sections.forEach((s) => observerRef.current?.observe(s))
sections.forEach(s => observerRef.current!.observe(s))
return () => observerRef.current?.disconnect()
}, [items, containerRef])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, containerRef, offset, updateHash])
const onJump = (id: string) => {
const rootEl = containerRef?.current ?? null
const el = (rootEl ?? document).querySelector<HTMLElement>(`#${CSS.escape(id)}`)
const rootNode: Document | HTMLElement = rootEl ?? document
const el = qs(rootNode, id)
if (!el) return
// sofort aktiv setzen (optimistisch)
setActiveId(id)
if (updateHash) history.replaceState(null, '', `#${id}`)
// Observer kurz blockieren
isProgrammaticRef.current = true
if (progTimerRef.current) window.clearTimeout(progTimerRef.current)
progTimerRef.current = window.setTimeout(() => {
isProgrammaticRef.current = false
}, smoothMs)
if (rootEl) {
// innerhalb eines Scroll-Containers:
const rootTop = rootEl.getBoundingClientRect().top
const targetTop = el.getBoundingClientRect().top
const delta = targetTop - rootTop + rootEl.scrollTop - 12 /* kleiner offset */
rootEl.scrollTo({ top: delta, behavior: 'smooth' })
const delta = targetTop - rootTop + rootEl.scrollTop - offset
rootEl.scrollTo({ top: Math.max(delta, 0), behavior: 'smooth' })
} else {
// Fenster scrollen
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
const y = window.scrollY + el.getBoundingClientRect().top - offset
window.scrollTo({ top: Math.max(y, 0), behavior: 'smooth' })
}
}
return (
<nav className={className} aria-label="Section navigation" role="tablist" aria-orientation="vertical">
{items.map((it) => {
{items.map(it => {
const isActive = activeId === it.id
return (
<button

View File

@ -42,9 +42,9 @@ export default async function RootLayout({children, params}: Props) {
return (
<html lang={lang} suppressHydrationWarning>
<body className={`antialiased bg-white dark:bg-black min-h-dvh ${geistSans.variable} ${geistMono.variable}`}>
{/* ⬇️ volle Viewporthöhe + keine Fensterscroller */}
<body className={`antialiased bg-white dark:bg-black h-dvh overflow-hidden ${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{/* ⬇️ EIN globaler i18n-Provider um ALLES */}
<NextIntlClientProvider locale={lang} messages={messages}>
<Providers>
<SSEHandler />
@ -53,11 +53,18 @@ export default async function RootLayout({children, params}: Props) {
<ReadyOverlayHost />
<TelemetrySocket />
<div className="min-h-dvh grid grid-cols-1 sm:grid-cols-[16rem_1fr]">
<Sidebar />
<div className="min-w-0 flex flex-col">
<main className="flex-1 min-w-0 overflow-hidden">
<div className="h-full box-border p-4 sm:p-6">{children}</div>
{/* ⬇️ Container selbst ist 100vh hoch */}
<div className="h-dvh grid grid-cols-1 sm:grid-cols-[16rem_1fr]">
{/* Sidebar ggf. scrollfähig machen */}
<div className="min-h-0 overflow-y-auto">
<Sidebar />
</div>
{/* Rechte Spalte füllt Höhe; wichtig: min-h-0, damit child scrollen darf */}
<div className="min-w-0 flex flex-col h-dvh min-h-0">
{/* Nur HIER scrollen */}
<main className="flex-1 min-w-0 min-h-0 overflow-auto overscroll-contain">
<div className="h-full min-h-0 box-border p-4 sm:p-6 overscroll-contain">{children}</div>
</main>
<GameBannerSpacer className="hidden sm:block" />
</div>
@ -72,3 +79,4 @@ export default async function RootLayout({children, params}: Props) {
</html>
);
}

View File

@ -8,7 +8,7 @@ export default function AppearanceSection() {
const tSettings = useTranslations('settings')
return (
<section id="account" className="scroll-mt-16 pb-10">
<section id="appearance" className="scroll-mt-16 pb-10">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{tSettings("sections.appearance.short")}</h2>
<form className="border-t border-gray-200 dark:border-neutral-700">
<AppearanceSettings />

View File

@ -8,7 +8,7 @@ export default function UserSection() {
const tSettings = useTranslations('settings')
return (
<section id="account" className="scroll-mt-16 pb-10">
<section id="user" className="scroll-mt-16 pb-10">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{tSettings("sections.user.short")}</h2>
<form className="border-t border-gray-200 dark:border-neutral-700">
<UserSettings />

View File

@ -24,8 +24,9 @@ export default function SettingsLayoutSettings({ children }: { children: React.R
<div className="sticky top-0 pt-2">
<ScrollSpyTabs
items={items}
containerRef={mainRef} // <- rechter Scroll-Container
containerRef={mainRef}
className="flex flex-col gap-1"
updateHash
/>
</div>
</aside>

View File

@ -7,11 +7,13 @@ import UserSection from './_sections/UserSection'
export default function SettingsPage() {
return (
<Card maxWidth='full'>
<UserSection />
<PrivacySection />
<AccountSection />
<AppearanceSection />
</Card>
<div className="h-full min-h-0">
<Card maxWidth="full" height="100%" bodyScrollable>
<UserSection />
<PrivacySection />
<AccountSection />
<AppearanceSection />
</Card>
</div>
)
}

View File

@ -125,12 +125,22 @@ export async function PUT(
// 1) Match-Felder zusammenbauen
const updateData: any = {}
if (typeof title !== 'undefined') updateData.title = title
if (typeof matchType === 'string') updateData.matchType = matchType
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten
if (typeof title === 'string') updateData.title = title
if (typeof matchType === 'string') updateData.matchType = matchType
// ⬇️ Relationen korrekt updaten
if (typeof teamAId !== 'undefined') {
updateData.teamA = teamAId
? { connect: { id: teamAId } }
: { disconnect: true }
}
if (typeof teamBId !== 'undefined') {
updateData.teamB = teamBId
? { connect: { id: teamBId } }
: { disconnect: true }
}
// Zeiten parsen
const parsedMatchDate = parseDateOrNull(matchDate)
if (parsedMatchDate !== undefined) updateData.matchDate = parsedMatchDate
@ -165,12 +175,11 @@ export async function PUT(
if (baseDate) {
const opensAt = voteOpensAt(baseDate, leadMinutes)
if (!m.mapVote) {
// Neu anlegen
const mapPool = MAP_OPTIONS.filter(o => o.active).map(o => o.key)
await tx.mapVote.create({
data: {
matchId: m.id,
bestOf : (m.bestOf as 1|3|5) ?? 3,
bestOf : bestOf ?? 3, // ✅
mapPool,
currentIdx: 0,
locked: false,
@ -304,7 +313,7 @@ export async function PUT(
teamBId: updated.teamBId,
matchDate: updated.matchDate,
demoDate: updated.demoDate,
bestOf: updated.bestOf,
bestOf: updated.mapVote?.bestOf ?? 3,
mapVote: updated.mapVote,
}, { headers: { 'Cache-Control': 'no-store' } })
} catch (err) {
@ -357,7 +366,7 @@ export async function GET(
teamBId: match.teamBId,
matchDate: match.matchDate,
demoDate: match.demoDate,
bestOf: match.bestOf,
bestOf: match.mapVote?.bestOf ?? 3,
mapVote: {
id: match.mapVote?.id ?? null,
leadMinutes: match.mapVote?.leadMinutes ?? 60,

View File

@ -122,17 +122,18 @@ export async function POST (req: NextRequest) {
// 4) Match anlegen
const newMatch = await tx.match.create({
data: {
teamAId,
teamBId,
title : safeTitle,
description: safeDesc,
map : safeMap,
demoDate : plannedAt,
bestOf : bestOfInt,
// Teams per relation verbinden
teamA: { connect: { id: teamAId } },
teamB: { connect: { id: teamBId } },
// Optional: falls du am Match die Kader je Seite referenzieren möchtest
teamAUsers: aUse.length ? { connect: aUse.map(id => ({ steamId: id })) } : undefined,
teamBUsers: bUse.length ? { connect: bUse.map(id => ({ steamId: id })) } : undefined,
// Optional: Kader verknüpfen nur setzen, wenn nicht leer
...(aUse.length ? { teamAUsers: { connect: aUse.map(id => ({ steamId: id })) } } : {}),
...(bUse.length ? { teamBUsers: { connect: bUse.map(id => ({ steamId: id })) } } : {}),
},
})

View File

@ -370,7 +370,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -384,7 +384,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {

View File

@ -371,7 +371,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -385,7 +385,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {

View File

@ -370,7 +370,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -384,7 +384,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {