updated for build
This commit is contained in:
parent
844ac4fe33
commit
386f701ad5
@ -10,7 +10,7 @@ type ButtonProps = {
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
onToggle?: (open: boolean) => void
|
||||
modalId?: string
|
||||
color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'transparent'
|
||||
color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'yellow' | 'transparent'
|
||||
variant?: 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link'
|
||||
/** Steuert NUR Höhe/Abstände */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
@ -80,57 +80,73 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
${sizeClasses[size] || sizeClasses['md']}
|
||||
inline-flex items-center gap-x-2 ${textSizeClasses[textSize] || 'text-sm'}
|
||||
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: {
|
||||
blue: 'bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700',
|
||||
red: 'bg-red-600 text-white hover:bg-red-700 focus:bg-red-700',
|
||||
gray: 'bg-gray-600 text-white hover:bg-gray-700 focus:bg-gray-700',
|
||||
teal: 'bg-teal-600 text-white hover:bg-teal-700 focus:bg-teal-700',
|
||||
green: 'bg-green-600 text-white hover:bg-green-700 focus:bg-green-700',
|
||||
transparent: 'bg-transparent-600 text-white hover:bg-transparent-700 focus:bg-transparent-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',
|
||||
// Preline nutzt bei Blau 600/700
|
||||
blue: 'border border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700',
|
||||
teal: 'border border-transparent bg-teal-500 text-white hover:bg-teal-600 focus:bg-teal-600',
|
||||
red: 'border border-transparent bg-red-500 text-white hover:bg-red-600 focus:bg-red-600',
|
||||
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: {
|
||||
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',
|
||||
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',
|
||||
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-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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
teal: 'border border-teal-500 text-teal-500 hover:border-teal-400 hover:text-teal-400 focus:border-teal-400 focus:text-teal-400',
|
||||
red: 'border border-red-500 text-red-500 hover:border-red-400 hover:text-red-400 focus:border-red-400 focus:text-red-400',
|
||||
yellow:'border border-yellow-500 text-yellow-500 hover:border-yellow-400 focus:border-yellow-400 focus:text-yellow-400',
|
||||
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: {
|
||||
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',
|
||||
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',
|
||||
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-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',
|
||||
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',
|
||||
transparent: 'border border-transparent text-white hover:bg-white/10 focus:bg-white/10 dark:text-white',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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: {
|
||||
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',
|
||||
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',
|
||||
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: '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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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: {
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
// entspricht deinem "White"-Block in Preline
|
||||
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-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',
|
||||
},
|
||||
|
||||
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',
|
||||
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',
|
||||
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: '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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
teal: 'inline-flex items-center gap-x-2 font-semibold text-teal-500 hover:text-teal-800 focus:text-teal-800',
|
||||
red: 'inline-flex items-center gap-x-2 font-semibold text-red-500 hover:text-red-800 focus:text-red-800',
|
||||
yellow:'inline-flex items-center gap-x-2 font-semibold text-yellow-500 hover:text-yellow-800 focus:text-yellow-800',
|
||||
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
|
||||
iconUrl?: string
|
||||
connectUri?: string
|
||||
score?: string
|
||||
connectedCount?: number
|
||||
totalExpected?: number
|
||||
onReconnect?: () => void
|
||||
@ -66,24 +65,10 @@ type PlayerLeaveMsg = {
|
||||
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 =
|
||||
| PlayersMsg
|
||||
| PlayerJoinMsg
|
||||
| PlayerLeaveMsg
|
||||
| ScoreMsg
|
||||
| ScoreWrapMsg
|
||||
| Record<string, unknown>
|
||||
|
||||
|
||||
@ -123,11 +108,6 @@ const parseWsData = (data: unknown): TelemetryMsg | 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) {
|
||||
const h = (host ?? '').trim() || '127.0.0.1'
|
||||
const p = (port ?? '').trim() || '8081'
|
||||
@ -153,7 +133,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
||||
bgUrl: bgUrlProp,
|
||||
iconUrl: iconUrlProp,
|
||||
connectUri: connectUriProp,
|
||||
score: scoreProp,
|
||||
connectedCount: connectedCountProp,
|
||||
totalExpected: totalExpectedProp,
|
||||
onReconnect,
|
||||
@ -195,7 +174,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
||||
|
||||
// Local live-state
|
||||
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)
|
||||
|
||||
// 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 })
|
||||
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')
|
||||
|
||||
// Score-String
|
||||
const scoreStr = scoreProp ?? ((score.a == null || score.b == null) ? '– : –' : `${score.a} : ${score.b}`)
|
||||
|
||||
const envConnect =
|
||||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
|
||||
process.env.NEXT_PUBLIC_STEAM_CONNECT_URI ||
|
||||
@ -368,7 +327,6 @@ export default function GameBanner(props: GameBannerProps = {}) {
|
||||
const bgUrl = bgUrlProp ?? cfg?.activeMapBg ?? undefined
|
||||
const pretty = {
|
||||
map: mapLabel ?? mapKey ?? '—',
|
||||
score: scoreStr,
|
||||
}
|
||||
|
||||
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>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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -150,20 +150,22 @@ export default function NotificationCenter({
|
||||
{needsAction ? (
|
||||
<>
|
||||
<Button
|
||||
title="Accept"
|
||||
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"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
>
|
||||
✔
|
||||
<span className="text-white-600">✓</span>
|
||||
</Button>
|
||||
<Button
|
||||
title="Reject"
|
||||
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"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
>
|
||||
✖
|
||||
<span className="text-white-600">✕</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -1,79 +1,92 @@
|
||||
// /src/app/[locale]/components/PlayerCard.tsx
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { Player } from '../../../types/team'
|
||||
import UserAvatarWithStatus from './UserAvatarWithStatus'
|
||||
import PremierRankBadge from './PremierRankBadge'
|
||||
import FaceitLevelImage from './FaceitLevelBadge'
|
||||
|
||||
export type CardWidth =
|
||||
| 'sm' // max-w-sm (24rem)
|
||||
| 'md' // max-w-md (28rem)
|
||||
| 'lg' // max-w-lg (32rem)
|
||||
| 'xl' // max-w-xl (36rem)
|
||||
| '2xl' // max-w-2xl (42rem)
|
||||
| 'full' // w-full
|
||||
| 'auto' // keine Begrenzung
|
||||
|
||||
type Props = {
|
||||
player: Player
|
||||
align?: 'left' | 'right'
|
||||
maxWidth?: CardWidth
|
||||
type PlayerCardProps = {
|
||||
id: string
|
||||
steamId?: string // ⬅️ neu: fürs Presence-Widget
|
||||
name: string
|
||||
avatar?: string
|
||||
country?: string // bleibt im Typ, wird nicht gerendert
|
||||
rating?: number
|
||||
team?: { id: string; name: string } | null
|
||||
faceit?: {
|
||||
level?: number | null
|
||||
elo?: number | null
|
||||
nickname?: string | null
|
||||
url?: string | null
|
||||
} | null
|
||||
onClick?: () => void // wird von der Page gesetzt (Profil öffnen)
|
||||
}
|
||||
|
||||
export default function PlayerCard({
|
||||
player,
|
||||
align = 'left',
|
||||
maxWidth = 'sm',
|
||||
}: Props) {
|
||||
/* --- Hilfs‑Klassen ----------------------------------------------------- */
|
||||
|
||||
const widthClasses: Record<CardWidth, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
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 --------------------------------------------------------- */
|
||||
steamId,
|
||||
name,
|
||||
avatar,
|
||||
rating,
|
||||
team,
|
||||
faceit,
|
||||
onClick,
|
||||
}: PlayerCardProps) {
|
||||
const rankVal = typeof rating === 'number' && Number.isFinite(rating) ? rating : 0
|
||||
const hasFaceit = typeof faceit?.level === 'number' || typeof faceit?.elo === 'number'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
|
||||
dark:bg-neutral-900 dark:border-neutral-700 dark:shadow-neutral-700/70
|
||||
${alignClasses} ${widthClasses[maxWidth]}
|
||||
`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.()}
|
||||
className="
|
||||
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 className={`flex items-center gap-3 ${rowDir}`}>
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={player.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full shrink-0 border object-cover"
|
||||
<div>
|
||||
{avatar ? (
|
||||
<UserAvatarWithStatus
|
||||
steamId={steamId}
|
||||
src={avatar}
|
||||
alt={name}
|
||||
size={32}
|
||||
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>
|
||||
{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>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,10 +5,8 @@ import { useRouter, usePathname } from '@/i18n/navigation'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import Button from './Button'
|
||||
import SidebarFooter from './SidebarFooter'
|
||||
import Select from './Select';
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
|
||||
type Submenu = 'teams' | 'players' | null
|
||||
import Select from './Select'
|
||||
import 'flag-icons/css/flag-icons.min.css'
|
||||
|
||||
export default function Sidebar() {
|
||||
const router = useRouter()
|
||||
@ -20,7 +18,6 @@ export default function Sidebar() {
|
||||
const tSidebar = useTranslations('sidebar')
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false) // mobile drawer
|
||||
const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null)
|
||||
|
||||
// Aktive Route prüfen (pathname kommt schon ohne Locale)
|
||||
const isActive = useCallback((path: string) => pathname === path, [pathname])
|
||||
@ -34,9 +31,6 @@ export default function Sidebar() {
|
||||
const idleClasses =
|
||||
'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
|
||||
const changeLocale = useCallback((nextLocale: 'en' | 'de') => {
|
||||
if (nextLocale === locale) return
|
||||
@ -83,108 +77,40 @@ export default function Sidebar() {
|
||||
</Button>
|
||||
</li>
|
||||
|
||||
{/* Teams (mit Submenu) */}
|
||||
{/* Teams (einzelner Button) */}
|
||||
<li>
|
||||
<Button
|
||||
onClick={() => toggleSubmenu('teams')}
|
||||
onClick={() => { router.push('/teams'); setIsOpen(false) }}
|
||||
size="sm"
|
||||
variant="link"
|
||||
className={`${navBtnBase} ${idleClasses} justify-between`}
|
||||
aria-expanded={openSubmenu === 'teams'}
|
||||
aria-controls="submenu-teams"
|
||||
className={`${navBtnBase} ${isActive('/teams') ? activeClasses : idleClasses}`}
|
||||
aria-label={tNav('teams.label')}
|
||||
>
|
||||
<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 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>
|
||||
|
||||
{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>
|
||||
|
||||
{/* Spieler (mit Submenu) */}
|
||||
{/* Spieler (einzelner Button) */}
|
||||
<li>
|
||||
<Button
|
||||
onClick={() => toggleSubmenu('players')}
|
||||
onClick={() => { router.push('/players'); setIsOpen(false) }}
|
||||
size="sm"
|
||||
variant="link"
|
||||
className={`${navBtnBase} ${idleClasses} justify-between`}
|
||||
aria-expanded={openSubmenu === 'players'}
|
||||
aria-controls="submenu-players"
|
||||
className={`${navBtnBase} ${isActive('/players') ? activeClasses : idleClasses}`}
|
||||
aria-label={tNav('players.label')}
|
||||
>
|
||||
<span className="flex items-center gap-x-3.5">
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
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"
|
||||
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 className="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{tNav('players.label')}
|
||||
</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>
|
||||
|
||||
{/* Spielplan */}
|
||||
@ -216,7 +142,7 @@ export default function Sidebar() {
|
||||
onChange={(val) => changeLocale(val as 'en' | 'de')}
|
||||
dropDirection="up"
|
||||
showArrow={false}
|
||||
fullWidth={false} // 👈 NEU
|
||||
fullWidth={false}
|
||||
options={[
|
||||
{
|
||||
value: 'en',
|
||||
@ -247,7 +173,8 @@ export default function Sidebar() {
|
||||
</footer>
|
||||
</div>
|
||||
),
|
||||
[openSubmenu, locale, tNav, tSidebar, isActive, changeLocale, router]
|
||||
// 🔧 openSubmenu entfernt – restliche Deps reichen aus
|
||||
[locale, tNav, tSidebar, isActive, changeLocale, router]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@ -346,7 +346,7 @@ export default function TeamCardComponent({
|
||||
initialTeams={initialTeams}
|
||||
initialInvitationMap={initialInvitationMap}
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<div className="mt-4 flex justify-start">
|
||||
<CreateTeamButton />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -800,7 +800,7 @@ function TeamMemberViewBody({
|
||||
title="Übernehmen"
|
||||
color="green"
|
||||
size="sm"
|
||||
variant="soft"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
await renameTeam(team.id, editedName)
|
||||
setIsEditingName(false)
|
||||
@ -808,7 +808,7 @@ function TeamMemberViewBody({
|
||||
}}
|
||||
className="h-[34px] px-3 flex items-center justify-center"
|
||||
>
|
||||
<span className="text-green-600">✓</span>
|
||||
<span className="text-white-600">✓</span>
|
||||
</Button>
|
||||
<Button
|
||||
title="Abbrechen"
|
||||
@ -821,7 +821,7 @@ function TeamMemberViewBody({
|
||||
}}
|
||||
className="h-[34px] px-3 flex items-center justify-center"
|
||||
>
|
||||
<span className="text-red-600">✕</span>
|
||||
<span className="text-white-600">✕</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -116,7 +116,10 @@ export default function UserAvatarWithStatus({
|
||||
|
||||
return (
|
||||
<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 }}
|
||||
{...rest}
|
||||
>
|
||||
|
||||
@ -76,7 +76,7 @@ export default function Dashboard() {
|
||||
</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"
|
||||
>
|
||||
<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">
|
||||
Match planen
|
||||
</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>
|
||||
|
||||
|
||||
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
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Button from '../components/Button'
|
||||
import Modal from '../components/Modal'
|
||||
import Input from '../components/Input'
|
||||
import TeamCard from '../components/TeamCard'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import NoTeamView from '../components/NoTeamView'
|
||||
import type { Team } from '../../../types/team'
|
||||
import Card from '../components/Card'
|
||||
import CreateTeamButton from '../components/CreateTeamButton'
|
||||
|
||||
// Minimale Response-Typen / Guards
|
||||
type TeamsJson = { items?: Team[]; teams?: Team[] } | Team[] | unknown
|
||||
type InviteItem = { type?: string; teamId?: string; id?: string }
|
||||
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[] {
|
||||
if (Array.isArray(data)) return data as Team[]
|
||||
@ -28,79 +37,53 @@ function parseTeams(data: TeamsJson): Team[] {
|
||||
return []
|
||||
}
|
||||
|
||||
function parseInvites(data: InvitesJson): InviteItem[] {
|
||||
if (Array.isArray(data)) return data as InviteItem[]
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const o = data as Record<string, unknown>
|
||||
if (Array.isArray(o.invitations)) return o.invitations as InviteItem[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
// parseInvitesToMap OHNE any
|
||||
function parseInvitesToMap(data: InvitesJson): Record<string, string> {
|
||||
const map: Record<string, string> = {}
|
||||
|
||||
function hasTeamFlag(data: UserJson): boolean {
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const o = data as Record<string, unknown>
|
||||
return o.team != null
|
||||
const arr: InviteItem[] = Array.isArray(data)
|
||||
? data.filter(isInviteItem)
|
||||
: (isRecord(data) && Array.isArray((data as { invitations?: unknown }).invitations)
|
||||
? ((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() {
|
||||
const { data: session } = useSession()
|
||||
const mySteamId = (session?.user as { steamId?: string } | undefined)?.steamId ?? ''
|
||||
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [invitationMap, setInvitationMap] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [invitationMap, setInvitationMap] = useState<Record<string, string>>({})
|
||||
// Create-Team Modal
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Flag aus /api/user
|
||||
const [userHasTeam, setUserHasTeam] = useState<boolean>(false)
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [tRes, iRes, uRes] = await Promise.all([
|
||||
const [tRes, iRes] = await Promise.all([
|
||||
fetch('/api/teams', { 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(),
|
||||
iRes.ok ? iRes.json().catch<unknown>(() => ({})) : Promise.resolve({} as unknown),
|
||||
uRes.ok ? uRes.json() : Promise.resolve({} as unknown),
|
||||
])
|
||||
|
||||
const nextTeams = parseTeams(tJson)
|
||||
const invites = parseInvites(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))
|
||||
setTeams(parseTeams(tJson))
|
||||
setInvitationMap(parseInvitesToMap(iJson))
|
||||
} catch (err) {
|
||||
console.error('[TeamsPage] load failed:', err)
|
||||
setTeams([])
|
||||
setInvitationMap({})
|
||||
setUserHasTeam(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -113,6 +96,7 @@ export default function TeamsPage() {
|
||||
fetchAll()
|
||||
}, [fetchAll])
|
||||
|
||||
// createTeam: Fehler-JSON ohne any
|
||||
const createTeam = async () => {
|
||||
if (!newName.trim()) return
|
||||
setSaving(true)
|
||||
@ -123,11 +107,12 @@ export default function TeamsPage() {
|
||||
body: JSON.stringify({ teamname: newName.trim() }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const j: unknown = await res.json().catch(() => ({}))
|
||||
type ErrJson = { message?: string }
|
||||
const raw: unknown = await res.json().catch(() => ({}))
|
||||
const msg =
|
||||
(typeof (j as Record<string, unknown>).message === 'string'
|
||||
? (j as Record<string, unknown>).message
|
||||
: null) ?? 'Team konnte nicht erstellt werden.'
|
||||
isRecord(raw) && typeof (raw as ErrJson).message === 'string'
|
||||
? (raw as ErrJson).message!
|
||||
: 'Team konnte nicht erstellt werden.'
|
||||
alert(msg)
|
||||
return
|
||||
}
|
||||
@ -150,69 +135,40 @@ export default function TeamsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Nur anzeigen, wenn der Spieler in KEINEM Team ist
|
||||
const canRequestJoin = !userHasTeam
|
||||
|
||||
return (
|
||||
<Card maxWidth="full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
Teams verwalten
|
||||
</h2>
|
||||
|
||||
<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)}
|
||||
<Card maxWidth='full'>
|
||||
<>
|
||||
{/* 👉 komplette Liste + Suche/Sortierung aus NoTeamView */}
|
||||
<NoTeamView
|
||||
initialTeams={teams}
|
||||
initialInvitationMap={invitationMap}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -197,7 +197,7 @@ export async function POST (req: NextRequest) {
|
||||
data: {
|
||||
steamId : uid,
|
||||
title : 'Match erstellt',
|
||||
message,
|
||||
message: message,
|
||||
actionType: 'match-created',
|
||||
actionData: created.id,
|
||||
},
|
||||
@ -230,6 +230,7 @@ export async function POST (req: NextRequest) {
|
||||
title : safeTitle,
|
||||
startsAt: (created.matchDate ?? created.demoDate ?? plannedAt).toISOString(),
|
||||
bestOf : bestOfInt,
|
||||
message: message,
|
||||
})
|
||||
await sendServerSSEMessage({ type: 'matches-updated' })
|
||||
// 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',
|
||||
'match-ended',
|
||||
'server-reset',
|
||||
'new-cs2-match',
|
||||
] as const;
|
||||
|
||||
|
||||
@ -114,6 +115,11 @@ export const NOTIFICATION_EVENTS = makeEventSet([
|
||||
'team-joined',
|
||||
'team-leader-changed',
|
||||
'team-leader-self',
|
||||
'team-member-joined',
|
||||
'team-member-left',
|
||||
'team-member-kicked',
|
||||
'match-created',
|
||||
'new-cs2-match',
|
||||
'expired-sharecode',
|
||||
] as const);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user