updated for build

This commit is contained in:
Linrador 2025-10-16 12:42:25 +02:00
parent 844ac4fe33
commit 386f701ad5
14 changed files with 497 additions and 371 deletions

View File

@ -10,7 +10,7 @@ type ButtonProps = {
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
onToggle?: (open: boolean) => void onToggle?: (open: boolean) => void
modalId?: string modalId?: string
color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'transparent' color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'yellow' | 'transparent'
variant?: 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link' variant?: 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link'
/** Steuert NUR Höhe/Abstände */ /** Steuert NUR Höhe/Abstände */
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full' size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
@ -80,57 +80,73 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
${sizeClasses[size] || sizeClasses['md']} ${sizeClasses[size] || sizeClasses['md']}
inline-flex items-center gap-x-2 ${textSizeClasses[textSize] || 'text-sm'} inline-flex items-center gap-x-2 ${textSizeClasses[textSize] || 'text-sm'}
font-medium rounded-lg leading-none font-medium rounded-lg leading-none
focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed focus:outline-hidden
disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
` `
const variants: Record<string, Record<string, string>> = { const variants: Record<NonNullable<ButtonProps['variant']>, Record<NonNullable<ButtonProps['color']>, string>> = {
solid: { solid: {
blue: 'bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700', gray: 'border border-transparent bg-gray-800 text-white hover:bg-gray-900 focus:bg-gray-900 dark:bg-white dark:text-neutral-800',
red: 'bg-red-600 text-white hover:bg-red-700 focus:bg-red-700', // Preline nutzt bei Blau 600/700
gray: 'bg-gray-600 text-white hover:bg-gray-700 focus:bg-gray-700', blue: 'border border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700',
teal: 'bg-teal-600 text-white hover:bg-teal-700 focus:bg-teal-700', teal: 'border border-transparent bg-teal-500 text-white hover:bg-teal-600 focus:bg-teal-600',
green: 'bg-green-600 text-white hover:bg-green-700 focus:bg-green-700', red: 'border border-transparent bg-red-500 text-white hover:bg-red-600 focus:bg-red-600',
transparent: 'bg-transparent-600 text-white hover:bg-transparent-700 focus:bg-transparent-700', yellow:'border border-transparent bg-yellow-500 text-white hover:bg-yellow-600 focus:bg-yellow-600',
// Nicht im Preline-Snippet, aber konsistent:
green: 'border border-transparent bg-green-500 text-white hover:bg-green-600 focus:bg-green-600',
// "transparent-600" gibt es in Tailwind nicht neutral halten:
transparent: 'border border-transparent bg-transparent text-inherit',
}, },
outline: { outline: {
blue: 'border border-gray-200 text-gray-500 hover:border-blue-600 hover:text-blue-600 focus:border-blue-600 focus:text-blue-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-blue-500 dark:hover:border-blue-600 dark:focus:text-blue-500 dark:focus:border-blue-600', gray: 'border border-gray-800 text-gray-800 hover:border-gray-500 hover:text-gray-500 focus:border-gray-500 focus:text-gray-500 dark:border-white dark:text-white dark:hover:text-neutral-300 dark:hover:border-neutral-300',
red: 'border border-gray-200 text-gray-500 hover:border-red-600 hover:text-red-600 focus:border-red-600 focus:text-red-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-red-500 dark:hover:border-red-600 dark:focus:text-red-500 dark:focus:border-red-600', blue: 'border border-blue-600 text-blue-600 hover:border-blue-500 hover:text-blue-500 focus:border-blue-500 focus:text-blue-500 dark:border-blue-500 dark:text-blue-500 dark:hover:text-blue-400 dark:hover:border-blue-400',
gray: 'border border-gray-200 text-gray-500 hover:border-gray-600 hover:text-gray-600 focus:border-gray-600 focus:text-gray-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-white dark:hover:border-neutral-600 dark:focus:text-white dark:focus:border-neutral-600', teal: 'border border-teal-500 text-teal-500 hover:border-teal-400 hover:text-teal-400 focus:border-teal-400 focus:text-teal-400',
teal: 'border border-teal-200 text-teal-500 hover:border-teal-600 hover:text-teal-600 focus:border-teal-600 focus:text-teal-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-white dark:hover:border-neutral-600 dark:focus:text-white dark:focus:border-neutral-600', red: 'border border-red-500 text-red-500 hover:border-red-400 hover:text-red-400 focus:border-red-400 focus:text-red-400',
green: 'border border-green-200 text-green-500 hover:border-green-600 hover:text-green-600 focus:border-green-600 focus:text-green-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-white dark:hover:border-neutral-600 dark:focus:text-white dark:focus:border-neutral-600', yellow:'border border-yellow-500 text-yellow-500 hover:border-yellow-400 focus:border-yellow-400 focus:text-yellow-400',
transparent: 'border border-white/20 bg-transparent text-white shadow-2xs hover:bg-white/15 focus:bg-white/15 dark:bg-transparent dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50', green: 'border border-green-500 text-green-500 hover:border-green-400 hover:text-green-400 focus:border-green-400 focus:text-green-400',
transparent: 'border border-white text-white hover:border-white/70 hover:text-white/70 focus:border-white/70 focus:text-white/70',
}, },
ghost: { ghost: {
blue: 'border border-transparent text-blue-600 hover:bg-blue-100 hover:text-blue-800 focus:bg-blue-100 focus:text-blue-800 dark:text-blue-500 dark:hover:bg-blue-800/30 dark:hover:text-blue-400 dark:focus:bg-blue-800/30 dark:focus:text-blue-400', gray: 'border border-transparent text-gray-800 hover:bg-gray-100 focus:bg-gray-100 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
red: 'border border-transparent text-red-600 hover:bg-red-100 hover:text-red-800 focus:bg-red-100 focus:text-red-800 dark:text-red-500 dark:hover:bg-red-800/30 dark:hover:text-red-400 dark:focus:bg-red-800/30 dark:focus:text-red-400', blue: 'border border-transparent text-blue-600 hover:bg-blue-100 focus:bg-blue-100 hover:text-blue-800 focus:text-blue-800 dark:text-blue-500 dark:hover:bg-blue-800/30 dark:hover:text-blue-400 dark:focus:bg-blue-800/30 dark:focus:text-blue-400',
gray: 'border border-transparent text-gray-600 hover:bg-gray-100 hover:text-gray-800 focus:bg-gray-100 focus:text-gray-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white', teal: 'border border-transparent text-teal-500 hover:bg-teal-100 focus:bg-teal-100 hover:text-teal-800 dark:hover:bg-teal-800/30 dark:hover:text-teal-400 dark:focus:bg-teal-800/30 dark:focus:text-teal-400',
teal: 'border border-transparent text-teal-600 hover:bg-teal-100 hover:text-teal-800 focus:bg-teal-100 focus:text-teal-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white', red: 'border border-transparent text-red-500 hover:bg-red-100 focus:bg-red-100 hover:text-red-800 dark:hover:bg-red-800/30 dark:hover:text-red-400 dark:focus:bg-red-800/30 dark:focus:text-red-400',
green: 'border border-transparent text-green-600 hover:bg-green-100 hover:text-green-800 focus:bg-green-100 focus:text-green-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:focus:text-white', yellow:'border border-transparent text-yellow-500 hover:bg-yellow-100 focus:bg-yellow-100 hover:text-yellow-800 dark:hover:bg-yellow-800/30 dark:hover:text-yellow-400 dark:focus:bg-yellow-800/30 dark:focus:text-yellow-400',
transparent: 'border border-transparent text-white hover:bg-white/10 focus:bg-white/10 dark:text-white', green: 'border border-transparent text-green-600 hover:bg-green-100 focus:bg-green-100 hover:text-green-800 dark:text-green-500 dark:hover:bg-green-800/30 dark:hover:text-green-400 dark:focus:bg-green-800/30 dark:focus:text-green-400',
transparent: 'border border-transparent text-white hover:bg-gray-100 focus:bg-gray-100 hover:text-gray-800 dark:hover:bg-white/10 dark:hover:text-white dark:focus:bg-white/10 dark:focus:text-white',
}, },
soft: { soft: {
blue: 'bg-blue-100 text-blue-800 hover:bg-blue-200 focus:bg-blue-200 dark:text-blue-400 dark:hover:bg-blue-900 dark:focus:bg-blue-900', gray: 'border border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 focus:bg-gray-200 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:focus:bg-white/20',
red: 'bg-red-100 text-red-800 hover:bg-red-200 focus:bg-red-200 dark:text-red-400 dark:hover:bg-red-900 dark:focus:bg-red-900', blue: 'border border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200 focus:bg-blue-200 dark:text-blue-400 dark:bg-blue-800/30 dark:hover:bg-blue-800/20 dark:focus:bg-blue-800/20',
gray: 'bg-gray-100 text-gray-800 hover:bg-gray-200 focus:bg-gray-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700', teal: 'border border-transparent bg-teal-100 text-teal-800 hover:bg-teal-200 focus:bg-teal-200 dark:text-teal-500 dark:bg-teal-800/30 dark:hover:bg-teal-800/20 dark:focus:bg-teal-800/20',
teal: 'bg-teal-100 text-teal-800 hover:bg-teal-200 focus:bg-teal-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700', red: 'border border-transparent bg-red-100 text-red-800 hover:bg-red-200 focus:bg-red-200 dark:text-red-500 dark:bg-red-800/30 dark:hover:bg-red-800/20 dark:focus:bg-red-800/20',
green: 'bg-green-100 text-green-800 hover:bg-green-200 focus:bg-green-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700', yellow:'border border-transparent bg-yellow-100 text-yellow-800 hover:bg-yellow-200 focus:bg-yellow-200 dark:text-yellow-500 dark:bg-yellow-800/30 dark:hover:bg-yellow-800/20 dark:focus:bg-yellow-800/20',
transparent: 'bg-transparent-100 text-transparent-800 hover:bg-transparent-200 focus:bg-transparent-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700', green: 'border border-transparent bg-green-100 text-green-800 hover:bg-green-200 focus:bg-green-200 dark:text-green-500 dark:bg-green-800/30 dark:hover:bg-green-800/20 dark:focus:bg-green-800/20',
transparent: 'border border-transparent bg-white/10 text-white hover:bg-white/20 focus:bg-white/20',
}, },
white: { white: {
blue: 'border border-teal-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700', // entspricht deinem "White"-Block in Preline
red: 'border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
gray: 'border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700', gray: 'border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
teal: 'border border-teal-200 bg-white text-teal-800 shadow-2xs hover:bg-teal-50 focus:bg-teal-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700', blue: 'border border-gray-200 bg-white text-blue-600 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-blue-500 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
green: 'border border-green-200 bg-white text-green-800 shadow-2xs hover:bg-green-50 focus:bg-green-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700', teal: 'border border-gray-200 bg-white text-teal-500 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
red: 'border border-gray-200 bg-white text-red-500 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
yellow:'border border-gray-200 bg-white text-yellow-500 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
green: 'border border-gray-200 bg-white text-green-800 shadow-2xs hover:bg-green-50 focus:bg-green-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
transparent: 'border border-white/20 bg-transparent text-white shadow-2xs hover:bg-white/15 focus:bg-white/15 dark:bg-transparent dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50', transparent: 'border border-white/20 bg-transparent text-white shadow-2xs hover:bg-white/15 focus:bg-white/15 dark:bg-transparent dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50',
}, },
link: { link: {
blue: 'border border-transparent text-blue-600 hover:text-blue-800 focus:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400 dark:focus:text-blue-400', gray: 'inline-flex items-center gap-x-2 font-semibold text-gray-800 hover:text-blue-600 focus:text-blue-600 dark:text-white dark:hover:text-white/70 dark:focus:text-white/70',
red: 'border border-transparent text-red-600 hover:text-red-800 focus:text-red-800 dark:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400', blue: 'inline-flex items-center gap-x-2 font-semibold text-blue-600 hover:text-blue-800 focus:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400 dark:focus:text-blue-400',
gray: 'border border-transparent text-gray-600 hover:text-gray-800 focus:text-gray-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white', teal: 'inline-flex items-center gap-x-2 font-semibold text-teal-500 hover:text-teal-800 focus:text-teal-800',
teal: 'border border-transparent text-teal-600 hover:text-teal-800 focus:text-teal-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white', red: 'inline-flex items-center gap-x-2 font-semibold text-red-500 hover:text-red-800 focus:text-red-800',
green: 'border border-transparent text-green-600 hover:text-green-800 focus:text-green-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white', yellow:'inline-flex items-center gap-x-2 font-semibold text-yellow-500 hover:text-yellow-800 focus:text-yellow-800',
transparent: 'border border-transparent text-transparent-600 hover:text-transparent-800 focus:text-transparent-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white', green: 'inline-flex items-center gap-x-2 font-semibold text-green-600 hover:text-green-800 focus:text-green-800',
transparent: 'inline-flex items-center gap-x-2 font-semibold text-white hover:text-white/80 focus:text-white/80',
}, },
} }

View File

@ -36,7 +36,6 @@ type GameBannerProps = {
bgUrl?: string bgUrl?: string
iconUrl?: string iconUrl?: string
connectUri?: string connectUri?: string
score?: string
connectedCount?: number connectedCount?: number
totalExpected?: number totalExpected?: number
onReconnect?: () => void onReconnect?: () => void
@ -66,24 +65,10 @@ type PlayerLeaveMsg = {
id?: string | number | null id?: string | number | null
} }
type ScoreMsg = {
type: 'score'
team1?: number
team2?: number
ct?: number
t?: number
}
type ScoreWrapMsg = {
score: { team1?: number; team2?: number; ct?: number; t?: number }
}
type TelemetryMsg = type TelemetryMsg =
| PlayersMsg | PlayersMsg
| PlayerJoinMsg | PlayerJoinMsg
| PlayerLeaveMsg | PlayerLeaveMsg
| ScoreMsg
| ScoreWrapMsg
| Record<string, unknown> | Record<string, unknown>
@ -123,11 +108,6 @@ const parseWsData = (data: unknown): TelemetryMsg | null => {
return null return null
} }
const toFiniteOrNull = (v: unknown): number | null => {
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) { function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
const h = (host ?? '').trim() || '127.0.0.1' const h = (host ?? '').trim() || '127.0.0.1'
const p = (port ?? '').trim() || '8081' const p = (port ?? '').trim() || '8081'
@ -153,7 +133,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
bgUrl: bgUrlProp, bgUrl: bgUrlProp,
iconUrl: iconUrlProp, iconUrl: iconUrlProp,
connectUri: connectUriProp, connectUri: connectUriProp,
score: scoreProp,
connectedCount: connectedCountProp, connectedCount: connectedCountProp,
totalExpected: totalExpectedProp, totalExpected: totalExpectedProp,
onReconnect, onReconnect,
@ -195,7 +174,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
// Local live-state // Local live-state
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set()) const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null })
const [connectHref, setConnectHref] = useState<string | null>(null) const [connectHref, setConnectHref] = useState<string | null>(null)
// optional: Connect-URI vom Server holen // optional: Connect-URI vom Server holen
@ -273,22 +251,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next }) setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
return return
} }
if ('type' in msg && msg.type === 'score') {
const m = msg as ScoreMsg
const a = toFiniteOrNull(m.team1 ?? m.ct)
const b = toFiniteOrNull(m.team2 ?? m.t)
setScore({ a, b })
return
}
if ('score' in msg) {
const m = (msg as ScoreWrapMsg).score
const a = toFiniteOrNull(m?.team1 ?? m?.ct)
const b = toFiniteOrNull(m?.team2 ?? m?.t)
setScore({ a, b })
return
}
} }
} }
@ -322,9 +284,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
const variant: Variant = variantProp ?? (isOnline ? 'connected' : 'disconnected') const variant: Variant = variantProp ?? (isOnline ? 'connected' : 'disconnected')
// Score-String
const scoreStr = scoreProp ?? ((score.a == null || score.b == null) ? ' : ' : `${score.a} : ${score.b}`)
const envConnect = const envConnect =
process.env.NEXT_PUBLIC_CONNECT_HREF || process.env.NEXT_PUBLIC_CONNECT_HREF ||
process.env.NEXT_PUBLIC_STEAM_CONNECT_URI || process.env.NEXT_PUBLIC_STEAM_CONNECT_URI ||
@ -368,7 +327,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
const bgUrl = bgUrlProp ?? cfg?.activeMapBg ?? undefined const bgUrl = bgUrlProp ?? cfg?.activeMapBg ?? undefined
const pretty = { const pretty = {
map: mapLabel ?? mapKey ?? '—', map: mapLabel ?? mapKey ?? '—',
score: scoreStr,
} }
const outerBase = inline ? 'relative w-full' : 'fixed right-0 bottom-0 left-0 sm:left-[16rem]' const outerBase = inline ? 'relative w-full' : 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
@ -394,7 +352,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
<span>Server: <span className="font-semibold">{serverLabel}</span></span> <span>Server: <span className="font-semibold">{serverLabel}</span></span>
)} )}
<span>Map: <span className="font-semibold">{pretty.map}</span></span> <span>Map: <span className="font-semibold">{pretty.map}</span></span>
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{shownConnected}</span> / {totalExpected}</span> <span>{tGameBanner('player-connected')}: <span className="font-semibold">{shownConnected}</span> / {totalExpected}</span>
</div> </div>
) )

View File

@ -150,20 +150,22 @@ export default function NotificationCenter({
{needsAction ? ( {needsAction ? (
<> <>
<Button <Button
title="Accept"
onClick={(e) => { e.stopPropagation(); onAction('accept', n.actionData ?? n.id); onSingleRead(n.id) }} onClick={(e) => { e.stopPropagation(); onAction('accept', n.actionData ?? n.id); onSingleRead(n.id) }}
className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700"
color="green" color="green"
size="sm" size="sm"
variant="solid"
> >
<span className="text-white-600"></span>
</Button> </Button>
<Button <Button
title="Reject"
onClick={(e) => { e.stopPropagation(); onAction('reject', n.actionData ?? n.id); onSingleRead(n.id) }} onClick={(e) => { e.stopPropagation(); onAction('reject', n.actionData ?? n.id); onSingleRead(n.id) }}
className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700"
color="red" color="red"
size="sm" size="sm"
variant="solid"
> >
<span className="text-white-600"></span>
</Button> </Button>
</> </>
) : ( ) : (

View File

@ -1,79 +1,92 @@
// /src/app/[locale]/components/PlayerCard.tsx
'use client' 'use client'
import Image from 'next/image' import UserAvatarWithStatus from './UserAvatarWithStatus'
import { Player } from '../../../types/team' import PremierRankBadge from './PremierRankBadge'
import FaceitLevelImage from './FaceitLevelBadge'
export type CardWidth = type PlayerCardProps = {
| 'sm' // max-w-sm (24rem) id: string
| 'md' // max-w-md (28rem) steamId?: string // ⬅️ neu: fürs Presence-Widget
| 'lg' // max-w-lg (32rem) name: string
| 'xl' // max-w-xl (36rem) avatar?: string
| '2xl' // max-w-2xl (42rem) country?: string // bleibt im Typ, wird nicht gerendert
| 'full' // w-full rating?: number
| 'auto' // keine Begrenzung team?: { id: string; name: string } | null
faceit?: {
type Props = { level?: number | null
player: Player elo?: number | null
align?: 'left' | 'right' nickname?: string | null
maxWidth?: CardWidth url?: string | null
} | null
onClick?: () => void // wird von der Page gesetzt (Profil öffnen)
} }
export default function PlayerCard({ export default function PlayerCard({
player, steamId,
align = 'left', name,
maxWidth = 'sm', avatar,
}: Props) { rating,
/* --- HilfsKlassen ----------------------------------------------------- */ team,
faceit,
const widthClasses: Record<CardWidth, string> = { onClick,
sm: 'max-w-sm', }: PlayerCardProps) {
md: 'max-w-md', const rankVal = typeof rating === 'number' && Number.isFinite(rating) ? rating : 0
lg: 'max-w-lg', const hasFaceit = typeof faceit?.level === 'number' || typeof faceit?.elo === 'number'
xl: 'max-w-xl',
'2xl':'max-w-2xl',
full: 'w-full',
auto: '', // keine Begrenzung
}
// Linksbündig = Karte nach links schieben, Rechtsbündig = nach rechts
const alignClasses =
align === 'right'
? 'ml-auto'
: align === 'left'
? 'mr-auto'
: 'mx-auto'
// Für den FlexContainer innen:
// * links: Avatar Name (row)
// * rechts: Name Avatar (rowreverse)
const rowDir = align === 'right' ? 'flex-row-reverse text-right' : ''
const avatarSrc = player.avatar || '/default-avatar.png'
/* --- Rendering --------------------------------------------------------- */
return ( return (
<div <button
className={` type="button"
flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl onClick={() => onClick?.()}
dark:bg-neutral-900 dark:border-neutral-700 dark:shadow-neutral-700/70 className="
${alignClasses} ${widthClasses[maxWidth]} w-full text-left rounded-lg border border-gray-200 dark:border-neutral-700
`} bg-white dark:bg-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-700
transition-colors shadow-sm
px-3 py-2
flex items-center gap-3
cursor-pointer
"
> >
<div className="p-2 md:p-4"> <div>
<div className={`flex items-center gap-3 ${rowDir}`}> {avatar ? (
<Image <UserAvatarWithStatus
src={avatarSrc} steamId={steamId}
alt={player.name} src={avatar}
width={48} alt={name}
height={48} size={32}
className="rounded-full shrink-0 border object-cover" showStatus
/> />
<span className="whitespace-nowrap overflow-hidden text-ellipsis"> ) : (
{player.name} <div className="h-full w-full grid place-items-center text-gray-400">
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor" aria-hidden>
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-5 0-9 2.5-9 5.5A1.5 1.5 0 0 0 4.5 21h15A1.5 1.5 0 0 0 21 19.5C21 16.5 17 14 12 14Z" />
</svg>
</div>
)}
</div>
<div className="min-w-0 flex-1">
{/* Name + PremierRank + Faceit-Level (direkt dahinter) */}
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium text-gray-900 dark:text-white truncate">{name}</span>
<span className="shrink-0">
<PremierRankBadge rank={rankVal} />
</span> </span>
{hasFaceit && (
<span className="shrink-0 inline-flex items-center gap-1">
<FaceitLevelImage
elo={typeof faceit?.elo === 'number' ? faceit!.elo! : 0}
className="-ml-0.5"
/>
</span>
)}
</div>
{/* Teamzeile (ohne Faceit-Link/Nickname) */}
<div className="text-xs text-gray-500 dark:text-neutral-400 mt-0.5 flex items-center gap-2">
{team?.name && <span className="truncate">{team.name}</span>}
</div> </div>
</div> </div>
</div> </button>
) )
} }

View File

@ -5,10 +5,8 @@ import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl' import { useTranslations, useLocale } from 'next-intl'
import Button from './Button' import Button from './Button'
import SidebarFooter from './SidebarFooter' import SidebarFooter from './SidebarFooter'
import Select from './Select'; import Select from './Select'
import 'flag-icons/css/flag-icons.min.css'; import 'flag-icons/css/flag-icons.min.css'
type Submenu = 'teams' | 'players' | null
export default function Sidebar() { export default function Sidebar() {
const router = useRouter() const router = useRouter()
@ -20,7 +18,6 @@ export default function Sidebar() {
const tSidebar = useTranslations('sidebar') const tSidebar = useTranslations('sidebar')
const [isOpen, setIsOpen] = useState(false) // mobile drawer const [isOpen, setIsOpen] = useState(false) // mobile drawer
const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null)
// Aktive Route prüfen (pathname kommt schon ohne Locale) // Aktive Route prüfen (pathname kommt schon ohne Locale)
const isActive = useCallback((path: string) => pathname === path, [pathname]) const isActive = useCallback((path: string) => pathname === path, [pathname])
@ -34,9 +31,6 @@ export default function Sidebar() {
const idleClasses = const idleClasses =
'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700' 'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700'
const toggleSubmenu = (key: Exclude<Submenu, null>) =>
setOpenSubmenu(prev => (prev === key ? null : key))
// ✅ Locale-Wechsel: gleiche Route behalten, nur Locale ändern // ✅ Locale-Wechsel: gleiche Route behalten, nur Locale ändern
const changeLocale = useCallback((nextLocale: 'en' | 'de') => { const changeLocale = useCallback((nextLocale: 'en' | 'de') => {
if (nextLocale === locale) return if (nextLocale === locale) return
@ -83,67 +77,31 @@ export default function Sidebar() {
</Button> </Button>
</li> </li>
{/* Teams (mit Submenu) */} {/* Teams (einzelner Button) */}
<li>
<Button
onClick={() => toggleSubmenu('teams')}
size="sm"
variant="link"
className={`${navBtnBase} ${idleClasses} justify-between`}
aria-expanded={openSubmenu === 'teams'}
aria-controls="submenu-teams"
>
<span className="flex items-center gap-x-3.5">
<svg className="size-4" viewBox="0 0 640 640" fill="currentColor">
<path d="M320 64c35.3 0 64 28.7 64 64s-28.7 64-64 64-64-28.7-64-64 28.7-64 64-64zm96 312c0 25-12.7 47-32 59.9V528c0 26.5-21.5 48-48 48h-32c-26.5 0-48-21.5-48-48v-92.1c-19.3-12.9-32-34.9-32-59.9v-40c0-53 43-96 96-96s96 43 96 96v40zM160 96c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm16 240v32c0 32.5 12.1 62.1 32 84.7V528c0 1.2 0 2.5.1 3.7-8.6 7.6-19.8 12.3-31.1 12.3H144c-26.5 0-48-21.5-48-48v-56.6C76.9 428.4 64 407.7 64 384v-32c0-53 43-96 96-96 12.7 0 24.8 2.5 35.9 6.9-12.6 21.4-19.9 46.8-19.9 73.1zM480 96c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm-48 432v-75.3c19.9-22.5 32-52.2 32-84.7v-32c0-26.7-7.3-52.1-19.9-73.1 11.1-4.4 23.2-6.9 35.9-6.9 53 0 96 43 96 96v32c0 23.7-12.9 44.4-32 55.4V496c0 26.5-21.5 48-48 48h-32c-10.8 0-21-3.6-29.1-9.7.1-1.2.1-2.5.1-3.7z" />
</svg>
{tNav('teams.label')}
</span>
<svg
className={`size-4 transition-transform ${openSubmenu === 'teams' ? 'rotate-180' : ''}`}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</Button>
{openSubmenu === 'teams' && (
<ul id="submenu-teams" className="pl-6 space-y-1 mt-1">
<li> <li>
<Button <Button
onClick={() => { router.push('/teams'); setIsOpen(false) }} onClick={() => { router.push('/teams'); setIsOpen(false) }}
size="sm" size="sm"
variant="link" variant="link"
className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700" className={`${navBtnBase} ${isActive('/teams') ? activeClasses : idleClasses}`}
aria-label={tNav('teams.label')}
> >
{tNav('teams.overview')} <svg className="size-4" viewBox="0 0 640 640" fill="currentColor" aria-hidden="true">
<path d="M320 64c35.3 0 64 28.7 64 64s-28.7 64-64 64-64-28.7-64-64 28.7-64 64-64zm96 312c0 25-12.7 47-32 59.9V528c0 26.5-21.5 48-48 48h-32c-26.5 0-48-21.5-48-48v-92.1c-19.3-12.9-32-34.9-32-59.9v-40c0-53 43-96 96-96s96 43 96 96v40zM160 96c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm16 240v32c0 32.5 12.1 62.1 32 84.7V528c0 1.2 0 2.5.1 3.7-8.6 7.6-19.8 12.3-31.1 12.3H144c-26.5 0-48-21.5-48-48v-56.6C76.9 428.4 64 407.7 64 384v-32c0-53 43-96 96-96 12.7 0 24.8 2.5 35.9 6.9-12.6 21.4-19.9 46.8-19.9 73.1zM480 96c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm-48 432v-75.3c19.9-22.5 32-52.2 32-84.7v-32c0-26.7-7.3-52.1-19.9-73.1 11.1-4.4 23.2-6.9 35.9-6.9 53 0 96 43 96 96v32c0 23.7-12.9 44.4-32 55.4V496c0 26.5-21.5 48-48 48h-32c-10.8 0-21-3.6-29.1-9.7.1-1.2.1-2.5.1-3.7z" />
</svg>
{tNav('teams.label')}
</Button> </Button>
</li> </li>
<li>
<Button
onClick={() => { router.push('/teams/manage'); setIsOpen(false) }}
size="sm"
variant="link"
className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700"
>
{tNav('teams.manage')}
</Button>
</li>
</ul>
)}
</li>
{/* Spieler (mit Submenu) */} {/* Spieler (einzelner Button) */}
<li> <li>
<Button <Button
onClick={() => toggleSubmenu('players')} onClick={() => { router.push('/players'); setIsOpen(false) }}
size="sm" size="sm"
variant="link" variant="link"
className={`${navBtnBase} ${idleClasses} justify-between`} className={`${navBtnBase} ${isActive('/players') ? activeClasses : idleClasses}`}
aria-expanded={openSubmenu === 'players'} aria-label={tNav('players.label')}
aria-controls="submenu-players"
> >
<span className="flex items-center gap-x-3.5">
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor"> <svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
<path <path
fillRule="evenodd" fillRule="evenodd"
@ -152,39 +110,7 @@ export default function Sidebar() {
/> />
</svg> </svg>
{tNav('players.label')} {tNav('players.label')}
</span>
<svg
className={`size-4 transition-transform ${openSubmenu === 'players' ? 'rotate-180' : ''}`}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</Button> </Button>
{openSubmenu === 'players' && (
<ul id="submenu-players" className="pl-6 space-y-1 mt-1">
<li>
<Button
onClick={() => { router.push('/players'); setIsOpen(false) }}
size="sm"
variant="link"
className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700"
>
{tNav('players.overview')}
</Button>
</li>
<li>
<Button
onClick={() => { router.push('/players/stats'); setIsOpen(false) }}
size="sm"
variant="link"
className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700"
>
{tNav('players.stats')}
</Button>
</li>
</ul>
)}
</li> </li>
{/* Spielplan */} {/* Spielplan */}
@ -216,7 +142,7 @@ export default function Sidebar() {
onChange={(val) => changeLocale(val as 'en' | 'de')} onChange={(val) => changeLocale(val as 'en' | 'de')}
dropDirection="up" dropDirection="up"
showArrow={false} showArrow={false}
fullWidth={false} // 👈 NEU fullWidth={false}
options={[ options={[
{ {
value: 'en', value: 'en',
@ -247,7 +173,8 @@ export default function Sidebar() {
</footer> </footer>
</div> </div>
), ),
[openSubmenu, locale, tNav, tSidebar, isActive, changeLocale, router] // 🔧 openSubmenu entfernt restliche Deps reichen aus
[locale, tNav, tSidebar, isActive, changeLocale, router]
) )
return ( return (

View File

@ -346,7 +346,7 @@ export default function TeamCardComponent({
initialTeams={initialTeams} initialTeams={initialTeams}
initialInvitationMap={initialInvitationMap} initialInvitationMap={initialInvitationMap}
/> />
<div className="pt-2"> <div className="mt-4 flex justify-start">
<CreateTeamButton /> <CreateTeamButton />
</div> </div>
</> </>

View File

@ -800,7 +800,7 @@ function TeamMemberViewBody({
title="Übernehmen" title="Übernehmen"
color="green" color="green"
size="sm" size="sm"
variant="soft" variant="ghost"
onClick={async () => { onClick={async () => {
await renameTeam(team.id, editedName) await renameTeam(team.id, editedName)
setIsEditingName(false) setIsEditingName(false)
@ -808,7 +808,7 @@ function TeamMemberViewBody({
}} }}
className="h-[34px] px-3 flex items-center justify-center" className="h-[34px] px-3 flex items-center justify-center"
> >
<span className="text-green-600"></span> <span className="text-white-600"></span>
</Button> </Button>
<Button <Button
title="Abbrechen" title="Abbrechen"
@ -821,7 +821,7 @@ function TeamMemberViewBody({
}} }}
className="h-[34px] px-3 flex items-center justify-center" className="h-[34px] px-3 flex items-center justify-center"
> >
<span className="text-red-600"></span> <span className="text-white-600"></span>
</Button> </Button>
</> </>
) : ( ) : (

View File

@ -116,7 +116,10 @@ export default function UserAvatarWithStatus({
return ( return (
<div <div
className={clsx('relative inline-flex items-center justify-center', className)} className={clsx(
'relative inline-flex items-center justify-center align-middle self-center leading-none shrink-0',
className
)}
style={{ width: size, height: size }} style={{ width: size, height: size }}
{...rest} {...rest}
> >

View File

@ -76,7 +76,7 @@ export default function Dashboard() {
</Link> </Link>
<Link <Link
href="/teams" href="/team"
className="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700" className="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:hover:bg-neutral-700"
> >
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden> <svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
@ -221,9 +221,6 @@ export default function Dashboard() {
<Link href="/schedule" className="text-xs font-semibold text-amber-900 underline underline-offset-4 dark:text-amber-200"> <Link href="/schedule" className="text-xs font-semibold text-amber-900 underline underline-offset-4 dark:text-amber-200">
Match planen Match planen
</Link> </Link>
<Link href="/upload" className="text-xs font-semibold text-amber-900 underline underline-offset-4 dark:text-amber-200">
Demo importieren
</Link>
</div> </div>
</div> </div>

View File

@ -0,0 +1,155 @@
// /src/app/[locale]/players/page.tsx
'use client'
import useSWR from 'swr'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from '@/i18n/navigation'
import Card from '../components/Card'
import LoadingSpinner from '../components/LoadingSpinner'
import PlayerCard from '../components/PlayerCard'
type ApiUser = {
id: string
name: string
avatar?: string
steamId?: string
country?: string
rating?: number
team?: { id: string; name: string } | null
}
type FaceitState = {
level: number | null
elo: number | null
nickname: string | null
url: string | null
}
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
async function fetchFaceitFor(steamId: string): Promise<FaceitState | null> {
try {
const base = process.env.NEXT_PUBLIC_BASE_URL ?? ''
const res = await fetch(`${base}/api/stats/${steamId}`, {
cache: 'no-store',
credentials: 'include',
})
if (!res.ok) return null
const data = await res.json()
const lvl = data?.user?.faceit?.faceitGames?.[0]?.skillLevel ?? null
const elo = data?.user?.faceit?.faceitGames?.[0]?.elo ?? null
const nick = data?.user?.faceit?.faceitNickname ?? null
const url = data?.user?.faceit?.faceitUrl
? String(data.user.faceit.faceitUrl).replace('{lang}', 'en')
: (nick ? `https://www.faceit.com/en/players/${encodeURIComponent(nick)}` : null)
return (lvl || elo || nick || url) ? { level: lvl, elo, nickname: nick, url } : null
} catch {
return null
}
}
export default function PlayersPage() {
const router = useRouter()
const [query, setQuery] = useState('')
const [faceits, setFaceits] = useState<Record<string, FaceitState | null>>({})
const { data, isLoading } = useSWR<{ ok: boolean; users: ApiUser[] }>(
`/api/users?limit=1000`,
fetcher,
{ revalidateOnMount: true, refreshInterval: 60_000, fallbackData: { ok: true, users: [] } }
)
// 1) users stabil aus data ableiten
const users = useMemo<ApiUser[]>(
() => (Array.isArray(data?.users) ? data.users : []),
[data?.users]
)
// 2) Sichtbare Liste daraus berechnen
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
const base = q
? users.filter(u =>
(u.name ?? '').toLowerCase().includes(q) ||
(u.steamId ?? '').toLowerCase().includes(q) ||
(u.team?.name ?? '').toLowerCase().includes(q)
)
: users.slice()
base.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', 'de', { sensitivity: 'base' }))
return base
}, [users, query])
// Faceit-Daten lazy für sichtbare Spieler nachladen (max 60 pro Lauf)
useEffect(() => {
const ids = visible.map(u => u.steamId ?? u.id).filter((id): id is string => !!id)
const toFetch = ids.filter(id => !(id in faceits)).slice(0, 60)
if (!toFetch.length) return
let alive = true
;(async () => {
const results = await Promise.all(
toFetch.map(async (id) => [id, await fetchFaceitFor(id)] as const)
)
if (!alive) return
setFaceits(prev => {
const next = { ...prev }
for (const [id, f] of results) next[id] = f
return next
})
})()
return () => { alive = false }
}, [visible, faceits])
return (
<Card maxWidth="full">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">Players</h2>
<div className="relative w-full sm:w-80">
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Spieler suchen …"
className="w-full pl-9 pr-3 py-2 rounded-lg border border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100 text-sm"
/>
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-neutral-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
</div>
</div>
{isLoading ? (
<div className="py-10 grid place-items-center text-gray-500 dark:text-gray-400">
<LoadingSpinner />
</div>
) : visible.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 dark:border-neutral-600 p-8 text-center text-sm text-gray-600 dark:text-neutral-400">
Keine Spieler gefunden.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visible.map(u => {
const steamId = u.steamId ?? u.id
return (
<PlayerCard
key={u.id}
id={u.id}
name={u.name}
avatar={u.avatar}
rating={u.rating}
team={u.team || null}
faceit={steamId ? (faceits[steamId] ?? null) : null}
onClick={() => router.push(`/profile/${steamId}`)}
/>
)
})}
</div>
)}
</Card>
)
}

View File

@ -1,22 +1,31 @@
// /src/app/[locale]/teams/page.tsx // /src/app/[locale]/teams/page.tsx
'use client' 'use client'
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import Button from '../components/Button'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import Input from '../components/Input' import Input from '../components/Input'
import TeamCard from '../components/TeamCard'
import LoadingSpinner from '../components/LoadingSpinner' import LoadingSpinner from '../components/LoadingSpinner'
import NoTeamView from '../components/NoTeamView'
import type { Team } from '../../../types/team' import type { Team } from '../../../types/team'
import Card from '../components/Card' import Card from '../components/Card'
import CreateTeamButton from '../components/CreateTeamButton'
// Minimale Response-Typen / Guards // Minimale Response-Typen / Guards
type TeamsJson = { items?: Team[]; teams?: Team[] } | Team[] | unknown type TeamsJson = { items?: Team[]; teams?: Team[] } | Team[] | unknown
type InviteItem = { type?: string; teamId?: string; id?: string } type InviteItem = { type?: string; teamId?: string; id?: string }
type InvitesJson = { invitations?: InviteItem[] } | InviteItem[] | unknown type InvitesJson = { invitations?: InviteItem[] } | InviteItem[] | unknown
type UserJson = { team?: unknown } | unknown
// Hilfs-Typeguards ganz oben dazupacken
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null
const isInviteItem = (v: unknown): v is InviteItem => {
if (!isRecord(v)) return false
if (v.type !== undefined && typeof v.type !== 'string') return false
if (v.teamId !== undefined && typeof v.teamId !== 'string') return false
if (v.id !== undefined && typeof v.id !== 'string') return false
return true
}
function parseTeams(data: TeamsJson): Team[] { function parseTeams(data: TeamsJson): Team[] {
if (Array.isArray(data)) return data as Team[] if (Array.isArray(data)) return data as Team[]
@ -28,79 +37,53 @@ function parseTeams(data: TeamsJson): Team[] {
return [] return []
} }
function parseInvites(data: InvitesJson): InviteItem[] { // parseInvitesToMap OHNE any
if (Array.isArray(data)) return data as InviteItem[] function parseInvitesToMap(data: InvitesJson): Record<string, string> {
if (typeof data === 'object' && data !== null) { const map: Record<string, string> = {}
const o = data as Record<string, unknown>
if (Array.isArray(o.invitations)) return o.invitations as InviteItem[]
}
return []
}
function hasTeamFlag(data: UserJson): boolean { const arr: InviteItem[] = Array.isArray(data)
if (typeof data === 'object' && data !== null) { ? data.filter(isInviteItem)
const o = data as Record<string, unknown> : (isRecord(data) && Array.isArray((data as { invitations?: unknown }).invitations)
return o.team != null ? ((data as { invitations: unknown[] }).invitations.filter(isInviteItem))
: [])
for (const inv of arr) {
if (inv.type === 'team-join-request' && inv.teamId && inv.id) {
map[inv.teamId] = inv.id
} }
return false }
return map
} }
export default function TeamsPage() { export default function TeamsPage() {
const { data: session } = useSession()
const mySteamId = (session?.user as { steamId?: string } | undefined)?.steamId ?? ''
const [teams, setTeams] = useState<Team[]>([]) const [teams, setTeams] = useState<Team[]>([])
const [invitationMap, setInvitationMap] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [invitationMap, setInvitationMap] = useState<Record<string, string>>({}) // Create-Team Modal
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Flag aus /api/user
const [userHasTeam, setUserHasTeam] = useState<boolean>(false)
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const [tRes, iRes, uRes] = await Promise.all([ const [tRes, iRes] = await Promise.all([
fetch('/api/teams', { cache: 'no-store' }), fetch('/api/teams', { cache: 'no-store' }),
fetch('/api/user/invitations', { cache: 'no-store' }), fetch('/api/user/invitations', { cache: 'no-store' }),
fetch('/api/user', { cache: 'no-store' }),
]) ])
const [tJson, iJson, uJson]: [TeamsJson, InvitesJson, UserJson] = await Promise.all([ const [tJson, iJson]: [TeamsJson, InvitesJson] = await Promise.all([
tRes.json(), tRes.json(),
iRes.ok ? iRes.json().catch<unknown>(() => ({})) : Promise.resolve({} as unknown), iRes.ok ? iRes.json().catch<unknown>(() => ({})) : Promise.resolve({} as unknown),
uRes.ok ? uRes.json() : Promise.resolve({} as unknown),
]) ])
const nextTeams = parseTeams(tJson) setTeams(parseTeams(tJson))
const invites = parseInvites(iJson) setInvitationMap(parseInvitesToMap(iJson))
// 🔧 WICHTIG: 'team-join-request' → 'pending', 'team-invite' → ID
const nextMap: Record<string, string> = {}
for (const inv of invites) {
const teamId = inv?.teamId
const type = inv?.type
const id = inv?.id
if (!teamId) continue
if (type === 'team-join-request') {
nextMap[teamId] = 'pending'
} else if (type === 'team-invite' && id) {
nextMap[teamId] = id
}
}
setTeams(nextTeams)
setInvitationMap(nextMap)
setUserHasTeam(hasTeamFlag(uJson))
} catch (err) { } catch (err) {
console.error('[TeamsPage] load failed:', err) console.error('[TeamsPage] load failed:', err)
setTeams([]) setTeams([])
setInvitationMap({}) setInvitationMap({})
setUserHasTeam(false)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -113,6 +96,7 @@ export default function TeamsPage() {
fetchAll() fetchAll()
}, [fetchAll]) }, [fetchAll])
// createTeam: Fehler-JSON ohne any
const createTeam = async () => { const createTeam = async () => {
if (!newName.trim()) return if (!newName.trim()) return
setSaving(true) setSaving(true)
@ -123,11 +107,12 @@ export default function TeamsPage() {
body: JSON.stringify({ teamname: newName.trim() }), body: JSON.stringify({ teamname: newName.trim() }),
}) })
if (!res.ok) { if (!res.ok) {
const j: unknown = await res.json().catch(() => ({})) type ErrJson = { message?: string }
const raw: unknown = await res.json().catch(() => ({}))
const msg = const msg =
(typeof (j as Record<string, unknown>).message === 'string' isRecord(raw) && typeof (raw as ErrJson).message === 'string'
? (j as Record<string, unknown>).message ? (raw as ErrJson).message!
: null) ?? 'Team konnte nicht erstellt werden.' : 'Team konnte nicht erstellt werden.'
alert(msg) alert(msg)
return return
} }
@ -150,50 +135,20 @@ export default function TeamsPage() {
) )
} }
// Nur anzeigen, wenn der Spieler in KEINEM Team ist
const canRequestJoin = !userHasTeam
return ( return (
<Card maxWidth="full"> <Card maxWidth='full'>
<div className="flex justify-between items-center mb-6"> <>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white"> {/* 👉 komplette Liste + Suche/Sortierung aus NoTeamView */}
Teams verwalten <NoTeamView
</h2> initialTeams={teams}
initialInvitationMap={invitationMap}
<Button color="blue" onClick={() => setShowCreate(true)}>
Neues Team erstellen
</Button>
</div>
{teams.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400">
Es wurden noch keine Teams erstellt.
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
currentUserSteamId={mySteamId}
invitationId={invitationMap[t.id]}
onUpdateInvitation={(teamId: string, newValue: string | null) => {
setInvitationMap((prev) => {
const next: Record<string, string> = { ...prev }
if (newValue === null) {
delete next[teamId] // Einladung entfernt
} else {
next[teamId] = newValue // Einladung gesetzt/aktualisiert
}
return next
})
}}
canRequestJoin={canRequestJoin}
/> />
))}
</div>
)}
<div className="mt-4 flex justify-start">
<CreateTeamButton />
</div>
{/* Modal: Team anlegen */}
<Modal <Modal
id="create-team-modal" id="create-team-modal"
title="Neues Team erstellen" title="Neues Team erstellen"
@ -213,6 +168,7 @@ export default function TeamsPage() {
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
/> />
</Modal> </Modal>
</>
</Card> </Card>
) )
} }

View File

@ -197,7 +197,7 @@ export async function POST (req: NextRequest) {
data: { data: {
steamId : uid, steamId : uid,
title : 'Match erstellt', title : 'Match erstellt',
message, message: message,
actionType: 'match-created', actionType: 'match-created',
actionData: created.id, actionData: created.id,
}, },
@ -230,6 +230,7 @@ export async function POST (req: NextRequest) {
title : safeTitle, title : safeTitle,
startsAt: (created.matchDate ?? created.demoDate ?? plannedAt).toISOString(), startsAt: (created.matchDate ?? created.demoDate ?? plannedAt).toISOString(),
bestOf : bestOfInt, bestOf : bestOfInt,
message: message,
}) })
await sendServerSSEMessage({ type: 'matches-updated' }) await sendServerSSEMessage({ type: 'matches-updated' })
// optional zusätzlich (falls Team-Ansichten reagieren sollen): // optional zusätzlich (falls Team-Ansichten reagieren sollen):

View File

@ -0,0 +1,93 @@
// /src/app/api/users/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import type { Prisma } from '@/generated/prisma' // ggf. '@prisma/client' verwenden, falls das dein Pfad ist
export const dynamic = 'force-dynamic'
type ApiUser = {
id: string
name: string
avatar?: string
steamId?: string
country?: string
rating?: number
team?: { id: string; name: string } | null
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url)
const q = (searchParams.get('q') ?? '').trim()
const limit = Math.min(2000, Math.max(1, Number(searchParams.get('limit') ?? 1000)))
// Select separat halten → saubere Typinferenz inkl. Relation 'team'
const select = {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
faceitNickname: true,
faceitAvatar: true,
faceitCountry: true,
team: { select: { id: true, name: true } },
} as const
// Basisfilter: lastActiveAt != null ODER (timeZone != null UND != '')
const activeOrTzFilter: Prisma.UserWhereInput = {
OR: [
{ lastActiveAt: { not: null } },
{ AND: [{ timeZone: { not: null } }, { timeZone: { not: '' } }] },
],
}
const insensitive = 'insensitive' as Prisma.QueryMode
// Optionaler Suchfilter
const searchFilter: Prisma.UserWhereInput | undefined = q
? {
OR: [
{ name: { contains: q, mode: insensitive } },
{ steamId: { contains: q, mode: insensitive } },
{ faceitNickname: { contains: q, mode: insensitive } },
{ team: { is: { name: { contains: q, mode: insensitive } } } },
],
}
: undefined
const where: Prisma.UserWhereInput = searchFilter
? { AND: [activeOrTzFilter, searchFilter] }
: activeOrTzFilter
const rows = await prisma.user.findMany({
take: limit,
where,
orderBy: [{ name: 'asc' }, { steamId: 'asc' }],
select,
})
// WICHTIG: keine Param-Typannotation hier!
const mapped: ApiUser[] = rows.map(u => ({
id: u.steamId,
name: u.name ?? u.faceitNickname ?? '—',
avatar: u.avatar ?? u.faceitAvatar ?? undefined,
steamId: u.steamId,
country: u.location ?? u.faceitCountry ?? undefined,
rating: u.premierRank ?? undefined,
team: u.team ? { id: u.team.id, name: u.team.name } : null,
}))
mapped.sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '', 'de', { sensitivity: 'base' })
)
return NextResponse.json(
{ ok: true, users: mapped },
{ headers: { 'Cache-Control': 'no-store' } }
)
} catch (e) {
console.error('[GET /api/users] error:', e)
return NextResponse.json({ ok: false, error: 'Failed to load users' }, { status: 500 })
}
}

View File

@ -35,6 +35,7 @@ export const SSE_EVENT_TYPES = [
'server-config-updated', 'server-config-updated',
'match-ended', 'match-ended',
'server-reset', 'server-reset',
'new-cs2-match',
] as const; ] as const;
@ -114,6 +115,11 @@ export const NOTIFICATION_EVENTS = makeEventSet([
'team-joined', 'team-joined',
'team-leader-changed', 'team-leader-changed',
'team-leader-self', 'team-leader-self',
'team-member-joined',
'team-member-left',
'team-member-kicked',
'match-created',
'new-cs2-match',
'expired-sharecode', 'expired-sharecode',
] as const); ] as const);