2025-10-14 15:30:11 +02:00

460 lines
22 KiB
TypeScript

// /src/app/[locale]/components/radar/TeamSidebar.tsx
'use client'
import React, { useEffect, useState } from 'react'
import Image from 'next/image'
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'
export type Team = 'T' | 'CT'
export type SidebarPlayer = {
id: string
name?: string | null
hp?: number | null
armor?: number | null
helmet?: boolean | null
defuse?: boolean | null
hasBomb?: boolean | null
alive?: boolean | null
activeWeapon?: string | { name?: string | null } | null
grenades?: Partial<Record<
'hegrenade'|'smokegrenade'|'flashbang'|'decoy'|'molotov'|'incgrenade',
number
>> | null
weapons?: { name: string; state?: string | null }[] | null
}
const EQUIP_BASE = '/assets/img/icons/equipment'
const equipIcon = (file: string) => `${EQUIP_BASE}/${file}`
/* ── Inline SVG Icons (weiß via currentColor) ── */
const HeartIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
<svg aria-hidden viewBox="0 0 640 640" className={className + ' text-white'} fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M305 151.1L320 171.8L335 151.1C360 116.5 400.2 96 442.9 96C516.4 96 576 155.6 576 229.1L576 231.7C576 343.9 436.1 474.2 363.1 529.9C350.7 539.3 335.5 544 320 544C304.5 544 289.2 539.4 276.9 529.9C203.9 474.2 64 343.9 64 231.7L64 229.1C64 155.6 123.6 96 197.1 96C239.8 96 280 116.5 305 151.1z"/>
</svg>
)
const ShieldIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
<svg aria-hidden viewBox="0 0 640 640" className={className + ' text-white'} fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M320 64C324.6 64 329.2 65 333.4 66.9L521.8 146.8C543.8 156.1 560.2 177.8 560.1 204C559.6 303.2 518.8 484.7 346.5 567.2C329.8 575.2 310.4 575.2 293.7 567.2C121.3 484.7 80.6 303.2 80.1 204C80 177.8 96.4 156.1 118.4 146.8L306.7 66.9C310.9 65 315.4 64 320 64z"/>
</svg>
)
/* ── Rotes Bomben-Icon via CSS-Maske ── */
const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string; title?: string; className?: string }) => (
<span
title={title}
role="img"
aria-label={title}
className={`inline-block align-middle bg-red-500 ${className}`}
style={{
maskImage: `url("${src}")`,
WebkitMaskImage: `url("${src}")`,
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
maskSize: 'contain',
WebkitMaskSize: 'contain',
}}
/>
)
/* ── kleine Image-Hilfen ── */
function IconImg({
src, alt, title, w, h, className,
}: { src: string; alt: string; title?: string; w: number; h: number; className?: string }) {
return (
<span className={className} title={title} aria-label={title}>
<Image src={src} alt={alt} width={w} height={h} unoptimized />
</span>
)
}
/* ── Gear Blöcke ── */
function leftGear(opts: { armor?: number|null; helmet?: boolean|null }) {
const out: { src: string; title: string; key: string }[] = []
if ((opts.armor ?? 0) > 0) out.push({ src: equipIcon('armor.svg'), title: 'Kevlar', key: 'armor' })
if (opts.helmet) out.push({ src: equipIcon('helmet.svg'), title: 'Helmet', key: 'helmet' })
return out
}
function rightGear(opts: { hasBomb?: boolean|null; team: Team; defuse?: boolean|null }) {
const out: { src: string; title: string; key: string }[] = []
if (opts.hasBomb) out.push({ src: equipIcon('c4.svg'), title: 'C4', key: 'c4' })
if (opts.team === 'CT' && opts.defuse) out.push({ src: equipIcon('defuser.svg'), title: 'Defuse Kit', key: 'defuser' })
return out
}
/* ── Normalisierung ── */
function normWeaponName(raw?: string | null) {
if (!raw) return ''
let k = String(raw).toLowerCase().replace(/^weapon_/, '').replace(/\s+/g, '')
if (k === 'usp-s' || k === 'usp-silencer') k = 'usp_silencer'
if (k === 'm4a1-s' || k === 'm4a1s') k = 'm4a1_silencer'
if (k === 'm4a1s_off' || k === 'm4a1-s_off') k = 'm4a1_silencer_off'
return k
}
function isActiveWeapon(itemName?: string|null, active?: string | { name?: string|null } | null, state?: string|null) {
if ((state ?? '').toLowerCase() === 'active') return true
const ni = normWeaponName(itemName)
const na = typeof active === 'string' ? normWeaponName(active) : normWeaponName(active?.name ?? null)
return !!ni && !!na && ni === na
}
/* ── Sets ── */
const GRENADE_SET = new Set(['hegrenade','smokegrenade','flashbang','decoy','molotov','incgrenade'])
const PRIMARY_SET = new Set([
'ak47','aug','sg556','galilar','famas','m4a1','m4a1_silencer','m4a1_silencer_off',
'awp','ssg08','scar20','g3sg1','xm1014','mag7','sawedoff','nova','m249','negev',
'p90','ump45','mp9','mp7','mp5sd','mac10','bizon'
])
const SECONDARY_SET = new Set([
'hkp2000','p2000','p250','glock','deagle','elite','usp_silencer','usp_silencer_off',
'fiveseven','cz75a','tec9','revolver','taser'
])
/* ── Icons ── */
const WEAPON_ALIAS: Record<string, string> = {
// Pistols
'hkp2000':'hkp2000','p2000':'p2000','p250':'p250','glock':'glock',
'deagle':'deagle','elite':'elite','usp_silencer':'usp_silencer','usp':'usp_silencer',
'usp_silencer_off':'usp_silencer_off','fiveseven':'fiveseven','cz75a':'cz75a','tec9':'tec9','revolver':'revolver',
// SMGs
'mac10':'mac10','mp7':'mp7','mp5sd':'mp5sd','mp9':'mp9','bizon':'bizon','ump45':'ump45','p90':'p90',
// Rifles
'ak47':'ak47','aug':'aug','sg556':'sg556','galilar':'galilar','famas':'famas',
'm4a1':'m4a1','m4a1_silencer':'m4a1_silencer','m4a1_silencer_off':'m4a1_silencer_off',
// Snipers / Heavy / Shotguns
'awp':'awp','ssg08':'ssg08','scar20':'scar20','g3sg1':'g3sg1',
'xm1014':'xm1014','mag7':'mag7','sawedoff':'sawedoff','nova':'nova',
'm249':'m249','negev':'negev',
// Grenades / misc
'hegrenade':'hegrenade','incgrenade':'incgrenade','molotov':'molotov',
'smokegrenade':'smokegrenade','flashbang':'flashbang','decoy':'decoy',
'taser':'taser','defuser':'defuser','c4':'c4','planted_c4':'planted_c4',
// Knives
'knife':'knife','knife_t':'knife_t','melee':'melee'
}
function weaponIconFromName(raw?: string | null): string | null {
if (!raw) return null
const k = normWeaponName(raw)
const file = WEAPON_ALIAS[k]
return file ? equipIcon(`${file}.svg`) : null
}
const GRENADE_DISPLAY_ORDER = ['flashbang','smokegrenade','hegrenade','molotov','incgrenade','decoy'] as const
function grenadeIconFromKey(k: string): string {
switch (k) {
case 'hegrenade': return equipIcon('hegrenade.svg')
case 'smokegrenade':return equipIcon('smokegrenade.svg')
case 'flashbang': return equipIcon('flashbang.svg')
case 'decoy': return equipIcon('decoy.svg')
case 'molotov': return equipIcon('molotov.svg')
case 'incgrenade': return equipIcon('incgrenade.svg')
default: return equipIcon('hegrenade.svg')
}
}
export default function TeamSidebar({
team, teamId, players, align = 'left', onHoverPlayer, score, oppScore
}: {
team: Team
teamId?: string
players: SidebarPlayer[]
align?: 'left' | 'right'
onHoverPlayer?: (id: string | null) => void
score?: number
oppScore?: number
}) {
const [teamLogo, setTeamLogo] = useState<string | null>(null)
const [teamApiName, setTeamApiName] = useState<string | null>(null)
const BOT_ICON = '/assets/img/icons/ui/bot.svg'
const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:')
useEffect(() => {
let abort = false
;(async () => {
if (!teamId) { setTeamLogo(null); setTeamApiName(null); return }
try {
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!abort) { setTeamLogo(data?.logo || null); setTeamApiName(data?.name || null) }
} catch { if (!abort) { setTeamLogo(null); setTeamApiName(null) } }
})()
return () => { abort = true }
}, [teamId])
const ensureTeamsLoaded = useAvatarDirectoryStore(s => s.ensureTeamsLoaded)
const avatarById = useAvatarDirectoryStore(s => s.byId)
const avatarVer = useAvatarDirectoryStore(s => s.version)
useEffect(() => { if (teamId) ensureTeamsLoaded([teamId]) }, [teamId, ensureTeamsLoaded])
const defaultTeamName = team === 'CT' ? 'Counter-Terrorists' : 'Terrorists'
const teamName = teamApiName || defaultTeamName
const teamColor = team === 'CT' ? 'text-blue-400' : 'text-amber-400'
const barArmor = team === 'CT' ? 'bg-blue-500' : 'bg-amber-500'
const ringColor = team === 'CT' ? 'ring-blue-500' : 'ring-amber-500'
const isRight = align === 'right'
const fallbackLogo = '/assets/img/logos/cs2.webp'
const logoSrc = teamLogo || fallbackLogo
const aliveCount = players.filter(p => p.alive !== false && (p.hp ?? 1) > 0).length
const sorted = [...players].sort((a,b)=>{
const al = (b.alive ? 1 : 0) - (a.alive ? 1 : 0); if (al) return al
const hp = (b.hp ?? -1) - (a.hp ?? -1); if (hp) return hp
return (a.name ?? '').localeCompare(b.name ?? '')
})
// lokaler Typ für Avatar-Lookup (eliminiert 'any')
type AvatarEntry = { avatar?: string; notFound?: boolean }
const byId: Record<string, AvatarEntry | undefined> = avatarById as Record<string, AvatarEntry | undefined>
void avatarVer // nur, um Re-Render bei Versionswechsel zu triggern
return (
<aside className="h-full min-h-0 flex flex-col rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between text-xs uppercase tracking-wide opacity-80">
<span className={`font-semibold flex items-center gap-2 ${teamColor}`}>
{/* Logo größer */}
<span className="relative block h-8 w-8 md:h-9 md:w-9">
<Image src={logoSrc} alt={teamName} fill sizes="36px" className="object-contain" unoptimized />
</span>
<span className="hidden sm:inline">{teamName}</span>
</span>
<span className="flex items-center gap-2">
{(typeof score === 'number' && typeof oppScore === 'number') && (
<span className="px-2 py-0.5 rounded bg-black/45 text-white text-[11px] font-semibold tabular-nums">
{score}<span className="opacity-60 mx-1">:</span>{oppScore}
</span>
)}
<span className="tabular-nums">{aliveCount}/{players.length}</span>
</span>
</div>
<div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
{sorted.map(p=>{
const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100)
const armor = clamp(p.armor ?? 0, 0, 100)
const dead = p.alive === false || hp <= 0
const entry: AvatarEntry | undefined = byId[p.id]
const avatarUrl = isBotId(p.id)
? BOT_ICON
: (entry && !entry?.notFound && entry?.avatar ? entry.avatar : '/assets/img/avatars/default_steam_avatar.jpg')
// ---- Waffen split ----
const all = (p.weapons ?? []).filter(w => !GRENADE_SET.has(normWeaponName(w.name)))
const active = p.activeWeapon
const prim = all.find(w => PRIMARY_SET.has(normWeaponName(w.name)))
const sec = all.find(w => SECONDARY_SET.has(normWeaponName(w.name)))
const knife = all.find(w => {
const n = normWeaponName(w.name); return n === 'knife' || n === 'knife_t' || n === 'melee'
})
const primIcon = weaponIconFromName(prim?.name) ?? (prim ? equipIcon('melee.svg') : null)
const secIcon = weaponIconFromName(sec?.name) ?? (sec ? equipIcon('melee.svg') : null)
const knifeIcon = weaponIconFromName(knife?.name) ?? (knife ? equipIcon('knife.svg') : null)
const primActive = prim ? isActiveWeapon(prim.name, active, prim.state ) : false
const secActive = sec ? isActiveWeapon(sec.name, active, sec.state ) : false
const knifeActive = knife ? isActiveWeapon(knife.name, active, knife.state) : false
return (
<div
key={`player-${p.id}`}
id={`player-${p.id}`}
onMouseEnter={()=>onHoverPlayer?.(p.id)}
onMouseLeave={()=>onHoverPlayer?.(null)}
tabIndex={0}
className={`
rounded-md px-2 py-2 cursor-pointer outline-none
bg-white dark:bg-neutral-800
hover:bg-neutral-200 hover:dark:bg-neutral-700
focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 focus-visible:ring-offset-white/10
${dead ? 'opacity-60' : ''}
`}
>
<div className={`flex ${isRight ? 'flex-row-reverse text-right' : 'flex-row'} items-center gap-3`}>
{/* Avatar mit Bomben-Glow / Dead-Desaturierung */}
<div className={`rounded-full ${p.hasBomb ? 'ring-2 ring-red-500/70 shadow-[0_0_12px_rgba(239,68,68,.35)]' : ''}`}>
<span className={`relative block h-12 w-12 rounded-full border border-white/10 ring-2 ${ringColor} bg-neutral-900 p-1 overflow-hidden ${dead ? 'grayscale opacity-70' : ''}`}>
<Image
src={avatarUrl}
alt={p.name || p.id}
fill
sizes="48px"
className="object-contain"
unoptimized
/>
</span>
</div>
<div className={`flex-1 min-w-0 flex flex-col ${isRight ? 'items-end' : 'items-start'}`}>
{/* Kopfzeile: Name & Gear */}
{!isRight ? (
<div className="flex items-center justify-between w-full min-h-[22px] gap-2">
<span className="truncate font-medium text-left tracking-wide [font-variant-numeric:tabular-nums]">
{p.name || p.id}
</span>
<span className="inline-flex items-center gap-1">
{leftGear({ armor: p.armor, helmet: p.helmet }).concat(
rightGear({ hasBomb: p.hasBomb, defuse: p.defuse, team })
).map(icon => (
icon.key === 'c4'
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
: <IconImg key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} w={20} h={20} className="inline-flex" />
))}
</span>
</div>
) : (
<div className="flex items-center justify-between w-full min-h-[22px] gap-2">
<span className="inline-flex items-center gap-1">
{leftGear({ armor: p.armor, helmet: p.helmet }).concat(
rightGear({ hasBomb: p.hasBomb, defuse: p.defuse, team })
).map(icon => (
icon.key === 'c4'
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
: <IconImg key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} w={20} h={20} className="inline-flex" />
))}
</span>
<span className="truncate font-medium text-right tracking-wide [font-variant-numeric:tabular-nums]">
{p.name || p.id}
</span>
</div>
)}
{/* Waffenzeile */}
<div
className={[
'mt-1 w-full flex items-center',
primIcon && (secIcon || knifeIcon)
? (isRight ? 'flex-row-reverse justify-between' : 'justify-between')
: (isRight ? 'justify-end' : 'justify-start')
].join(' ')}
>
{/* Primär */}
{primIcon && (
<div className="flex items-center gap-3 shrink-0">
<IconImg
src={primIcon}
alt={prim?.name ?? 'primary'}
title={prim?.name ?? 'primary'}
w={64}
h={64}
className={`p-2 rounded-md ${
primActive
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30'
: 'grayscale brightness-90 contrast-75 opacity-90'
}`}
/>
</div>
)}
{/* Sekundär + Messer */}
{(secIcon || knifeIcon) && (
<div className={['flex items-center gap-2', !primIcon ? (isRight ? 'justify-end' : 'justify-start') : ''].join(' ')}>
{secIcon && (
<IconImg
src={secIcon}
alt={sec?.name ?? 'secondary'}
title={sec?.name ?? 'secondary'}
w={40}
h={40}
className={`p-2 rounded-md ${
secActive
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30'
: 'grayscale brightness-90 contrast-75 opacity-90'
}`}
/>
)}
{knifeIcon && (
<IconImg
src={knifeIcon}
alt={knife?.name ?? 'knife'}
title={knife?.name ?? 'knife'}
w={40}
h={40}
className={knifeActive
? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2'
: 'grayscale brightness-90 contrast-75 opacity-90'}
/>
)}
</div>
)}
</div>
{/* Granaten */}
<div className={`mt-2 flex items-center gap-1 ${isRight ? 'justify-start' : 'justify-end'}`}>
{GRENADE_DISPLAY_ORDER.flatMap(k=>{
const c = p.grenades?.[k] ?? 0
if (!c) return []
const src = grenadeIconFromKey(k)
return Array.from({ length: c }, (_,i)=>(
<IconImg key={`${k}-${i}`} src={src} alt={k} title={k} w={16} h={16} />
))
})}
</div>
{/* HP / Armor Bars */}
<div className="mt-2 w-full space-y-2">
{/* HP */}
<div
className="relative h-4 rounded-md bg-neutral-800/80 ring-1 ring-black/40 overflow-hidden"
title={`HP: ${hp}`}
aria-label={`HP ${hp}`}
>
<div
className={[
'h-full transition-[width] duration-300 ease-out',
hp > 66 ? 'bg-green-500' : hp > 20 ? 'bg-amber-500' : 'bg-red-500',
hp > 0 && hp <= 20 ? 'animate-hpPulse' : ''
].join(' ')}
style={{ width: `${hp}%` }}
/>
<div className="pointer-events-none absolute inset-0 opacity-70 mix-blend-overlay bg-[repeating-linear-gradient(to_right,transparent,transparent_11px,rgba(255,255,255,0.06)_12px)]" />
<div className="absolute inset-0 flex items-center justify-between px-2 text-[11px] font-semibold text-white/95">
<span className="flex items-center gap-1 select-none"><HeartIcon /></span>
<span className="tabular-nums select-none drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">{hp}</span>
</div>
</div>
{/* Armor */}
<div
className="relative h-4 rounded-md bg-neutral-800/80 ring-1 ring-black/40 overflow-hidden"
title={`Kevlar: ${armor}%`}
aria-label={`Armor ${armor} Prozent`}
>
<div className={`h-full transition-[width] duration-300 ease-out ${barArmor}`} style={{ width: `${armor}%` }} />
<div className="pointer-events-none absolute inset-0 opacity-60 bg-[repeating-linear-gradient(45deg,rgba(255,255,255,0.08)_0_6px,transparent_6px_12px)]" />
<div className="absolute inset-0 flex items-center justify-between px-2 text-[10px] font-medium text-white/90">
<span className="flex items-center gap-1 select-none"><ShieldIcon /></span>
<span className="tabular-nums select-none drop-shadow-[0_1px_1px_rgba(0,0,0,0.45)]">{armor}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
{/* Mini-Animation für Low-HP */}
<style jsx global>{`
@keyframes hpPulse {
0% { filter: brightness(1); }
50% { filter: brightness(1.25); }
100% { filter: brightness(1); }
}
.animate-hpPulse { animation: hpPulse 1s ease-in-out infinite; }
`}</style>
</aside>
)
}
function clamp(n: number, a: number, b: number) {
return Math.max(a, Math.min(b, n))
}