updated for build
This commit is contained in:
parent
844ac4fe33
commit
386f701ad5
@ -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',
|
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',
|
||||||
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',
|
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',
|
||||||
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',
|
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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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,
|
||||||
/* --- Hilfs‑Klassen ----------------------------------------------------- */
|
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 Flex‑Container innen:
|
|
||||||
// * links: Avatar – Name (row)
|
|
||||||
// * rechts: Name – Avatar (row‑reverse)
|
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,108 +77,40 @@ export default function Sidebar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{/* Teams (mit Submenu) */}
|
{/* Teams (einzelner Button) */}
|
||||||
<li>
|
<li>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => toggleSubmenu('teams')}
|
onClick={() => { router.push('/teams'); setIsOpen(false) }}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="link"
|
variant="link"
|
||||||
className={`${navBtnBase} ${idleClasses} justify-between`}
|
className={`${navBtnBase} ${isActive('/teams') ? activeClasses : idleClasses}`}
|
||||||
aria-expanded={openSubmenu === 'teams'}
|
aria-label={tNav('teams.label')}
|
||||||
aria-controls="submenu-teams"
|
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-x-3.5">
|
<svg className="size-4" viewBox="0 0 640 640" fill="currentColor" aria-hidden="true">
|
||||||
<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" />
|
||||||
<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>
|
</svg>
|
||||||
|
{tNav('teams.label')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{openSubmenu === 'teams' && (
|
|
||||||
<ul id="submenu-teams" className="pl-6 space-y-1 mt-1">
|
|
||||||
<li>
|
|
||||||
<Button
|
|
||||||
onClick={() => { router.push('/teams'); 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.overview')}
|
|
||||||
</Button>
|
|
||||||
</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>
|
</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"
|
d="M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4h-4Z"
|
||||||
d="M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4h-4Z"
|
clipRule="evenodd"
|
||||||
clipRule="evenodd"
|
/>
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{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>
|
</svg>
|
||||||
|
{tNav('players.label')}
|
||||||
</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 (
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
155
src/app/[locale]/players/page.tsx
Normal file
155
src/app/[locale]/players/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,69 +135,40 @@ 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
id="create-team-modal"
|
|
||||||
title="Neues Team erstellen"
|
|
||||||
show={showCreate}
|
|
||||||
onClose={() => {
|
|
||||||
setShowCreate(false)
|
|
||||||
setNewName('')
|
|
||||||
}}
|
|
||||||
onSave={createTeam}
|
|
||||||
closeButtonColor="blue"
|
|
||||||
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label="Teamname"
|
|
||||||
value={newName}
|
|
||||||
placeholder="z. B. Ironie eSports"
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</Modal>
|
|
||||||
|
<div className="mt-4 flex justify-start">
|
||||||
|
<CreateTeamButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal: Team anlegen */}
|
||||||
|
<Modal
|
||||||
|
id="create-team-modal"
|
||||||
|
title="Neues Team erstellen"
|
||||||
|
show={showCreate}
|
||||||
|
onClose={() => {
|
||||||
|
setShowCreate(false)
|
||||||
|
setNewName('')
|
||||||
|
}}
|
||||||
|
onSave={createTeam}
|
||||||
|
closeButtonColor="blue"
|
||||||
|
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label="Teamname"
|
||||||
|
value={newName}
|
||||||
|
placeholder="z. B. Ironie eSports"
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
93
src/app/api/users/route.ts
Normal file
93
src/app/api/users/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user