diff --git a/src/app/[locale]/components/Button.tsx b/src/app/[locale]/components/Button.tsx index e53e6b2..d259026 100644 --- a/src/app/[locale]/components/Button.tsx +++ b/src/app/[locale]/components/Button.tsx @@ -10,7 +10,7 @@ type ButtonProps = { onClick?: (event: React.MouseEvent) => 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(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> = { + const variants: Record, Record, 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', }, } diff --git a/src/app/[locale]/components/GameBanner.tsx b/src/app/[locale]/components/GameBanner.tsx index fea2e6f..68b596d 100644 --- a/src/app/[locale]/components/GameBanner.tsx +++ b/src/app/[locale]/components/GameBanner.tsx @@ -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 @@ -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>(new Set()) - const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null }) const [connectHref, setConnectHref] = useState(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 = {}) { Server: {serverLabel} )} Map: {pretty.map} - Score: {pretty.score} {tGameBanner('player-connected')}: {shownConnected} / {totalExpected} ) diff --git a/src/app/[locale]/components/NotificationCenter.tsx b/src/app/[locale]/components/NotificationCenter.tsx index 2a010a6..796db3c 100644 --- a/src/app/[locale]/components/NotificationCenter.tsx +++ b/src/app/[locale]/components/NotificationCenter.tsx @@ -150,20 +150,22 @@ export default function NotificationCenter({ {needsAction ? ( <> ) : ( diff --git a/src/app/[locale]/components/PlayerCard.tsx b/src/app/[locale]/components/PlayerCard.tsx index 8cd8dd7..e9d4a8b 100644 --- a/src/app/[locale]/components/PlayerCard.tsx +++ b/src/app/[locale]/components/PlayerCard.tsx @@ -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 = { - 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 ( -
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 + " > -
-
- {player.name} + {avatar ? ( + - - {player.name} + ) : ( +
+ + + +
+ )} +
+ +
+ {/* Name + PremierRank + Faceit-Level (direkt dahinter) */} +
+ {name} + + + {hasFaceit && ( + + + + )} +
+ + {/* Teamzeile (ohne Faceit-Link/Nickname) */} +
+ {team?.name && {team.name}}
-
+ ) } diff --git a/src/app/[locale]/components/Sidebar.tsx b/src/app/[locale]/components/Sidebar.tsx index 5db72f6..811d860 100644 --- a/src/app/[locale]/components/Sidebar.tsx +++ b/src/app/[locale]/components/Sidebar.tsx @@ -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(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) => - 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() { - {/* Teams (mit Submenu) */} + {/* Teams (einzelner Button) */}
  • - - {openSubmenu === 'teams' && ( - - )}
  • - {/* Spieler (mit Submenu) */} + {/* Spieler (einzelner Button) */}
  • - - {openSubmenu === 'players' && ( - - )}
  • {/* 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() {
    ), - [openSubmenu, locale, tNav, tSidebar, isActive, changeLocale, router] + // 🔧 openSubmenu entfernt – restliche Deps reichen aus + [locale, tNav, tSidebar, isActive, changeLocale, router] ) return ( diff --git a/src/app/[locale]/components/TeamCardComponent.tsx b/src/app/[locale]/components/TeamCardComponent.tsx index 12c241a..be4351f 100644 --- a/src/app/[locale]/components/TeamCardComponent.tsx +++ b/src/app/[locale]/components/TeamCardComponent.tsx @@ -346,7 +346,7 @@ export default function TeamCardComponent({ initialTeams={initialTeams} initialInvitationMap={initialInvitationMap} /> -
    +
    diff --git a/src/app/[locale]/components/TeamMemberView.tsx b/src/app/[locale]/components/TeamMemberView.tsx index 3298633..ba2286d 100644 --- a/src/app/[locale]/components/TeamMemberView.tsx +++ b/src/app/[locale]/components/TeamMemberView.tsx @@ -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" > - + ) : ( diff --git a/src/app/[locale]/components/UserAvatarWithStatus.tsx b/src/app/[locale]/components/UserAvatarWithStatus.tsx index ce9f999..93c2060 100644 --- a/src/app/[locale]/components/UserAvatarWithStatus.tsx +++ b/src/app/[locale]/components/UserAvatarWithStatus.tsx @@ -116,7 +116,10 @@ export default function UserAvatarWithStatus({ return (
    diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 0d4825a..1d71aab 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -76,7 +76,7 @@ export default function Dashboard() { @@ -221,9 +221,6 @@ export default function Dashboard() { Match planen - - Demo importieren -
    diff --git a/src/app/[locale]/players/page.tsx b/src/app/[locale]/players/page.tsx new file mode 100644 index 0000000..326fe76 --- /dev/null +++ b/src/app/[locale]/players/page.tsx @@ -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 { + 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>({}) + + 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( + () => (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 ( + +
    +

    Players

    +
    + 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" + /> + + + + +
    +
    + + {isLoading ? ( +
    + +
    + ) : visible.length === 0 ? ( +
    + Keine Spieler gefunden. +
    + ) : ( +
    + {visible.map(u => { + const steamId = u.steamId ?? u.id + return ( + router.push(`/profile/${steamId}`)} + /> + ) + })} +
    + )} +
    + ) +} diff --git a/src/app/[locale]/teams/page.tsx b/src/app/[locale]/teams/page.tsx index 6eb6e82..40c5b74 100644 --- a/src/app/[locale]/teams/page.tsx +++ b/src/app/[locale]/teams/page.tsx @@ -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 => + 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 - if (Array.isArray(o.invitations)) return o.invitations as InviteItem[] - } - return [] -} +// parseInvitesToMap OHNE any +function parseInvitesToMap(data: InvitesJson): Record { + const map: Record = {} -function hasTeamFlag(data: UserJson): boolean { - if (typeof data === 'object' && data !== null) { - const o = data as Record - 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([]) + const [invitationMap, setInvitationMap] = useState>({}) const [loading, setLoading] = useState(true) - const [invitationMap, setInvitationMap] = useState>({}) + // 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(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(() => ({})) : 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 = {} - 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).message === 'string' - ? (j as Record).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 ( - -
    -

    - Teams verwalten -

    - - -
    - - {teams.length === 0 ? ( -
    - Es wurden noch keine Teams erstellt. -
    - ) : ( -
    - {teams.map((t) => ( - { - setInvitationMap((prev) => { - const next: Record = { ...prev } - if (newValue === null) { - delete next[teamId] // Einladung entfernt - } else { - next[teamId] = newValue // Einladung gesetzt/aktualisiert - } - return next - }) - }} - canRequestJoin={canRequestJoin} - /> - ))} -
    - )} - - { - setShowCreate(false) - setNewName('') - }} - onSave={createTeam} - closeButtonColor="blue" - closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'} - > - setNewName(e.target.value)} + + <> + {/* 👉 komplette Liste + Suche/Sortierung aus NoTeamView */} + - + +
    + +
    + + {/* Modal: Team anlegen */} + { + setShowCreate(false) + setNewName('') + }} + onSave={createTeam} + closeButtonColor="blue" + closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'} + > + setNewName(e.target.value)} + /> + +
    ) } diff --git a/src/app/api/matches/create/route.ts b/src/app/api/matches/create/route.ts index 239cdf7..4c64606 100644 --- a/src/app/api/matches/create/route.ts +++ b/src/app/api/matches/create/route.ts @@ -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): diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..63e7353 --- /dev/null +++ b/src/app/api/users/route.ts @@ -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 }) + } +} diff --git a/src/lib/sseEvents.ts b/src/lib/sseEvents.ts index 66467f0..84261e4 100644 --- a/src/lib/sseEvents.ts +++ b/src/lib/sseEvents.ts @@ -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);