This commit is contained in:
Linrador 2025-10-13 13:05:08 +02:00
parent 6e4a9a77eb
commit 4e7ea7b8fd
43 changed files with 540 additions and 509 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"
},
@ -3287,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"
}
@ -3427,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"
@ -3893,7 +3890,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",
@ -4068,7 +4064,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",
@ -5417,6 +5412,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"
}
@ -5845,6 +5841,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"
},
@ -6032,7 +6029,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",
@ -6325,7 +6321,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",
@ -6342,6 +6339,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"
}
@ -6470,6 +6468,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"
}
@ -6756,6 +6755,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"
},
@ -6786,7 +6786,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.17.1",
@ -6795,7 +6796,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.17.1",
"@prisma/engines": "6.17.1"
@ -6912,7 +6912,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"
}
@ -6922,7 +6921,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"
},
@ -6992,7 +6990,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",
@ -7662,8 +7661,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",
@ -7720,7 +7718,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7946,7 +7943,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8047,7 +8043,6 @@
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@ -8212,7 +8207,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

@ -1,32 +1,39 @@
// /src/app/admin/server/page.tsx
// /src/app/[locale]/admin/server/page.tsx
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
import Card from '../../components/Card'
import ServerView from '../../components/admin/server/ServerView'
export const dynamic = 'force-dynamic'
// Wichtig: Promises wie in .next/types
type PageProps = {
params?: Promise<{ locale?: string }>
searchParams?: Promise<Record<string, string | string[] | undefined>>
}
async function ensureConfig() {
const cfg = await prisma.serverConfig.upsert({
return prisma.serverConfig.upsert({
where: { id: 'default' },
update: {},
create: {
id: 'default',
serverIp: '',
serverPassword: '', // ⬅️ neu
serverPassword: '',
pterodactylServerId: '',
pterodactylServerApiKey: '',
},
})
return cfg
}
export default async function AdminServerPage(req: NextRequest) {
const session = await getServerSession(authOptions(req))
export default async function AdminServerPage(_props: PageProps) {
// Falls du locale brauchst:
// const { locale } = (await _props.params) ?? {}
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as any | undefined
if (!me?.steamId || !me?.isAdmin) {
@ -47,7 +54,7 @@ export default async function AdminServerPage(req: NextRequest) {
const serverIp = String(formData.get('serverIp') ?? '').trim()
const serverId = String(formData.get('serverId') ?? '').trim()
const serverApiKey = String(formData.get('serverApiKey') ?? '').trim()
const serverPassword = String(formData.get('serverPassword') ?? '').trim() // ⬅️ neu
const serverPassword = String(formData.get('serverPassword') ?? '').trim()
const clientApiKey = String(formData.get('clientApiKey') ?? '').trim()
if (!serverIp) throw new Error('Server-IP darf nicht leer sein.')
@ -60,7 +67,7 @@ export default async function AdminServerPage(req: NextRequest) {
serverIp,
pterodactylServerId: serverId,
...(serverApiKey ? { pterodactylServerApiKey: serverApiKey } : {}),
...(serverPassword ? { serverPassword } : {}), // ⬅️ nur setzen, wenn übergeben
...(serverPassword ? { serverPassword } : {}),
},
})
@ -77,12 +84,7 @@ export default async function AdminServerPage(req: NextRequest) {
return (
<Card title="Serververwaltung" description="Hier kannst du die Servereinstellungen ändern" maxWidth="auto">
<ServerView
cfg={cfg}
meUser={meUser}
meSteamId={me.steamId}
onSave={save}
/>
<ServerView cfg={cfg} meUser={meUser} meSteamId={me.steamId} onSave={save} />
</Card>
)
}

View File

@ -1,8 +1,8 @@
// ───────────────────────────────────────────────────────────
// src/app/(admin)/admin/teams/[teamId]/page.tsx (SERVER)
// ───────────────────────────────────────────────────────────
// src/app/(admin)/admin/teams/[teamId]/page.tsx
import TeamAdminClient from './TeamAdminClient'
import type { AppPageProps } from '@/types/next'
export default function Page({ params }: { params: { teamId: string } }) {
return <TeamAdminClient teamId={params.teamId} />
export default async function Page({ params }: AppPageProps<{ teamId: string }>) {
const { teamId } = await params
return <TeamAdminClient teamId={teamId} />
}

View File

@ -1,3 +1,5 @@
// /src/app/[locale]]/components/SidebarFooter.ts
'use client'
import { useEffect, useState } from 'react'

View File

@ -1,16 +1,12 @@
// app/match-details/[matchId]/layout.tsx
// /src/app/[locale]/match-details/[matchId]/layout.tsx
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { MatchProvider } from './MatchContext'
import type { Match } from '../../../../types/match'
import https from 'https'
import { Agent } from 'undici'
export const dynamic = 'force-dynamic'
export const revalidate = 0
// (optional) falls du sicher Node Runtime willst:
// export const runtime = 'nodejs'
async function loadMatch(matchId: string): Promise<Match | null> {
const h = await headers()
@ -18,9 +14,7 @@ async function loadMatch(matchId: string): Promise<Match | null> {
const host = (h.get('x-forwarded-host') ?? h.get('host') ?? '').split(',')[0].trim()
const base = host ? `${proto}://${host}` : (process.env.NEXTAUTH_URL ?? 'http://localhost:3000')
// ⚠️ Nur in Dev benutzen!
const insecure = new Agent({ connect: { rejectUnauthorized: false } })
const init: any = { cache: 'no-store' }
if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') {
init.dispatcher = insecure
@ -31,18 +25,17 @@ async function loadMatch(matchId: string): Promise<Match | null> {
return res.json()
}
export default async function MatchLayout({
children,
params,
}: {
type Params = { matchId: string }
type Props = {
children: React.ReactNode
params: { matchId: string } | Promise<{ matchId: string }>
}) {
// In neueren Next-Versionen können params ein Promise sein:
const { matchId } = await Promise.resolve(params as any)
params: Promise<Params>
}
export default async function MatchLayout({ children, params }: Props) {
const { matchId } = await params
const match = await loadMatch(matchId)
if (!match) return notFound()
if (!match) notFound()
return <MatchProvider match={match}>{children}</MatchProvider>
}

View File

@ -1,6 +1,8 @@
// /src/app/profile/[steamId]/matches/page.tsx
// /src/app/[locale]/profile/[steamId]/matches/page.tsx
import MatchesList from '@/app/[locale]/components/profile/[steamId]/matches/MatchesList'
import type { AsyncParams } from '@/types/next'
export default function MatchesPage({ params }: { params: { steamId: string } }) {
return <MatchesList steamId={params.steamId} />
export default async function MatchesPage({ params }: AsyncParams<{ steamId: string }>) {
const { steamId } = await params
return <MatchesList steamId={steamId} />
}

View File

@ -1,6 +1,8 @@
// /src/app/profile/[steamId]/page.tsx
// /src/app/[locale]/profile/[steamId]/page.tsx
import Profile from '../../components/profile/[steamId]/Profile'
import type { AsyncParams } from '@/types/next'
export default function ProfilePage({ params }: { params: { steamId: string } }) {
return <Profile steamId={params.steamId} /> // ggf. Props geben
export default async function ProfilePage({ params }: AsyncParams<{ steamId: string; locale?: string }>) {
const { steamId } = await params
return <Profile steamId={steamId} />
}

View File

@ -1,5 +1,7 @@
// /src/app/[locale]/profile/[steamId]/stats/page.tsx
import StatsView from '@/app/[locale]/components/profile/[steamId]/stats/StatsView'
import { MatchStats } from '@/types/match'
import type { AsyncParams } from '@/types/next'
async function getStats(steamId: string) {
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
@ -8,12 +10,14 @@ async function getStats(steamId: string) {
return res.json()
}
export default async function StatsPage({ params }: { params: { steamId: string } }) {
const data = await getStats(params.steamId)
export default async function StatsPage({ params }: AsyncParams<{ steamId: string }>) {
const { steamId } = await params
const data = await getStats(steamId)
if (!data) return <p>Keine Statistiken verfügbar.</p>
return (
<StatsView
steamId={params.steamId}
steamId={steamId}
stats={{ matches: data.stats as MatchStats[] }}
/>
)

View File

@ -1,10 +1,10 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
export default async function ProfileRedirectPage(req: NextRequest) {
const session = await getServerSession(authOptions(req))
export default async function ProfileRedirectPage() {
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
redirect('/login') // Wenn nicht eingeloggt, zur Loginseite

View File

@ -1,12 +1,12 @@
// /src/app/radar/page.tsx
// /src/app/[locale]/radar/page.tsx
import Card from '../components/Card'
import LiveRadar from '../components/radar/LiveRadar';
import LiveRadar from '../components/radar/LiveRadar'
export default function RadarPage({ params }: { params: { matchId: string } }) {
export default function RadarPage() {
return (
<Card maxWidth="full" height="inherit">
<LiveRadar />
</Card>
);
}
)
}

View File

@ -0,0 +1,327 @@
// /src/app/[locale]/team/[teamId]/TeamClient.tsx
'use client'
import { useEffect, useMemo, useState, KeyboardEvent, MouseEvent, ChangeEvent } from 'react'
import { useRouter } from 'next/navigation'
import LoadingSpinner from '../../components/LoadingSpinner'
import Card from '../../components/Card'
import PremierRankBadge from '../../components/PremierRankBadge'
import { Player, Team } from '@/types/team'
/* ---------- kleine Helfer ---------- */
function uniqBySteamId<T extends { steamId: string }>(list: T[]): T[] {
const seen = new Set<string>()
const out: T[] = []
for (const p of list) {
if (!seen.has(p.steamId)) {
seen.add(p.steamId)
out.push(p)
}
}
return out
}
function byName<T extends { name: string }>(a: T, b: T) {
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' })
}
function classNames(...xs: Array<string | false | null | undefined>) {
return xs.filter(Boolean).join(' ')
}
/* ---------- Hauptseite ---------- */
export default function TeamDetailClient({ teamId }: { teamId: string }) {
const router = useRouter()
const [team, setTeam] = useState<Team | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [q, setQ] = useState('')
const [seg, setSeg] = useState<'active' | 'inactive' | 'invited'>('active')
useEffect(() => {
let alive = true
;(async () => {
try {
setLoading(true)
setError(null)
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
if (res.status === 404) {
router.replace('/404')
return
}
if (!res.ok) throw new Error('Team konnte nicht geladen werden')
const data: Team = await res.json()
if (alive) setTeam(data)
} catch (e: any) {
if (alive) setError(e?.message ?? 'Unbekannter Fehler')
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [teamId, router])
/* ---------- Ableitungen ---------- */
const members = useMemo(() => {
if (!team) return []
const all = [
...(team.leader ? [team.leader] : []),
...team.activePlayers,
...team.inactivePlayers,
]
// uniq + Leader nach vorne
const uniq = uniqBySteamId(all).sort(byName)
if (team.leader) {
const i = uniq.findIndex((p) => p.steamId === team.leader!.steamId)
if (i > 0) {
const [lead] = uniq.splice(i, 1)
uniq.unshift(lead)
}
}
return uniq
}, [team])
const counts = useMemo(
() => ({
active: team?.activePlayers.length ?? 0,
inactive: team?.inactivePlayers.length ?? 0,
invited: team?.invitedPlayers.length ?? 0,
total: members.length,
}),
[team, members]
)
const filteredList = useMemo(() => {
if (!team) return []
const norm = (s: string) => s.toLowerCase().normalize('NFKD')
const search = norm(q)
const matchQ = (p: { name: string; location?: string; steamId: string }) => {
if (!search) return true
return (
norm(p.name).includes(search) ||
norm(p.location ?? '').includes(search) ||
norm(p.steamId).includes(search)
)
}
if (seg === 'active') {
return uniqBySteamId(team.activePlayers).filter(matchQ).sort(byName)
}
if (seg === 'inactive') {
return uniqBySteamId(team.inactivePlayers).filter(matchQ).sort(byName)
}
// invited
return uniqBySteamId(team.invitedPlayers).filter(matchQ).sort(byName)
}, [team, q, seg])
/* ---------- Interaktionen ---------- */
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
const onCardClick =
(steamId: string) =>
(e: MouseEvent) => {
e.preventDefault()
goToProfile(steamId)
}
const onCardKey =
(steamId: string) =>
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
goToProfile(steamId)
}
}
/* ---------- Render ---------- */
if (loading) {
return (
<Card maxWidth="auto">
<div className="py-10 flex justify-center">
<LoadingSpinner />
</div>
</Card>
)
}
if (error) {
return (
<Card maxWidth="auto">
<div className="p-4 text-red-600">{error}</div>
</Card>
)
}
if (!team) return null
return (
<Card maxWidth="auto">
{/* Header */}
<div className="mb-5 sm:mb-6 flex items-center gap-4">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt={team.name || 'Teamlogo'}
className="w-14 h-14 sm:w-16 sm:h-16 rounded-full object-cover border border-gray-200 dark:border-neutral-700"
/>
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-neutral-100 truncate">
{team.name}
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-400">
{counts.total} Mitglied{counts.total === 1 ? '' : 'er'}
{team.leader?.name ? (
<span className="ml-2 text-gray-400 dark:text-neutral-500"> Leader: {team.leader.name}</span>
) : null}
</p>
</div>
</div>
{/* Toolbar */}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Suche */}
<div className="relative w-full sm:max-w-xs">
<input
type="search"
value={q}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
placeholder="Spieler suchen…"
className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm
placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500
dark:border-neutral-700 dark:bg-neutral-800 dark:text-white"
aria-label="Spieler suchen"
/>
<svg
aria-hidden
viewBox="0 0 24 24"
className="pointer-events-none absolute right-3 top-2.5 h-5 w-5 text-gray-400"
>
<path
fill="currentColor"
d="M21 20l-5.8-5.8A7 7 0 1 0 4 11a7 7 0 0 0 11.2 5.2L21 20zM6 11a5 5 0 1 1 10.001.001A5 5 0 0 1 6 11z"
/>
</svg>
</div>
{/* Segment */}
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
{([
{ key: 'active', label: `Aktiv (${counts.active})` },
{ key: 'inactive', label: `Inaktiv (${counts.inactive})` },
{ key: 'invited', label: `Eingeladen (${counts.invited})` },
] as const).map((opt) => (
<button
key={opt.key}
onClick={() => setSeg(opt.key)}
className={classNames(
'px-3 py-1.5 text-sm transition',
seg === opt.key
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Grid */}
{filteredList.length === 0 ? (
<div
className="rounded-lg border border-dashed border-gray-200 p-8 text-center text-sm
text-gray-500 dark:border-neutral-700 dark:text-neutral-400"
>
{q ? (
<>
Keine Treffer für <span className="font-medium">{q}</span>.
</>
) : seg === 'active' ? (
'Keine aktiven Mitglieder.'
) : seg === 'inactive' ? (
'Keine inaktiven Mitglieder.'
) : (
'Keine eingeladenen Spieler.'
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredList.map((m) => (
<div
key={m.steamId}
role="button"
tabIndex={0}
aria-label={`${m.name} Profil öffnen`}
onClick={onCardClick(m.steamId)}
onKeyDown={onCardKey(m.steamId)}
className="
group flex items-center gap-4 p-3 rounded-lg border
border-gray-200 dark:border-neutral-700
bg-white dark:bg-neutral-800 shadow-sm
transition cursor-pointer focus:outline-none
hover:shadow-md hover:bg-gray-50 dark:hover:bg-neutral-700
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
focus:ring-offset-white dark:focus:ring-offset-neutral-800
"
>
<img
src={m.avatar}
alt={m.name}
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
onClick={(e) => {
e.stopPropagation()
goToProfile(m.steamId)
}}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 dark:text-neutral-100 truncate">{m.name}</span>
{/* Leader-Badge (falls vorhanden) */}
{team.leader?.steamId === m.steamId && (
<span
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded
bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
>
Leader
</span>
)}
{/* Aktiv/Inaktiv */}
{seg !== 'invited' && (
<span
className={classNames(
'inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded',
team.activePlayers.some((a) => a.steamId === m.steamId)
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
)}
>
{team.activePlayers.some((a) => a.steamId === m.steamId) ? 'Aktiv' : 'Inaktiv'}
</span>
)}
{/* Rank-Badge (0 => unranked Style in deiner Badge) */}
<PremierRankBadge rank={(m as Player).premierRank ?? 0} />
</div>
{(m as Player).location && (
<div className="text-xs text-gray-500 dark:text-neutral-400">{(m as Player).location}</div>
)}
</div>
{/* Chevron */}
<svg aria-hidden viewBox="0 0 24 24" className="w-4 h-4 text-gray-400 group-hover:text-gray-500 transition">
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
</svg>
</div>
))}
</div>
)}
</Card>
)
}

View File

@ -1,326 +1,8 @@
// /src/app/team/[teamId]/page.tsx
'use client'
// /src/app/[locale]/team/[teamId]/page.tsx
import type { AppPageProps } from '@/types/next'
import TeamDetailClient from './TeamClient'
import {useEffect, useMemo, useState, KeyboardEvent, MouseEvent, ChangeEvent} from 'react'
import {useRouter} from 'next/navigation'
import LoadingSpinner from '../../components/LoadingSpinner'
import Card from '../../components/Card'
import PremierRankBadge from '../../components/PremierRankBadge'
type Player = {
steamId: string
name: string
avatar: string
location?: string
premierRank?: number
isAdmin?: boolean
}
type InvitedPlayer = {
invitationId: string
steamId: string
name: string
avatar: string
location?: string
premierRank?: number
isAdmin?: boolean
}
type TeamResponse = {
id: string
name: string
logo?: string | null
leader?: Player | null
activePlayers: Player[]
inactivePlayers: Player[]
invitedPlayers: InvitedPlayer[]
}
/* ---------- kleine Helfer ---------- */
function uniqBySteamId<T extends {steamId: string}>(list: T[]): T[] {
const seen = new Set<string>()
const out: T[] = []
for (const p of list) {
if (!seen.has(p.steamId)) {
seen.add(p.steamId)
out.push(p)
}
}
return out
}
function byName<T extends {name: string}>(a: T, b: T) {
return a.name.localeCompare(b.name, 'de', {sensitivity: 'base'})
}
function classNames(...xs: Array<string | false | null | undefined>) {
return xs.filter(Boolean).join(' ')
}
/* ---------- Hauptseite ---------- */
export default function TeamDetailPage({ params }: { params: { teamId: string } }) {
const router = useRouter()
const [team, setTeam] = useState<TeamResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// UI-State
const [q, setQ] = useState('') // Suche
const [seg, setSeg] = useState<'active'|'inactive'|'invited'>('active') // Segment
useEffect(() => {
let alive = true
;(async () => {
try {
setLoading(true)
setError(null)
const res = await fetch(`/api/team/${params.teamId}`, { cache: 'no-store' })
if (res.status === 404) {
router.replace('/404')
return
}
if (!res.ok) throw new Error('Team konnte nicht geladen werden')
const data: TeamResponse = await res.json()
if (alive) setTeam(data)
} catch (e: any) {
if (alive) setError(e?.message ?? 'Unbekannter Fehler')
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [params.teamId, router])
/* ---------- Ableitungen ---------- */
const members = useMemo(() => {
if (!team) return []
const all = [
...(team.leader ? [team.leader] : []),
...team.activePlayers,
...team.inactivePlayers,
]
// uniq + Leader nach vorne
const uniq = uniqBySteamId(all).sort(byName)
if (team.leader) {
const i = uniq.findIndex(p => p.steamId === team.leader!.steamId)
if (i > 0) {
const [lead] = uniq.splice(i, 1)
uniq.unshift(lead)
}
}
return uniq
}, [team])
const counts = useMemo(() => ({
active: team?.activePlayers.length ?? 0,
inactive: team?.inactivePlayers.length ?? 0,
invited: team?.invitedPlayers.length ?? 0,
total: members.length,
}), [team, members])
const filteredList = useMemo(() => {
if (!team) return []
const norm = (s: string) => s.toLowerCase().normalize('NFKD')
const search = norm(q)
const matchQ = (p: {name: string; location?: string; steamId: string}) => {
if (!search) return true
return (
norm(p.name).includes(search) ||
norm(p.location ?? '').includes(search) ||
norm(p.steamId).includes(search)
)
}
if (seg === 'active') {
return uniqBySteamId(team.activePlayers).filter(matchQ).sort(byName)
}
if (seg === 'inactive') {
return uniqBySteamId(team.inactivePlayers).filter(matchQ).sort(byName)
}
// invited
return uniqBySteamId(team.invitedPlayers).filter(matchQ).sort(byName)
}, [team, q, seg])
/* ---------- Interaktionen ---------- */
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
const onCardClick = (steamId: string) => (e: MouseEvent) => { e.preventDefault(); goToProfile(steamId) }
const onCardKey = (steamId: string) => (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToProfile(steamId) }
}
/* ---------- Render ---------- */
if (loading) {
return (
<Card maxWidth="auto">
<div className="py-10 flex justify-center"><LoadingSpinner /></div>
</Card>
)
}
if (error) {
return (
<Card maxWidth="auto">
<div className="p-4 text-red-600">{error}</div>
</Card>
)
}
if (!team) return null
return (
<Card maxWidth="auto">
{/* Header */}
<div className="mb-5 sm:mb-6 flex items-center gap-4">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt={team.name}
className="w-14 h-14 sm:w-16 sm:h-16 rounded-full object-cover border border-gray-200 dark:border-neutral-700"
/>
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-neutral-100 truncate">
{team.name}
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-400">
{counts.total} Mitglied{counts.total === 1 ? '' : 'er'}
{team.leader?.name ? (
<span className="ml-2 text-gray-400 dark:text-neutral-500"> Leader: {team.leader.name}</span>
) : null}
</p>
</div>
</div>
{/* Toolbar */}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Suche */}
<div className="relative w-full sm:max-w-xs">
<input
type="search"
value={q}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
placeholder="Spieler suchen…"
className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm
placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500
dark:border-neutral-700 dark:bg-neutral-800 dark:text-white"
aria-label="Spieler suchen"
/>
<svg aria-hidden viewBox="0 0 24 24" className="pointer-events-none absolute right-3 top-2.5 h-5 w-5 text-gray-400">
<path fill="currentColor" d="M21 20l-5.8-5.8A7 7 0 1 0 4 11a7 7 0 0 0 11.2 5.2L21 20zM6 11a5 5 0 1 1 10.001.001A5 5 0 0 1 6 11z"/>
</svg>
</div>
{/* Segment */}
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
{([
{key: 'active', label: `Aktiv (${counts.active})`},
{key: 'inactive', label: `Inaktiv (${counts.inactive})`},
{key: 'invited', label: `Eingeladen (${counts.invited})`},
] as const).map(opt => (
<button
key={opt.key}
onClick={() => setSeg(opt.key)}
className={classNames(
'px-3 py-1.5 text-sm transition',
seg === opt.key
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Grid */}
{filteredList.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 p-8 text-center text-sm
text-gray-500 dark:border-neutral-700 dark:text-neutral-400">
{q
? <>Keine Treffer für <span className="font-medium">{q}</span>.</>
: seg === 'active'
? 'Keine aktiven Mitglieder.'
: seg === 'inactive'
? 'Keine inaktiven Mitglieder.'
: 'Keine eingeladenen Spieler.'}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredList.map((m) => (
<div
key={m.steamId}
role="button"
tabIndex={0}
aria-label={`${m.name} Profil öffnen`}
onClick={onCardClick(m.steamId)}
onKeyDown={onCardKey(m.steamId)}
className="
group flex items-center gap-4 p-3 rounded-lg border
border-gray-200 dark:border-neutral-700
bg-white dark:bg-neutral-800 shadow-sm
transition cursor-pointer focus:outline-none
hover:shadow-md hover:bg-gray-50 dark:hover:bg-neutral-700
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
focus:ring-offset-white dark:focus:ring-offset-neutral-800
"
>
<img
src={m.avatar}
alt={m.name}
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
onClick={(e) => { e.stopPropagation(); goToProfile(m.steamId) }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 dark:text-neutral-100 truncate">
{m.name}
</span>
{/* Leader-Badge (falls vorhanden) */}
{team.leader?.steamId === m.steamId && (
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded
bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Leader
</span>
)}
{/* Aktiv/Inaktiv sichtbar, aber dezenter */}
{seg !== 'invited' && (
<span
className={classNames(
'inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded',
(team.activePlayers.some(a => a.steamId === m.steamId))
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
)}
>
{team.activePlayers.some(a => a.steamId === m.steamId) ? 'Aktiv' : 'Inaktiv'}
</span>
)}
{/* PremierRankBadge IMMER rendern (0 => „unranked“ Style in deiner Badge) */}
<PremierRankBadge rank={(m as Player).premierRank ?? 0} />
</div>
{(m as Player).location && (
<div className="text-xs text-gray-500 dark:text-neutral-400">
{(m as Player).location}
</div>
)}
</div>
{/* Chevron */}
<svg aria-hidden viewBox="0 0 24 24" className="w-4 h-4 text-gray-400 group-hover:text-gray-500 transition">
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
</svg>
</div>
))}
</div>
)}
</Card>
)
export default async function Page({ params }: AppPageProps<{ teamId: string }>) {
const { teamId } = await params
return <TeamDetailClient teamId={teamId} />
}

View File

@ -1,15 +1,12 @@
// /src/app/api/auth/[...nextauth]/route.ts
import type { NextRequest } from 'next/server';
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
import type { NextRequest } from 'next/server'
import NextAuth from 'next-auth'
import { buildAuthOptions } from '@/lib/auth'
export const runtime = 'nodejs'; // Steam/OpenID braucht Node
export const dynamic = 'force-dynamic'; // kein Static Caching
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest, ctx: any) {
return NextAuth(req, ctx, authOptions(req));
}
const handler = (req: NextRequest, ctx: any) =>
NextAuth(req, ctx, buildAuthOptions(req))
export async function POST(req: NextRequest, ctx: any) {
return NextAuth(req, ctx, authOptions(req));
}
export { handler as GET, handler as POST }

View File

@ -2,12 +2,12 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { decrypt, encrypt } from '@/lib/crypto'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) {
@ -30,7 +30,7 @@ export async function GET(req: NextRequest) {
}
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) {

View File

@ -1,7 +1,7 @@
// /src/app/api/cs2/server/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs'
@ -15,7 +15,7 @@ function buildConnectHref(serverIp: string, password?: string | null, port = 270
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId?: string } | undefined
if (!me?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })

View File

@ -2,7 +2,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs'
@ -17,7 +17,7 @@ function buildPanelUrl(base: string, serverId: string) {
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId?: string; isAdmin?: boolean } | undefined
if (!me?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
if (!me?.isAdmin) return NextResponse.json({ error: 'forbidden' }, { status: 403 })

View File

@ -2,13 +2,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const EXPIRY_DAYS = 30
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) {
@ -48,7 +48,7 @@ export async function GET(req: NextRequest) {
}
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) {

View File

@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
if (!me?.isAdmin) {
return NextResponse.json({ message: 'Nur Admins dürfen löschen.' }, { status: 403 })

View File

@ -1,7 +1,7 @@
// /src/app/api/matches/[matchId]/mapvote/admin-edit/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -115,7 +115,7 @@ function shapeStateSlim(vote: any) {
// - enabled=true: setzt adminEditingBy = me.steamId (falls schon anderer Admin aktiv -> 409, außer force)
// - enabled=false: cleart adminEditingBy, wenn ich der aktive Editor bin (oder force)
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
if (!me.isAdmin) return NextResponse.json({ message: 'Nur Admins' }, { status: 403 })

View File

@ -1,7 +1,7 @@
// /src/app/api/matches/[id]/mapvote/reset/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import { MAP_OPTIONS } from '@/lib/mapOptions'
@ -61,7 +61,7 @@ function buildMapVisuals(matchId: string, mapPool: string[]) {
}
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -2,7 +2,7 @@
import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -88,8 +88,6 @@ async function sendServerCommand(command: string) {
cache: 'no-store',
})
console.log(res);
if (res.status === 204) {
console.log('[mapvote] Command OK (204):', command)
return
@ -620,7 +618,7 @@ export async function GET(req: NextRequest, { params }: { params: { matchId: str
/* -------------------- POST ------------------- */
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -1,7 +1,7 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import { MapVoteAction } from '@/generated/prisma'
@ -85,7 +85,7 @@ export async function PUT(
const id = params?.matchId
if (!id) return NextResponse.json({ error: 'Missing matchId in route params' }, { status: 400 })
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
@ -333,7 +333,7 @@ export async function GET(
}
// Falls Lesen nur für Admin/Leader erlaubt sein soll, Auth prüfen:
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user
if (!me?.steamId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -56,7 +56,7 @@ export async function GET(req: NextRequest, { params }: { params: { matchId: str
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
// ⬇️ Session holen, um myReady zu berechnen
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId?: string } | undefined
const mySteamId = me?.steamId
@ -103,7 +103,7 @@ export async function GET(req: NextRequest, { params }: { params: { matchId: str
/** ---- POST: mich als bereit markieren ---- */
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user as { steamId?: string } | undefined
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -2,7 +2,7 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import {
isFuture,
buildCommunityFuturePayload,
@ -140,7 +140,7 @@ export async function GET(_: Request, { params }: { params: { matchId: string }
/* ───────────────────────── PUT ───────────────────────── */
export async function PUT(req: NextRequest, { params }: { params: { matchId: string } }) {
const id = params.matchId
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const me = session?.user
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
@ -348,7 +348,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
/* ───────────────────────── DELETE ───────────────────────── */
export async function DELETE(req: NextRequest, { params }: { params: { matchId: string } }) {
const id = params.matchId
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.isAdmin) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
try {
await prisma.matchPlayer.deleteMany({ where: { matchId: id } })

View File

@ -1,7 +1,7 @@
// /src/app/api/matches/create/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -39,7 +39,7 @@ const ACTIVE_DUTY = [
export async function POST (req: NextRequest) {
// ── Auth: nur Admins
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}

View File

@ -1,6 +1,6 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { NextResponse, type NextRequest } from 'next/server'
// ✅ Neue Notification erstellen
@ -24,7 +24,7 @@ export async function POST(req: NextRequest) {
// ✅ Notifications für aktuellen User laden
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.id) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
@ -40,7 +40,7 @@ export async function GET(req: NextRequest) {
// ✅ Alle Notifications auf "gelesen" setzen
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.id) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -1,7 +1,7 @@
// app/api/notifications/mark-all-read/route.ts
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { NextResponse, type NextRequest } from 'next/server'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -9,7 +9,7 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -1,12 +1,12 @@
// app/api/notifications/mark-read/[id]/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -1,11 +1,11 @@
// /api/notifications/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -1,9 +1,9 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session || !session.user?.steamId) {
return NextResponse.json(

View File

@ -1,7 +1,7 @@
// /src/app/api/team/delete/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -9,7 +9,7 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
const isAdmin = !!session?.user?.isAdmin
if (!steamId) {

View File

@ -2,7 +2,7 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const dynamic = 'force-dynamic'
@ -12,7 +12,7 @@ export async function POST(
{ params }: { params: { action: 'accept' | 'reject' } }
) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const leaderSteamId = session?.user?.steamId
if (!leaderSteamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -2,13 +2,13 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) {
try {
// ── Session ──────────────────────────────────────────────
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}

View File

@ -1,12 +1,12 @@
// /src/app/api/user/activity/route.ts
import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId // <-- hier definieren
if (!steamId) {

View File

@ -1,12 +1,12 @@
// /src/app/api/user/away/route.ts
import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) {

View File

@ -1,12 +1,12 @@
// /src/app/api/user/invitations/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })

View File

@ -1,12 +1,12 @@
// /src/app/api/user/offline/route.ts
import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
if (!steamId) return NextResponse.json({ ok: true }, { status: 200 }) // still return 200 for beacon

View File

@ -1,11 +1,11 @@
// src/app/api/user/privacy/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
@ -19,7 +19,7 @@ export async function GET(req: NextRequest) {
}
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}

View File

@ -1,7 +1,7 @@
// /src/app/api/user/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { sessionAuthOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic'; // kein Static Caching

View File

@ -1,7 +1,7 @@
// app/api/user/timezone/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { sessionAuthOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs' // falls nötig
@ -20,7 +20,7 @@ function isValidIanaOrNull(v: unknown): v is string | null {
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Not authenticated' }, { status: 401 })
}
@ -37,7 +37,7 @@ export async function GET(req: NextRequest) {
export async function PUT(req: NextRequest) {
try {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Not authenticated' }, { status: 401 })
}

View File

@ -1,6 +1,6 @@
// /src/lib/auth.ts
import type { NextAuthOptions } from 'next-auth'
import { NextRequest } from 'next/server'
import type { NextRequest } from 'next/server'
import Steam from 'next-auth-steam'
import { prisma } from './prisma'
import type { SteamProfile } from '@/types/steam'
@ -21,12 +21,16 @@ function guessTzFromCountry(cc?: string | null): string | null {
return null
}
// 👉 Login-/Auth-Factory mit echtem Request (für /api/auth/... Routen, SignIn etc.)
export const authOptions = (req: NextRequest): NextAuthOptions => ({
/**
* Für /api/auth/[...nextauth] Routen benötigt den echten Request,
* weil `next-auth-steam` den Request abgreift.
*/
export const buildAuthOptions = (req: NextRequest): NextAuthOptions => ({
secret: process.env.NEXTAUTH_SECRET,
providers: [
Steam(req, { clientSecret: process.env.STEAM_API_KEY! }),
],
session: { strategy: 'jwt' },
callbacks: {
async jwt({ token, account, profile }) {
if (account && profile) {
@ -68,31 +72,38 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
token.image = steamProfile.avatarfull
}
// User-Infos (Team/Admin/TZ) in den Token spiegeln
const userInDb = await prisma.user.findUnique({
where: { steamId: token.steamId || token.sub || '' },
where: { steamId: (token as any).steamId || token.sub || '' },
select: { teamId: true, isAdmin: true, steamId: true, timeZone: true },
})
if (userInDb) {
token.team = userInDb.teamId ?? null
token.isAdmin = userInDb.steamId === '76561198000414190' ? true : userInDb.isAdmin ?? false
token.timeZone = userInDb.timeZone ?? undefined
;(token as any).team = userInDb.teamId ?? null
;(token as any).isAdmin =
userInDb.steamId === '76561198000414190' ? true : userInDb.isAdmin ?? false
;(token as any).timeZone = userInDb.timeZone ?? undefined
} else {
token.timeZone = undefined
;(token as any).timeZone = undefined
}
if (token.steamId) {
await syncFaceitProfile(prisma, token.steamId)
// Faceit-Sync „best effort“
if ((token as any).steamId) {
try {
await syncFaceitProfile(prisma, (token as any).steamId)
} catch {
// bewusst leise
}
}
return token
},
async session({ session, token }) {
if (!token.steamId) throw new Error('steamId is missing in token')
if (!(token as any).steamId) throw new Error('steamId is missing in token')
session.user = {
...session.user,
steamId: token.steamId,
steamId: (token as any).steamId,
name: token.name,
image: token.image,
team: (token as any).team ?? null,
@ -105,28 +116,29 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
},
redirect({ url, baseUrl }) {
const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`);
const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`);
if (isSignOut) return `${baseUrl}/`;
if (isSignIn || url === baseUrl) return `${baseUrl}/dashboard`;
return url.startsWith(baseUrl) ? url : baseUrl;
const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`)
const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`)
if (isSignOut) return `${baseUrl}/`
if (isSignIn || url === baseUrl) return `${baseUrl}/dashboard`
return url.startsWith(baseUrl) ? url : baseUrl
},
},
session: { strategy: 'jwt' },
})
// ⚠️ NEU: Minimal-Options NUR für getServerSession() in App-Routen/Server Actions
// → Kein Provider nötig (wir lesen nur die vorhandene Session)
/**
* Für getServerSession() in App-Routen/Server Actions/Pages.
* Keine Provider nötig wir lesen nur die bereits bestehende Session.
*/
export const sessionAuthOptions: NextAuthOptions = {
secret: process.env.NEXTAUTH_SECRET,
providers: [], // ✅ satisfies NextAuthOptions, triggert keine Provider-Init
providers: [], // wichtig: keine Initialisierung eines Providers hier
session: { strategy: 'jwt' },
callbacks: {
async session({ session, token }) {
if (!token.steamId) throw new Error('steamId is missing in token')
if (!(token as any).steamId) throw new Error('steamId is missing in token')
session.user = {
...session.user,
steamId: token.steamId,
steamId: (token as any).steamId,
name: token.name,
image: token.image,
team: (token as any).team ?? null,
@ -138,5 +150,6 @@ export const sessionAuthOptions: NextAuthOptions = {
},
}
// ❌ WEG DAMIT: Das verursacht den Crash beim Import!
// export const baseAuthOptions: NextAuthOptions = authOptions({} as NextRequest)
// ⚠️ Wichtig: KEIN export von irgendwas wie `authOptions({} as NextRequest)`.
// Das würde beim Import in Pages sofort den Provider initialisieren und
// zu Build-/Type-Fehlern führen.

View File

@ -1,28 +1,33 @@
// types/next-auth.d.ts
// src/types/next-auth.d.ts
import { DefaultSession, DefaultUser } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: DefaultSession['user'] & {
steamId?: string
isAdmin?: boolean
team?: string | null
}
user: {
steamId: string
isAdmin: boolean
team: string | null
timeZone: string | null
name?: string | null
image?: string | null
} & DefaultSession['user']
}
interface User extends DefaultUser {
steamId: string
isAdmin: boolean
team?: string | null
timeZone?: string | null
}
}
declare module 'next-auth/jwt' {
interface JWT {
steamId?: string
isAdmin?: boolean
team?: string | null
name?: string
image?: string
steamId: string
isAdmin: boolean
team: string | null
timeZone: string | null
name?: string | null
image?: string | null
}
}

8
src/types/next.ts Normal file
View File

@ -0,0 +1,8 @@
// /src/types/next.ts
export type AppPageProps<P extends Record<string, string> = {}> = {
params: Promise<P & { locale?: string }>
searchParams?: Promise<Record<string, string | string[] | undefined>>
}
export type AsyncParams<T> = { params: Promise<T> }
export type AsyncSearchParams<T = Record<string, unknown>> = { searchParams?: Promise<T> }