This commit is contained in:
Linrador 2025-09-26 13:47:13 +02:00
parent 530425a82c
commit 942664cf55
27 changed files with 857 additions and 1328 deletions

View File

@ -154,7 +154,6 @@
roundHistory Json?
winnerTeam String?
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVote MapVote?

View File

@ -13,6 +13,7 @@ import Button from './Button'
import Modal from './Modal'
import { Match } from '../../../types/match'
import { useSSEStore } from '@/lib/useSSEStore'
import { useUserTimeZone } from '@/hooks/useUserTimeZone'
type Props = { matchType?: string }
@ -99,30 +100,12 @@ function dateKeyInTZ(date: Date | string, timeZone: string): string {
return `${p.year}-${pad(p.month)}-${pad(p.day)}`; // YYYY-MM-DD
}
function readCookieClient(name: string): string | undefined {
if (typeof document === 'undefined') return undefined
const m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
return m ? decodeURIComponent(m[1]) : undefined
}
function isValidIanaTzClient(tz?: string): tz is string {
if (!tz) return false
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(0); return true } catch { return false }
}
export default function CommunityMatchList({ matchType }: Props) {
const { data: session } = useSession()
const router = useRouter()
const pathname = usePathname()
const locale = useLocale()
const [userTZ, setUserTZ] = useState<string>(() => {
const fromCookie = readCookieClient('tz')
if (isValidIanaTzClient(fromCookie)) return fromCookie!
if (session?.user?.timeZone && isValidIanaTzClient(session.user.timeZone)) return session.user.timeZone
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'
}
)
console.log(userTZ);
const userTZ = useUserTimeZone([session?.user?.steamId])
const weekdayFmt = useMemo(() =>
new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-GB', {
weekday: 'long',
@ -162,24 +145,6 @@ export default function CommunityMatchList({ matchType }: Props) {
const [now, setNow] = useState(() => Date.now())
// Beim Mount & Tab-Fokus Cookie neu einlesen
useEffect(() => {
const apply = () => {
const fromCookie = readCookieClient('tz')
if (isValidIanaTzClient(fromCookie)) {
setUserTZ(prev => (prev === fromCookie ? prev : fromCookie!))
}
}
apply()
const onFocus = () => apply()
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
return () => {
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
}
}, [])
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)

View File

@ -26,7 +26,40 @@ import Link from 'next/link'
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
/* ─────────────────── Helpers ─────────────────── */
/** Teile eines Datums in einer Ziel-TZ extrahieren */
function getZonedParts(d: Date | string | number, timeZone: string, locale = 'de-DE') {
const date = typeof d === 'number' ? new Date(d) : (typeof d === 'string' ? new Date(d) : d)
const parts = new Intl.DateTimeFormat(locale, {
timeZone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false
}).formatToParts(date)
const pick = (t: Intl.DateTimeFormatPartTypes) => parts.find(p => p.type === t)?.value || ''
return {
y: pick('year'), m: pick('month'), d: pick('day'),
hh: pick('hour'), mm: pick('minute')
}
}
function formatDateTimeInTZ(d: Date | string | number, tz: string, locale = 'de-DE') {
const p = getZonedParts(d, tz, locale)
return `${p.d}.${p.m}.${p.y} ${p.hh}:${p.mm}`
}
/** user-TZ aus API lesen (fallbacks eingebaut) */
async function fetchUserTZ(): Promise<string> {
try {
const res = await fetch('/api/user/timezone', { credentials: 'include', cache: 'no-store' })
const j = res.ok ? await res.json() : null
const fromDb = j && typeof j.timeZone === 'string' ? j.timeZone : null
return fromDb
?? Intl.DateTimeFormat().resolvedOptions().timeZone
?? 'Europe/Berlin'
} catch {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'
}
}
const kdr = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number'
? d === 0
@ -45,20 +78,63 @@ const normalizeMapKey = (raw?: string) =>
type VoteAction = 'BAN' | 'PICK' | 'DECIDER'
type VoteStep = { order: number; action: VoteAction; map?: string | null }
const mapLabelFromKey = (key?: string) => {
const k = (key ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
return MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD')
// 1) Normalisieren: String → "de_mirage"
const norm = (m?: unknown): string => {
if (!m) return ''
if (typeof m === 'string') return m.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
if (typeof m === 'object') {
const o = m as Record<string, unknown>
// häufige Felder, die den Mapkey tragen:
const cand =
o.key ?? o.map ?? o.name ?? o.id ?? (o as any)?.value ?? (o as any)?.slug
return typeof cand === 'string'
? cand.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
: ''
}
return ''
}
// Maps aus dem MapVote (nur PICK/DECIDER, sortiert)
function extractSeriesMaps(match: Match): string[] {
const steps = (match.mapVote?.steps ?? []) as unknown as VoteStep[]
const picks = steps
.filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER'))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map(s => s.map ?? '')
const n = Math.max(1, match.bestOf ?? 1)
return picks.slice(0, n)
// 2) Label: erst aus MAP_OPTIONS, sonst hübsch machen
const humanize = (k: string) =>
k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
const mapLabelFromKey = (key?: string) => {
const k = norm(key)
const fromList = MAP_OPTIONS.find(o => o.key === k)?.label
return fromList ?? (k ? humanize(k) : 'TBD')
}
// 3) Final-Maps extrahieren deckt Strings/Objekte & verschiedene Felder ab
function extractSeriesMaps(match: Match, bestOf: number): string[] {
const n = Math.max(1, bestOf)
// a) klassische Steps (nur PICK/DECIDER)
const fromSteps: unknown[] =
(match.mapVote?.steps ?? [])
.filter((s: any) => s && (s.action === 'PICK' || s.action === 'DECIDER'))
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0))
.map((s: any) => s.map)
// b) häufige Ergebnis-Felder
const mv: any = match.mapVote ?? {}
const candidates: unknown[] = [
mv.result?.maps,
mv.result?.picks, // [{map: '...'}] / [{key:'...'}]
mv.result?.series, // ['de_mirage', ...] oder [{key:...}]
mv.final?.maps,
(match as any)?.series?.maps,
(match as any)?.maps,
].flat().filter(Boolean)
// c) flach ziehen + normalisieren + entduplizieren (stabile Reihenfolge)
const chain = [...fromSteps, ...candidates]
.map(norm)
.filter(Boolean)
const uniq: string[] = []
for (const k of chain) if (!uniq.includes(k)) uniq.push(k)
return uniq.slice(0, n)
}
// Wählt ein konsistentes Hintergrundbild pro Map
@ -74,63 +150,87 @@ const pickMapImage = (key?: string) => {
}
/* ─────────────────── UI-Snippets ─────────────────── */
function SeriesStrip({
function SeriesTabsStrip({
bestOf,
scoreA = 0,
scoreB = 0,
maps,
active,
onChange,
}: {
bestOf: number
scoreA?: number | null
scoreB?: number | null
maps: string[]
active: number
onChange: (idx: number) => void
}) {
const winsA = Math.max(0, scoreA ?? 0)
const winsB = Math.max(0, scoreB ?? 0)
const needed = Math.ceil(bestOf / 2)
const total = Math.max(bestOf, maps.length || 1)
const finished = winsA >= needed || winsB >= needed
const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1)
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const dir = e.key === 'ArrowRight' ? 1 : -1
const next = (active + dir + total) % total
onChange(next)
// Fokus auf den neuen Tab schieben
const el = document.getElementById(`tab-map-${next}`)
el?.focus()
}
return (
<div className="w-full">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm text-gray-400">Best of {bestOf} First to {needed}</div>
<div className="text-sm font-semibold">{winsA}:{winsB}</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{Array.from({length: total}).map((_, i) => {
<div
role="tablist"
aria-label="Maps der Serie"
className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3"
onKeyDown={onKey}
>
{Array.from({ length: total }).map((_, i) => {
const key = maps[i] ?? ''
const label = mapLabelFromKey(key)
const isDone = i < winsA + winsB
const isCurrent = i === currentIdx
const isActive = i === active
const isFuture = i > winsA + winsB
return (
<div
<button
key={`series-map-${i}`}
id={`tab-map-${i}`}
role="tab"
aria-selected={isActive}
aria-controls={`panel-map-${i}`}
type="button"
onClick={() => onChange(i)}
className={[
'flex items-center justify-between rounded-md border px-3 py-2',
isCurrent
? 'border-blue-500 ring-2 ring-blue-300/50 bg-blue-500/10'
: isDone
? 'border-emerald-500 bg-emerald-500/10'
: 'border-gray-600 bg-neutral-800/40',
'flex items-center justify-between rounded-md border px-3 py-2 text-left outline-none',
isActive
? 'shadow bg-blue-600/20 border-blue-500'
: isCurrent
? 'border-blue-500 bg-blue-500/10'
: isDone
? 'border-emerald-500 bg-emerald-500/10'
: 'border-gray-600 bg-neutral-800/40',
].join(' ')}
title={label || `Map ${i + 1}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-xs opacity-70">Map {i + 1}</span>
<span className="truncate font-medium">{label}</span>
</div>
<div className="shrink-0 flex items-center gap-1">
</span>
<span className="shrink-0 flex items-center gap-1">
{isDone && <span className="text-xs font-semibold text-emerald-400"></span>}
{isCurrent && !isDone && !isFuture && (
<span className="text-[11px] font-semibold text-blue-400">LIVE / als Nächstes</span>
)}
</div>
</div>
</span>
</button>
)
})}
</div>
@ -138,59 +238,23 @@ function SeriesStrip({
)
}
function SeriesTabs({
maps,
winsA = 0,
winsB = 0,
active,
onChange,
}: {
maps: string[]
winsA?: number | null
winsB?: number | null
active: number
onChange: (idx: number) => void
}) {
const done = (winsA ?? 0) + (winsB ?? 0)
return (
<div className="flex flex-wrap gap-2">
{maps.map((m, i) => {
const label = mapLabelFromKey(m)
const isActive = active === i
const isDone = i < done
return (
<button
key={i}
type="button"
onClick={() => onChange(i)}
className={[
'rounded-md px-3 py-1.5 text-sm transition',
isActive
? 'bg-blue-600 text-white ring-1 ring-blue-400 shadow'
: 'bg-neutral-800/50 text-neutral-200 ring-1 ring-neutral-600 hover:bg-neutral-700/60',
isDone && !isActive ? 'border-emerald-500/60' : '',
].join(' ')}
title={label || `Map ${i + 1}`}
>
<span className="mr-2 font-medium">Map {i + 1}</span>
<span className="opacity-80">{label || 'TBD'}</span>
{isDone && <span className="ml-2 text-emerald-400"></span>}
</button>
)
})}
</div>
)
}
/* ─────────────────── Komponente ─────────────────── */
export function MatchDetails({match, initialNow}: { match: Match; initialNow: number }) {
const {data: session} = useSession()
const {lastEvent} = useSSEStore()
const router = useRouter()
const isAdmin = !!session?.user?.isAdmin
const [userTZ, setUserTZ] = useState<string>('Europe/Berlin')
// ⬇️ bestOf nur im State halten: community → 3 (oder was du magst), sonst 1
const [bestOf, setBestOf] = useState<1 | 3 | 5>(() =>
match.matchType === 'community' ? 3 : 1
)
// Alle Maps der Serie (BO3/BO5)
const allMaps = useMemo(() => extractSeriesMaps(match), [match.mapVote?.steps, match.bestOf])
// Alle Maps der Serie (BO3/BO5) abhängig von bestOf-State
const allMaps = useMemo(
() => extractSeriesMaps(match, bestOf),
[match.mapVote?.steps, (match as any)?.mapVote?.result?.maps, match.map, bestOf]
)
const [activeMapIdx, setActiveMapIdx] = useState(0)
// Zeit / Modals
@ -213,6 +277,15 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
const currentMapKey = normalizeMapKey(match.map)
// beim mount user-tz aus DB laden
useEffect(() => {
let alive = true
fetchUserTZ().then(tz => { if (alive) setUserTZ(tz) })
return () => { alive = false }
}, [])
// Aktiv-Map aus Query (?m=2) initialisieren
useEffect(() => {
@ -242,14 +315,16 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
// Datum
const dateString = match.matchDate ?? match.demoDate
const readableDate = dateString ? format(new Date(dateString), 'PPpp', {locale: de}) : 'Unbekannt'
const readableDate = dateString
? formatDateTimeInTZ(dateString, userTZ, 'de-DE') // z.B. "31.08.2025 20:15"
: 'Unbekannt'
// „Serie komplett“ (für SeriesStrip)
// „Serie komplett“ (für SeriesStrip) nutzt bestOf-State
const seriesMaps = useMemo(() => {
const fromVote = extractSeriesMaps(match)
const n = Math.max(1, match.bestOf ?? 1)
const fromVote = extractSeriesMaps(match, bestOf)
const n = Math.max(1, bestOf)
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({length: n - fromVote.length}, () => '')]
}, [match.bestOf, match.mapVote?.steps?.length])
}, [bestOf, match.mapVote?.steps?.length])
// Ticker für Mapvote-Zeitfenster
useEffect(() => {
@ -316,6 +391,22 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) router.refresh()
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
useEffect(() => {
// Keine Tabs? oder noch Pick/Ban-Phase? → nichts tun
if (bestOf <= 1 || !allMaps?.length || isPickBanPhase) return
const idx = allMaps.findIndex(m => normalizeMapKey(m) === currentMapKey)
if (idx !== -1 && idx !== activeMapIdx) {
setActiveMapIdx(idx)
// URL (m=) mitschieben, damit Refresh/Share konsistent bleibt
const sp = new URLSearchParams(window.location.search)
sp.set('m', String(idx))
const url = `${window.location.pathname}?${sp.toString()}${window.location.hash}`
window.history.replaceState(null, '', url)
}
}, [currentMapKey, allMaps, bestOf, isPickBanPhase]) // ← Dependencies
// Tabellen-Layout
const ColGroup = () => (
<colgroup>
@ -462,36 +553,50 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(2px)',
transform: 'scale(1.02)',
transform: 'scale(1.12)',
transformOrigin: 'center',
opacity: 0.55
}}
/>
)
})()}
{/* Gradient + Shine */}
{/* Overlay: stärkere Vignette (Ränder kaschieren) */}
<div
aria-hidden
className="absolute inset-0"
className="absolute inset-0 pointer-events-none"
style={{
background:
'linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.35) 40%, rgba(0,0,0,0.65) 100%)',
backgroundImage: `
/* Radial: dunkelt Ecken/Kanten ab */
radial-gradient(120% 120% at 50% 38%,
rgba(0,0,0,0) 40%,
rgba(0,0,0,.48) 72%,
rgba(0,0,0,.86) 100%),
/* Vertikal: oben/unten Verlauf */
linear-gradient(180deg,
rgba(0,0,0,.62) 0%,
rgba(0,0,0,.35) 40%,
rgba(0,0,0,.78) 100%),
/* Horizontal: links/rechts Verlauf */
linear-gradient(90deg,
rgba(0,0,0,.72) 0%,
rgba(0,0,0,0) 12%,
rgba(0,0,0,0) 88%,
rgba(0,0,0,.72) 100%)
`,
/* zusätzlicher weicher Rand */
boxShadow: 'inset 0 0 140px rgba(0,0,0,.9), inset 0 0 40px rgba(0,0,0,.55)'
}}
/>
<div className="pointer-events-none absolute inset-0 before:absolute before:-left-1/4 before:top-0 before:h-full before:w-1/2 before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent before:animate-[shine_4.5s_linear_infinite]" />
{/* Content */}
<div className="relative p-4 sm:p-6">
<div className="relative py-3">
{/* Meta-Zeile */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-center gap-4">
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-white/75">
<span className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
{match.matchType || 'match'}
</span>
<span className="opacity-60"></span>
<span className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
Best of {match.bestOf ?? 1}
</span>
{dateString && (
<>
<span className="opacity-60"></span>
@ -500,11 +605,15 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
</time>
</>
)}
<span className="opacity-60"></span>
<span className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
Best of {bestOf}
</span>
</div>
</div>
{/* Teams + Score */}
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:gap-6">
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:gap-6 px-1">
{/* Team A */}
<div className="min-w-0">
<div className="flex items-center gap-3">
@ -560,26 +669,15 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
</div>
{/* Map-Tabs bei Serie */}
{allMaps.length > 1 && (
{bestOf > 1 && (
<div className="mt-4">
<SeriesTabs
maps={allMaps}
winsA={match.scoreA}
winsB={match.scoreB}
active={activeMapIdx}
onChange={setActive}
/>
</div>
)}
{/* Series-Strip (Visuelle Übersicht) */}
{(match.bestOf ?? 1) > 1 && (
<div className="mt-5">
<SeriesStrip
bestOf={match.bestOf ?? 3}
<SeriesTabsStrip
bestOf={bestOf}
scoreA={match.scoreA}
scoreB={match.scoreB}
maps={seriesMaps}
active={activeMapIdx}
onChange={setActive}
/>
</div>
)}
@ -587,10 +685,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
{/* Header Animations */}
<style jsx>{`
@keyframes shine {
0% { transform: translateX(-60%); }
100% { transform: translateX(120%); }
}
@keyframes pop {
0% { transform: scale(0.8); opacity: .3; }
100% { transform: scale(1); opacity: 1; }
@ -637,7 +731,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
<span className="text-gray-300">
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
Du kannst die Aufstellung noch bis <strong>{formatDateTimeInTZ(endDate, userTZ)}</strong> bearbeiten.
</span>
</div>
@ -681,7 +775,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
<span className="text-gray-300">
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
Du kannst die Aufstellung noch bis <strong>{formatDateTimeInTZ(endDate, userTZ)}</strong> bearbeiten.
</span>
</div>
@ -730,7 +824,11 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null}
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
defaultBestOf={Number(match.bestOf) === 5 ? 5 : 3}
defaultBestOf={
match.matchType === 'community'
? (bestOf === 5 ? 5 : 3)
: undefined
}
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
/>
)}

View File

@ -1,383 +0,0 @@
'use client'
export default function Profile() {
return (
<>
<div className="p-3 md:p-5 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700">
<figure>
<svg className="w-full" preserveAspectRatio="none" width="1113" height="161" viewBox="0 0 1113 161" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_697_201879)"><rect x="1" width="1112" height="348" fill="#B2E7FE"/><rect width="185.209" height="704.432" transform="matrix(0.50392 0.86375 -0.860909 0.508759 435.452 -177.87)" fill="#FF8F5D"/><rect width="184.653" height="378.667" transform="matrix(0.849839 -0.527043 0.522157 0.852849 -10.4556 -16.4521)" fill="#3ECEED"/><rect width="184.653" height="189.175" transform="matrix(0.849839 -0.527043 0.522157 0.852849 35.4456 58.5195)" fill="#4C48FF"/></g><defs><clipPath id="clip0_697_201879"><rect x="0.5" width="1112" height="161" rx="12" fill="white"/></clipPath></defs></svg>
</figure>
<div className="-mt-24">
<div className="relative flex size-30 mx-auto border-4 border-white rounded-full dark:border-neutral-800">
<img className="object-cover size-full rounded-full" src="https://images.unsplash.com/photo-1659482633369-9fe69af50bfb?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=facearea&facepad=3&w=320&h=320&q=80" alt="Hero Image" />
<div className="absolute bottom-0 -end-2">
<button type="button" className="group p-2 max-w-[125px] inline-flex justify-center items-center gap-x-2 text-start bg-red-600 border border-red-600 text-white text-xs font-medium rounded-full shadow-2xs align-middle focus:outline-hidden focus:bg-red-500" data-hs-overlay="#hs-pro-dsm">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>
<span className="group-hover:block hidden">Offline</span>
</button>
</div>
</div>
<div className="mt-3 text-center">
<h1 className="text-xl font-semibold text-gray-800 dark:text-neutral-200">
James Collins
</h1>
<p className="text-gray-500 dark:text-neutral-500">
its_james
</p>
</div>
</div>
<div className="mt-4 md:mt-7 -mb-0.5 flex flex-col md:flex-row md:justify-between md:items-center gap-3">
<div className="md:order-2 flex justify-center md:justify-end">
<label htmlFor="hs-pro-dupfub" className="relative py-1.5 px-3 inline-flex items-center justify-center sm:justify-start border border-gray-200 cursor-pointer font-medium text-sm rounded-lg peer-checked:bg-gray-100 hover:border-gray-300 focus:outline-none focus:border-gray-300 dark:border-neutral-700 dark:peer-checked:bg-neutral-800 dark:hover:border-neutral-600 dark:focus:border-neutral-600">
<input type="checkbox" id="hs-pro-dupfub" className="peer hidden" checked />
<span className="relative z-10 text-gray-800 dark:text-neutral-200 peer-checked:hidden">
Follow
</span>
<span className="relative z-10 hidden peer-checked:flex text-gray-800 dark:text-neutral-200">
Unfollow
</span>
</label>
</div>
<div className="relative flex justify-center md:justify-start" data-hs-scroll-nav='{
"autoCentering": true
}'>
<nav className="hs-scroll-nav-body flex flex-nowrap gap-x-1 overflow-x-auto [&::-webkit-scrollbar]:h-0 snap-x snap-mandatory pb-1.5">
<a className="snap-start relative inline-flex flex-nowrap items-center gap-x-2 px-2.5 py-1.5 hover:bg-gray-100 text-gray-500 hover:text-gray-800 text-sm whitespace-nowrap rounded-lg disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 after:absolute after:-bottom-0.5 after:inset-x-0 after:z-10 after:w-1/4 after:h-0.5 after:rounded-full after:mx-auto after:pointer-events-none dark:text-neutral-500 dark:hover:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 after:bg-gray-600 text-gray-800 font-medium dark:bg-neutral-800 dark:text-white dark:after:bg-neutral-200 active" href="../../pro/dashboard/user-profile-my-profile.html">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="15" r="3"/><circle cx="9" cy="7" r="4"/><path d="M10 15H6a4 4 0 0 0-4 4v2"/><path d="m21.7 16.4-.9-.3"/><path d="m15.2 13.9-.9-.3"/><path d="m16.6 18.7.3-.9"/><path d="m19.1 12.2.3-.9"/><path d="m19.6 18.7-.4-1"/><path d="m16.8 12.3-.4-1"/><path d="m14.3 16.6 1-.4"/><path d="m20.7 13.8 1-.4"/></svg>
My Profile
</a>
<a className="snap-start relative inline-flex flex-nowrap items-center gap-x-2 px-2.5 py-1.5 hover:bg-gray-100 text-gray-500 hover:text-gray-800 text-sm whitespace-nowrap rounded-lg disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 after:absolute after:-bottom-0.5 after:inset-x-0 after:z-10 after:w-1/4 after:h-0.5 after:rounded-full after:mx-auto after:pointer-events-none dark:text-neutral-500 dark:hover:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 " href="../../pro/dashboard/user-profile-teams.html">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Teams
</a>
<a className="snap-start relative inline-flex flex-nowrap items-center gap-x-2 px-2.5 py-1.5 hover:bg-gray-100 text-gray-500 hover:text-gray-800 text-sm whitespace-nowrap rounded-lg disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 after:absolute after:-bottom-0.5 after:inset-x-0 after:z-10 after:w-1/4 after:h-0.5 after:rounded-full after:mx-auto after:pointer-events-none dark:text-neutral-500 dark:hover:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 " href="../../pro/dashboard/user-profile-files.html">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15.5 2H8.6c-.4 0-.8.2-1.1.5-.3.3-.5.7-.5 1.1v12.8c0 .4.2.8.5 1.1.3.3.7.5 1.1.5h9.8c.4 0 .8-.2 1.1-.5.3-.3.5-.7.5-1.1V6.5L15.5 2z"/><path d="M3 7.6v12.8c0 .4.2.8.5 1.1.3.3.7.5 1.1.5h9.8"/><path d="M15 2v5h5"/></svg>
Files
</a>
<a className="snap-start relative inline-flex flex-nowrap items-center gap-x-2 px-2.5 py-1.5 hover:bg-gray-100 text-gray-500 hover:text-gray-800 text-sm whitespace-nowrap rounded-lg disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 after:absolute after:-bottom-0.5 after:inset-x-0 after:z-10 after:w-1/4 after:h-0.5 after:rounded-full after:mx-auto after:pointer-events-none dark:text-neutral-500 dark:hover:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 " href="../../pro/dashboard/user-profile-connections.html">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>
Connections
</a>
</nav>
</div>
</div>
</div>
<div id="hs-pro-dsm" className="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto [--close-when-click-inside:true] pointer-events-none" role="dialog" tabIndex={-1} aria-labelledby="hs-pro-dsm-label">
<div className="hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500 mt-0 opacity-0 ease-out transition-all sm:max-w-xl sm:w-full m-3 sm:mx-auto h-[calc(100%-56px)] min-h-[calc(100%-56px)] flex items-center">
<div className="w-full flex flex-col bg-white rounded-xl pointer-events-auto shadow-xl dark:bg-neutral-800">
<div className="py-2.5 px-4 flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
<h3 id="hs-pro-dsm-label" className="font-medium text-gray-800 dark:text-neutral-200">
Set status
</h3>
<button type="button" className="size-8 shrink-0 flex justify-center items-center gap-x-2 rounded-full border border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-400 dark:focus:bg-neutral-600" aria-label="Close" data-hs-overlay="#hs-pro-dsm">
<span className="sr-only">Close</span>
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div id="hs-modal-status-body" className="p-4 space-y-6 max-h-[75dvh] overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500">
<div className="flex items-center border border-gray-200 rounded-lg dark:border-neutral-700">
<div className="p-3 border-e border-gray-200 dark:border-neutral-700">
<svg className="shrink-0 size-4 text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>
</div>
<input type="text" className="py-1.5 sm:py-2 px-3 block w-full border-transparent rounded-e-md sm:text-sm placeholder:text-gray-400 focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-transparent dark:border-transparent dark:text-neutral-300 dark:placeholder:text-white/60 dark:focus:ring-neutral-600" placeholder="Whats your status?" />
</div>
<div>
<h4 className="text-sm text-gray-500 dark:text-neutral-500">
Suggestions
</h4>
<div className="mt-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="flex flex-col gap-y-2">
<label htmlFor="hs-pro-dupsms1" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms1" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🗓 <span className="ms-2">In a meeting</span>
</span>
</label>
<label htmlFor="hs-pro-dupsms3" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms3" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🚎 <span className="ms-2">Commuting</span>
</span>
</label>
<label htmlFor="hs-pro-dupsms5" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms5" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🎯 <span className="ms-2">Focusing</span>
</span>
</label>
</div>
<div className="flex flex-col gap-y-2">
<label htmlFor="hs-pro-dupsms2" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms2" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🤒 <span className="ms-2">Out sick</span>
</span>
</label>
<label htmlFor="hs-pro-dupsms7" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms7" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🌴 <span className="ms-2">On vacation</span>
</span>
</label>
<label htmlFor="hs-pro-dupsms6" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms6" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🏡 <span className="ms-2">Working remotely</span>
</span>
</label>
</div>
</div>
</div>
</div>
<ul className="flex flex-col bg-white border border-gray-200 rounded-xl -space-y-px dark:bg-neutral-800 dark:border-neutral-700">
<li className="p-3 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="flex gap-x-3">
<span className="mt-0.5 shrink-0 flex flex-col justify-center items-center size-6.5 bg-red-500 text-white rounded-full">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.164 14H15c-1.5-1-2-5.902-2-7 0-.264-.02-.523-.06-.776L5.164 14zm6.288-10.617A4.988 4.988 0 0 0 8.995 2.1a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 7c0 .898-.335 4.342-1.278 6.113l9.73-9.73zM10 15a2 2 0 1 1-4 0h4zm-9.375.625a.53.53 0 0 0 .75.75l14.75-14.75a.53.53 0 0 0-.75-.75L.625 15.625z"/>
</svg>
</span>
<div className="grow">
<div className="flex justify-between items-center mb-1">
<h4 className="text-sm font-semibold text-gray-800 dark:text-neutral-200">
Offline
</h4>
<label htmlFor="hs-pro-dsmofs" className="relative inline-block w-11 h-6 cursor-pointer">
<input type="checkbox" id="hs-pro-dsmofs" className="peer sr-only" />
<span className="absolute inset-0 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out peer-checked:bg-blue-600 dark:bg-neutral-700 dark:peer-checked:bg-blue-500 peer-disabled:opacity-50 peer-disabled:pointer-events-none"></span>
<span className="absolute top-1/2 start-0.5 -translate-y-1/2 size-5 bg-white rounded-full shadow-sm transition-transform duration-200 ease-in-out peer-checked:translate-x-full dark:bg-neutral-400 dark:peer-checked:bg-white"></span>
</label>
</div>
<p className="text-xs text-gray-500 dark:text-neutral-500">
Mute notifications and unassign new messages
</p>
</div>
</div>
</li>
<li className="p-3 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="flex gap-x-3">
<span className="mt-0.5 shrink-0 flex flex-col justify-center items-center size-6.5 bg-yellow-500 text-white rounded-full">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
</svg>
</span>
<div className="grow">
<div className="flex justify-between items-center mb-1">
<h4 className="text-sm font-semibold text-gray-800 dark:text-neutral-200">
Do not disturb
</h4>
<label htmlFor="hs-pro-dsmdnds" className="relative inline-block w-11 h-6 cursor-pointer">
<input type="checkbox" id="hs-pro-dsmdnds" className="peer sr-only" />
<span className="absolute inset-0 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out peer-checked:bg-blue-600 dark:bg-neutral-700 dark:peer-checked:bg-blue-500 peer-disabled:opacity-50 peer-disabled:pointer-events-none"></span>
<span className="absolute top-1/2 start-0.5 -translate-y-1/2 size-5 bg-white rounded-full shadow-sm transition-transform duration-200 ease-in-out peer-checked:translate-x-full dark:bg-neutral-400 dark:peer-checked:bg-white"></span>
</label>
</div>
<p className="text-xs text-gray-500 dark:text-neutral-500">
Mute notifications
</p>
</div>
</div>
</li>
<li className="p-3 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="flex gap-x-3">
<span className="mt-0.5 shrink-0 flex flex-col justify-center items-center size-6.5 bg-yellow-500 text-white rounded-full">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
</svg>
</span>
<div className="grow">
<div className="flex justify-between items-center mb-1">
<h4 className="text-sm font-semibold text-gray-800 dark:text-neutral-200">
Matches
</h4>
<label htmlFor="hs-pro-dsmschs" className="relative inline-block w-11 h-6 cursor-pointer">
<input type="checkbox" id="hs-pro-dsmschs" className="peer sr-only" />
<span className="absolute inset-0 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out peer-checked:bg-blue-600 dark:bg-neutral-700 dark:peer-checked:bg-blue-500 peer-disabled:opacity-50 peer-disabled:pointer-events-none"></span>
<span className="absolute top-1/2 start-0.5 -translate-y-1/2 size-5 bg-white rounded-full shadow-sm transition-transform duration-200 ease-in-out peer-checked:translate-x-full dark:bg-neutral-400 dark:peer-checked:bg-white"></span>
</label>
</div>
<div className="mt-3 sm:mt-1 flex flex-wrap items-center gap-2">
<div className="relative">
<select id="hs-pro-select-time1" data-hs-select='{
"placeholder": "Select option...",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 px-4 pe-7 flex text-nowrap w-full cursor-pointer bg-gray-100 rounded-lg text-start text-sm text-gray-800 focus:outline-hidden focus:bg-gray-200 before:absolute before:inset-0 before:z-1 dark:bg-neutral-700 dark:text-neutral-200 dark:focus:bg-neutral-700",
"dropdownClasses": "mt-2 z-50 w-full min-w-36 max-h-72 p-1 space-y-0.5 overflow-hidden overflow-y-auto bg-white rounded-xl shadow-xl [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500 dark:bg-neutral-900 dark:bg-neutral-900",
"optionClasses": "hs-selected:bg-gray-100 dark:hs-selected:bg-neutral-800 py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800",
"optionTemplate": "<div className=\"flex justify-between items-center w-full\"><span data-title></span><span className=\"hidden hs-selected:block\"><svg className=\"shrink-0 size-3.5 text-gray-800 dark:text-neutral-200\" xmlns=\"http:.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg></span></div>",
"viewport": "#hs-modal-status-body"
}' className="hidden">
<option value="">Choose</option>
<option>12:01 AM</option>
<option>1:00 AM</option>
<option>2:00 AM</option>
<option>3:00 AM</option>
<option>4:00 AM</option>
<option>5:00 AM</option>
<option>6:00 AM</option>
<option>7:00 AM</option>
<option>8:00 AM</option>
<option selected>9:00 AM</option>
<option >10:00 AM</option>
<option>11:00 AM</option>
<option>12:01 PM</option>
<option>1:00 PM</option>
<option>2:00 PM</option>
<option>3:00 PM</option>
<option>4:00 PM</option>
<option>5:00 PM</option>
<option>6:00 PM</option>
<option>7:00 PM</option>
<option>8:00 PM</option>
<option>9:00 PM</option>
<option>10:00 PM</option>
<option>11:00 PM</option>
</select>
<div className="absolute top-1/2 end-2.5 -translate-y-1/2">
<svg className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</div>
</div>
<span className="text-sm text-gray-500 dark:text-neutral-500">to:</span>
<div className="relative">
<select id="hs-pro-select-time2" data-hs-select='{
"placeholder": "Select option...",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 px-4 pe-7 flex text-nowrap w-full cursor-pointer bg-gray-100 rounded-lg text-start text-sm text-gray-800 focus:outline-hidden focus:bg-gray-200 before:absolute before:inset-0 before:z-1 dark:bg-neutral-700 dark:text-neutral-200 dark:focus:bg-neutral-700",
"dropdownClasses": "mt-2 z-50 w-full min-w-36 max-h-72 p-1 space-y-0.5 overflow-hidden overflow-y-auto bg-white rounded-xl shadow-xl [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500 dark:bg-neutral-900 dark:bg-neutral-900",
"optionClasses": "hs-selected:bg-gray-100 dark:hs-selected:bg-neutral-800 py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800",
"optionTemplate": "<div className=\"flex justify-between items-center w-full\"><span data-title></span><span className=\"hidden hs-selected:block\"><svg className=\"shrink-0 size-3.5 text-gray-800 dark:text-neutral-200\" xmlns=\"http:.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg></span></div>",
"viewport": "#hs-modal-status-body"
}' className="hidden">
<option value="">Choose</option>
<option>12:01 AM</option>
<option>1:00 AM</option>
<option>2:00 AM</option>
<option>3:00 AM</option>
<option>4:00 AM</option>
<option>5:00 AM</option>
<option>6:00 AM</option>
<option>7:00 AM</option>
<option>8:00 AM</option>
<option >9:00 AM</option>
<option selected>10:00 AM</option>
<option>11:00 AM</option>
<option>12:01 PM</option>
<option>1:00 PM</option>
<option>2:00 PM</option>
<option>3:00 PM</option>
<option>4:00 PM</option>
<option>5:00 PM</option>
<option>6:00 PM</option>
<option>7:00 PM</option>
<option>8:00 PM</option>
<option>9:00 PM</option>
<option>10:00 PM</option>
<option>11:00 PM</option>
</select>
<div className="absolute top-1/2 end-2.5 -translate-y-1/2">
<svg className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</div>
</div>
</div>
</div>
</div>
</li>
</ul>
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
<div className="flex items-center gap-x-3">
<label className="text-sm text-gray-500 dark:text-neutral-500">
Clear status
</label>
<div className="relative">
<select data-hs-select='{
"placeholder": "Status",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 ps-3 pe-7 inline-flex justify-center items-center text-start bg-white border border-gray-200 text-gray-800 text-sm rounded-lg shadow-2xs align-middle focus:outline-hidden focus:ring-2 focus:ring-blue-500 before:absolute before:inset-0 before:z-1 hover:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700",
"dropdownClasses": "mt-2 z-50 w-48 p-1 space-y-0.5 bg-white rounded-xl shadow-xl dark:bg-neutral-900",
"optionClasses": "hs-selected:bg-gray-100 dark:hs-selected:bg-neutral-800 py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800",
"optionTemplate": "<div className=\"flex justify-between items-center w-full\"><span data-title></span><span className=\"hidden hs-selected:block\"><svg className=\"shrink-0 size-3.5 text-gray-800 dark:text-neutral-200\" xmlns=\"http:.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg></span></div>",
"viewport": "#hs-modal-status-body"
}' className="hidden">
<option value="">Choose</option>
<option selected>Never</option>
<option>In 30 minutes</option>
<option>In 1 hour</option>
<option>Today</option>
<option>This week</option>
</select>
<div className="absolute top-1/2 end-2.5 -translate-y-1/2">
<svg className="shrink-0 size-3.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</div>
</div>
</div>
<div className="flex items-center gap-x-3">
<label className="text-sm text-gray-500 dark:text-neutral-500">
Visible to
</label>
<div className="relative inline-block">
<select id="hs-pro-select-visibility" data-hs-select='{
"placeholder": "Visibile to",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"><span className=\"me-2\" data-icon></span><span className=\"text-gray-800 dark:text-neutral-200\" data-title></span></button>",
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 ps-3 pe-7 inline-flex justify-center items-center text-start bg-white border border-gray-200 text-gray-500 text-sm rounded-lg shadow-2xs align-middle focus:outline-hidden focus:ring-2 focus:ring-blue-500 before:absolute before:inset-0 before:z-1 hover:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-500 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700",
"dropdownClasses": "mt-2 z-50 w-48 p-1 space-y-0.5 bg-white rounded-xl shadow-xl dark:bg-neutral-900",
"optionClasses": "hs-selected:bg-gray-100 dark:hs-selected:bg-neutral-800 py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800",
"optionTemplate": "<div><div className=\"flex items-center\"><div className=\"me-2\" data-icon></div><div className=\"font-semibold text-gray-800 dark:text-neutral-200\" data-title></div></div><div className=\"text-sm text-gray-500 dark:text-neutral-500\" data-description></div></div>",
"viewport": "#hs-modal-status-body"
}' className="hidden">
<option value="">Choose</option>
<option value="1" selected data-hs-select-option='{
"description": "Your status will be visible to everyone",
"icon": "<svg className=\"shrink-0 size-4\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"lucide lucide-globe-2\"><path d=\"M21.54 15H17a2 2 0 0 0-2 2v4.54\"/><path d=\"M7 3.34V5a3 3 0 0 0 3 3v0a2 2 0 0 1 2 2v0c0 1.1.9 2 2 2v0a2 2 0 0 0 2-2v0c0-1.1.9-2 2-2h3.17\"/><path d=\"M11 21.95V18a2 2 0 0 0-2-2v0a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05\"/><circle cx=\"12\" cy=\"12\" r=\"10\"/></svg>"
}'>Anyone</option>
<option value="2" data-hs-select-option='{
"icon": "<svg className=\"inline-block size-4\" width=\"32\" height=\"32\" viewBox=\"0 0 32 32\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M16.0355 1.75926C10.6408 1.75926 5.30597 1.49951 0.0111241 1C-0.288584 7.23393 5.50578 13.1282 12.7987 14.5668L13.9975 14.7266C14.3372 12.4289 15.9956 3.7773 16.595 1.73928C16.4152 1.75926 16.2353 1.75926 16.0355 1.75926Z\" fill=\"#A49DFF\"/><path d=\"M16.615 1.75926C16.615 1.75926 25.2266 7.9932 28.5234 16.3451C30.0419 11.3499 31.1608 6.15498 32 1C26.8051 1.49951 21.71 1.75926 16.615 1.75926Z\" fill=\"#28289A\"/><path d=\"M13.9975 14.7466L13.8177 15.9455C13.8177 15.9455 12.2592 28.4133 23.1886 31.9699C25.2266 26.8748 27.0049 21.6599 28.5234 16.3251C21.9698 15.8456 13.9975 14.7466 13.9975 14.7466Z\" fill=\"#5ADCEE\"/><path d=\"M16.6149 1.75927C16.0155 3.79729 14.3571 12.4089 14.0175 14.7466C14.0175 14.7466 21.9897 15.8456 28.5233 16.3251C25.1866 7.9932 16.6149 1.75927 16.6149 1.75927Z\" fill=\"#7878FF\"/></svg>"
}'>Guideline</option>
</select>
<div className="absolute top-1/2 end-2.5 -translate-y-1/2">
<svg className="shrink-0 size-3.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</div>
</div>
</div>
</div>
</div>
<div className="p-4 flex justify-between gap-x-2">
<div className="flex-1 flex justify-end items-center gap-2">
<button type="button" className="py-2 px-3 text-nowrap inline-flex justify-center items-center text-start whitespace-nowrap bg-white border border-gray-200 text-gray-800 text-sm font-medium rounded-lg shadow-2xs align-middle hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700" data-hs-overlay="#hs-pro-dsm">
Cancel
</button>
<button type="button" className="py-2 px-3 text-nowrap inline-flex justify-center items-center gap-x-2 text-start whitespace-nowrap bg-blue-600 border border-blue-600 text-white text-sm font-medium rounded-lg shadow-2xs align-middle hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:ring-1 focus:ring-blue-300 dark:focus:ring-blue-500" data-hs-overlay="#hs-pro-dsm">
Save status
</button>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -13,6 +13,7 @@ type SelectProps = {
className?: string;
showArrow?: boolean;
fullWidth?: boolean;
disabled?: boolean;
};
export default function Select({
@ -24,6 +25,7 @@ export default function Select({
className,
showArrow = true,
fullWidth = true,
disabled = false,
}: SelectProps) {
const [open, setOpen] = useState(false);
const [direction, setDirection] = useState<"up" | "down">("down");
@ -56,7 +58,7 @@ export default function Select({
};
useEffect(() => {
if (!open) return;
if (!open || disabled) return;
computePosition();
const onScroll = () => computePosition();
@ -73,7 +75,7 @@ export default function Select({
document.removeEventListener("keydown", onKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, dropDirection]);
}, [open, dropDirection, disabled]);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
@ -119,10 +121,14 @@ export default function Select({
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(prev => !prev)}
onClick={() => { if (!disabled) setOpen(prev => !prev); }}
aria-haspopup="listbox"
aria-expanded={open}
className={`relative py-2 px-3 ${showArrow ? 'pe-9' : ''} ${fullWidth ? 'w-full' : 'w-auto inline-flex'} w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-300 ${className || ''}`}
aria-disabled={disabled}
disabled={disabled}
className={`relative py-2 px-3 ${showArrow ? 'pe-9' : ''} ${fullWidth ? 'w-full' : 'w-auto inline-flex'} w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-300 ${className || ''} ${
disabled ? 'opacity-60 cursor-not-allowed pointer-events-auto' : ''
}`}
>
<span className="inline-flex items-center gap-2">
{selectedOption ? selectedOption.label : placeholder}

View File

@ -0,0 +1,150 @@
// /src/app/profile/[steamId]/Profile.tsx
import Link from 'next/link'
import Card from '../../Card'
type Props = { steamId: string }
type ApiStats = {
stats: Array<{
date: string
kills: number
deaths: number
assists?: number | null
totalDamage?: number | null
matchType?: string | null
}>
}
async function getStats(steamId: string): Promise<ApiStats | null> {
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store' })
if (!res.ok) return null
return res.json()
}
export default async function Profile({ steamId }: Props) {
const data = await getStats(steamId)
const matches = data?.stats ?? []
const games = matches.length
const kills = matches.reduce((s, m) => s + (m.kills ?? 0), 0)
const deaths = matches.reduce((s, m) => s + (m.deaths ?? 0), 0)
const assists = matches.reduce((s, m) => s + (m.assists ?? 0), 0)
const dmg = matches.reduce((s, m) => s + (m.totalDamage ?? 0), 0)
const kd = deaths === 0 ? '∞' : (kills / Math.max(1, deaths)).toFixed(2)
return (
<div className="space-y-6">
{/* Quick KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-neutral-400">Matches</div>
<div className="mt-1 text-2xl font-bold">{games}</div>
</div>
<div className="rounded-lg bg-blue-500/15 p-2 ring-1 ring-blue-400/20">🎮</div>
</div>
</Card>
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-neutral-400">Kills</div>
<div className="mt-1 text-2xl font-bold">{kills}</div>
</div>
<div className="rounded-lg bg-emerald-500/15 p-2 ring-1 ring-emerald-400/20">🎯</div>
</div>
</Card>
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-neutral-400">K/D</div>
<div className="mt-1 text-2xl font-bold">{kd}</div>
</div>
<div className="rounded-lg bg-fuchsia-500/15 p-2 ring-1 ring-fuchsia-400/20"></div>
</div>
</Card>
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-neutral-400">Damage (sum)</div>
<div className="mt-1 text-2xl font-bold">{Math.round(dmg)}</div>
</div>
<div className="rounded-lg bg-amber-500/15 p-2 ring-1 ring-amber-400/20">🔥</div>
</div>
</Card>
</div>
{/* Callouts to subpages */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Statistiken</h3>
<p className="mt-1 text-sm text-neutral-400">
Charts, Verläufe und Map-Auswertungen.
</p>
</div>
<Link
href={`/profile/${steamId}/stats`}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Öffnen
</Link>
</div>
</Card>
<Card>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Matches</h3>
<p className="mt-1 text-sm text-neutral-400">
Alle Spiele in einer übersichtlichen Liste.
</p>
</div>
<Link
href={`/profile/${steamId}/matches`}
className="rounded-lg bg-neutral-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-600"
>
Öffnen
</Link>
</div>
</Card>
</div>
{/* Letzte 5 Matches */}
<Card>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-base font-semibold">Letzte Matches</h3>
<Link href={`/profile/${steamId}/matches`} className="text-sm text-blue-400 hover:underline">
Alle ansehen
</Link>
</div>
{matches.slice(0, 5).length === 0 ? (
<p className="text-sm text-neutral-400">Noch keine Daten.</p>
) : (
<ul className="divide-y divide-neutral-800/70">
{matches.slice(0, 5).map((m, i) => (
<li key={i} className="py-2 flex items-center justify-between">
<div className="min-w-0">
<div className="text-xs text-neutral-500">{new Date(m.date).toLocaleString()}</div>
<div className="text-sm">
{m.matchType ? <span className="mr-2 text-neutral-400">{m.matchType} </span> : null}
<span>K {m.kills} / D {m.deaths} {Number.isFinite(m.assists) ? `/ A ${m.assists}` : ''}</span>
</div>
</div>
<div className="shrink-0 text-right">
<div className="rounded-md bg-neutral-800 px-2 py-0.5 text-xs">
K/D&nbsp;
{m.deaths === 0 ? '∞' : ((m.kills ?? 0) / Math.max(1, m.deaths ?? 0)).toFixed(2)}
</div>
</div>
</li>
))}
</ul>
)}
</Card>
</div>
)
}

View File

@ -0,0 +1,112 @@
// /src/app/profile/[steamId]/matches/MatchesList.tsx
import Link from 'next/link'
import Card from '../../../Card'
import { MAP_OPTIONS } from '@/lib/mapOptions'
type Props = { steamId: string }
type MatchRow = {
matchId?: string | number | null
id?: string | number | null
date: string
map: string
kills: number
deaths: number
assists?: number | null
totalDamage?: number | null
roundCount?: number | null
scoreA?: number | null
scoreB?: number | null
team?: 'A' | 'B' | null
result?: 'win' | 'loss' | 'draw' | null
matchType?: string | null
}
function labelForMap(key: string) {
const k = key.toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
return MAP_OPTIONS.find(o => o.key === k)?.label ?? k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function kdr(k: number, d: number) {
if (!Number.isFinite(k) || !Number.isFinite(d)) return '-'
return d === 0 ? '∞' : (k / d).toFixed(2)
}
async function getData(steamId: string) {
// greift auf die gleiche API wie die Stats-Seite zu
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store' })
if (!res.ok) return { matches: [] as MatchRow[] }
const json = await res.json()
return { matches: (json?.stats ?? []) as MatchRow[] }
}
export default async function MatchesList({ steamId }: Props) {
const { matches } = await getData(steamId)
if (!matches?.length) {
return (
<Card>
<p className="text-sm text-neutral-400">Keine Matches gefunden.</p>
</Card>
)
}
return (
<div className="space-y-3">
{matches.map((m, idx) => {
const linkId = String(m.matchId ?? m.id ?? '')
const href = linkId ? `/match/${linkId}` : undefined
const ADR =
Number.isFinite(m.totalDamage) && Number.isFinite(m.roundCount) && (m.roundCount ?? 0) > 0
? ((m.totalDamage as number) / (m.roundCount as number)).toFixed(1)
: '-'
const resultPill =
m.result === 'win' ? 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30' :
m.result === 'loss' ? 'bg-red-500/15 text-red-300 ring-1 ring-red-500/30' :
'bg-neutral-500/15 text-neutral-300 ring-1 ring-neutral-500/30'
const content = (
<div className="flex items-center justify-between gap-4 rounded-lg border border-neutral-700/60 bg-neutral-900/40 p-3 hover:bg-neutral-900/70 transition">
<div className="min-w-0">
<div className="text-xs text-neutral-400">{new Date(m.date).toLocaleString()}</div>
<div className="truncate text-sm font-medium">
{labelForMap(m.map)}
{m.matchType ? <span className="ml-2 text-xs text-neutral-400"> {m.matchType}</span> : null}
</div>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-neutral-300">
<span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">K: {m.kills}</span>
<span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">D: {m.deaths}</span>
{Number.isFinite(m.assists) && <span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">A: {m.assists}</span>}
<span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">K/D: {kdr(m.kills, m.deaths)}</span>
<span className="rounded-md bg-neutral-800/80 px-1.5 py-0.5">ADR: {ADR}</span>
</div>
</div>
<div className="shrink-0 text-right">
{(Number.isFinite(m.scoreA) || Number.isFinite(m.scoreB)) && (
<div className="text-lg font-semibold">
{m.scoreA ?? 0} : {m.scoreB ?? 0}
</div>
)}
<div className={`mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[11px] ${resultPill}`}>
{m.result === 'win' ? 'Win' : m.result === 'loss' ? 'Loss' : 'Match'}
</div>
</div>
</div>
)
return (
<div key={`${linkId || 'row'}-${idx}`}>
{href ? (
<Link href={href} className="block">{content}</Link>
) : (
content
)}
</div>
)
})}
</div>
)
}

View File

@ -1,340 +0,0 @@
// /src/app/components/profile/[steamId]/matches/UserMatchesList.tsx
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import Table from '../../../Table'
import PremierRankBadge from '../../../PremierRankBadge'
import CompRankBadge from '../../../CompRankBadge'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import Button from '../../../Button'
interface Match {
id: string
map: string
date: string
score: string | null
winnerTeam?: 'CT' | 'T' | 'Draw' | null
team?: 'CT' | 'T' | null
matchType: 'premier' | 'competitive' | string
rating: string
kills: number
deaths: number
kdr: string
rankNew: number
rankOld: number
rankChange: number | null
oneK: number
twoK: number
threeK: number
fourK: number
fiveK: number
aim: number | string
}
const parseScore = (raw?: string | null): [number, number] => {
if (!raw) return [0, 0]
const [a, b] = raw.split(':').map(n => Number(n.trim()))
return [Number.isNaN(a) ? 0 : a, Number.isNaN(b) ? 0 : b]
}
// Scroll-Parent des Sentinels finden (falls eigenes overflow-Element)
function getScrollParent(el: HTMLElement | null): HTMLElement | Window {
if (!el) return window
let p: HTMLElement | null = el.parentElement
const re = /(auto|scroll)/i
while (p && p !== document.body) {
const cs = getComputedStyle(p)
if (re.test(cs.overflowY) || re.test(cs.overflow)) return p
p = p.parentElement
}
return window
}
export default function UserMatchesList({ steamId }: { steamId: string }) {
const [matches, setMatches] = useState<Match[]>([])
const [cursor, setCursor] = useState<string | null>(null)
const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState<boolean>(false)
// Refs halten stets den aktuellen Wert für Callbacks
const cursorRef = useRef<string | null>(null)
const loadingRef = useRef(false)
const hasMoreRef = useRef(true)
const router = useRouter()
const sentinelRef = useRef<HTMLDivElement | null>(null)
const setCursorBoth = (c: string | null) => { setCursor(c); cursorRef.current = c }
const setLoadingBoth = (v: boolean) => { setLoading(v); loadingRef.current = v }
const setHasMoreBoth = (v: boolean) => { setHasMore(v); hasMoreRef.current = v }
// Seite laden initial: ersetzen, sonst: anhängen
const fetchPage = useCallback(async (opts?: { initial?: boolean }) => {
if (!steamId) return
if (loadingRef.current || !hasMoreRef.current) return
setLoadingBoth(true)
try {
const params = new URLSearchParams({ types: 'premier,competitive', limit: '10' })
const useCursor = opts?.initial ? null : cursorRef.current
if (useCursor) params.set('cursor', useCursor)
const res = await fetch(`/api/user/${steamId}/matches?` + params.toString(), { cache: 'no-store' })
if (!res.ok) throw new Error('Failed to fetch')
const data = await res.json()
const pageItems: Match[] = Array.isArray(data) ? data : (data.items ?? [])
const pageNextCursor: string | null = Array.isArray(data) ? null : (data.nextCursor ?? null)
const pageHasMore: boolean = Array.isArray(data) ? false : !!data.hasMore
if (opts?.initial) {
setMatches(pageItems) // → genau 10 nach (Re-)Öffnen des Tabs
} else {
setMatches(prev => {
const seen = new Set(prev.map(m => m.id))
const merged = [...prev]
for (const it of pageItems) if (!seen.has(it.id)) merged.push(it)
return merged
})
}
setCursorBoth(pageNextCursor)
setHasMoreBoth(pageHasMore)
} catch (e) {
console.error(e)
} finally {
setLoadingBoth(false)
}
}, [steamId])
const onLoadMoreClick = useCallback(() => {
// Guards sind bereits in fetchPage drin (loadingRef / hasMoreRef)
fetchPage()
}, [fetchPage])
// Sichtbarkeits-Check des Sentinels im aktuellen Layout/Container
const sentinelInView = useCallback(() => {
const el = sentinelRef.current
if (!el) return false
const rect = el.getBoundingClientRect()
const vh = window.innerHeight || document.documentElement.clientHeight
const vw = window.innerWidth || document.documentElement.clientWidth
return rect.top < vh && rect.bottom >= 0 && rect.left < vw && rect.right >= 0
}, [])
const checkAndLoadMore = useCallback(() => {
if (!hasMoreRef.current || loadingRef.current) return
if (sentinelInView()) fetchPage()
}, [fetchPage, sentinelInView])
// Reset + erste Seite laden (ersetzen)
useEffect(() => {
setMatches([])
setCursorBoth(null)
setHasMoreBoth(true)
setLoadingBoth(false)
if (steamId) fetchPage({ initial: true })
}, [steamId, fetchPage])
// IntersectionObserver mit KORREKTEM root (Scroll-Parent) + Fallback-Events
useEffect(() => {
const target = sentinelRef.current
if (!target) return
const rootEl = getScrollParent(target)
const rootForIO = rootEl instanceof Window ? undefined : rootEl
const io = new IntersectionObserver(
entries => {
const first = entries[0]
if (first.isIntersecting && hasMoreRef.current && !loadingRef.current) {
fetchPage()
}
},
{ root: rootForIO as Element | undefined, rootMargin: '400px 0px 400px 0px' }
)
io.observe(target)
// Scroll/Resize-Fallback auf dem tatsächlichen Scroll-Container
const scrollTarget: any = rootEl instanceof Window ? window : rootEl
const onScroll = () => checkAndLoadMore()
const onResize = () => checkAndLoadMore()
scrollTarget.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onResize)
// Kick direkt nach Mount (z. B. wenn Sentinel bereits sichtbar)
const id = window.setTimeout(checkAndLoadMore, 60)
// Reaktion auf Tab-Show/Hide per CSS (class/hidden/style)
const mo = new MutationObserver(() => {
// Frame abwarten, dann prüfen
requestAnimationFrame(checkAndLoadMore)
})
// bis zur nächsten Panel-Grenze hochhorchen
let node: Element | null = target.parentElement
while (node && node !== document.body) {
mo.observe(node, { attributes: true, attributeFilter: ['class', 'style', 'hidden'] })
node = node.parentElement
}
return () => {
window.clearTimeout(id)
io.disconnect()
mo.disconnect()
scrollTarget.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onResize)
}
}, [checkAndLoadMore, fetchPage])
return (
<div
className="
matches-card
flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70
"
>
{/* Tabelle: Rundung/Clipping kommt rein per CSS (siehe globals.css) */}
<Table>
<Table.Head>
<Table.Row>
{['Map','Date','Score','Rank','Aim','Kills','Deaths','K/D'].map(h => (
<Table.Cell key={h} as="th">{h}</Table.Cell>
))}
</Table.Row>
</Table.Head>
<Table.Body>
{matches.map(m => {
const mapInfo =
MAP_OPTIONS.find(opt => opt.key === m.map) ??
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')
const [scoreCT, scoreT] = parseScore(m.score)
const ownCTSide = m.team !== 'T'
const left = ownCTSide ? scoreCT : scoreT
const right = ownCTSide ? scoreT : scoreCT
const scoreColor =
left > right ? 'text-green-600 dark:text-green-400'
: left < right ? 'text-red-600 dark:text-red-400'
: 'text-yellow-600 dark:text-yellow-400'
return (
<Table.Row
key={m.id}
onClick={() => router.push(`/match-details/${m.id}`)}
className="cursor-pointer"
>
<Table.Cell hoverable>
<div className="flex items-center gap-2">
{(() => {
const raw = m.map || ''
const normKey = raw.replace(/^de_/, '') // "de_ancient" -> "ancient"
// 1) Versuch: exact normKey
// 2) Versuch: raw (mit "de_")
// 3) Versuch: wieder mit "de_" (falls m.map ohne Prefix kam)
const opt =
MAP_OPTIONS.find(o => o.key === normKey) ||
MAP_OPTIONS.find(o => o.key === raw) ||
MAP_OPTIONS.find(o => o.key === `de_${normKey}`) ||
null
const label =
opt?.label ||
normKey || // sinnvoller Text, falls alles fehlt
'Map'
// Icon-Quelle: erst aus MAP_OPTIONS, sonst generischer Pfad auf Basis des normKey
const iconSrc = opt?.icon || `/assets/img/mapicons/map_icon_${normKey}.svg`
// Debug (kannst du wieder entfernen)
// console.log({ raw, normKey, optKey: opt?.key, label, iconSrc })
return (
<>
<img
src={iconSrc}
alt={label}
width={32}
height={32}
className="shrink-0"
onError={(e) => {
(e.currentTarget as HTMLImageElement).src =
'/assets/img/mapicons/map_icon_lobby_mapveto.svg'
}}
/>
{label}
</>
)
})()}
</div>
</Table.Cell>
<Table.Cell hoverable>{new Date(m.date).toLocaleString()}</Table.Cell>
<Table.Cell hoverable>
<span className={`font-medium ${scoreColor}`}>
{left} : {right}
</span>
</Table.Cell>
<Table.Cell hoverable className="whitespace-nowrap">
<div className="flex items-center gap-[6px]">
{m.matchType === 'premier'
? <PremierRankBadge rank={m.rankNew} />
: <CompRankBadge rank={m.rankNew} /> }
{m.rankChange !== null && m.matchType === 'premier' && (
<span className={m.rankChange > 0 ? 'text-green-500' : m.rankChange < 0 ? 'text-red-500' : ''}>
{m.rankChange > 0 ? '+' : ''}{m.rankChange}
</span>
)}
</div>
</Table.Cell>
<Table.Cell hoverable>
{Number.isFinite(Number(m.aim))
? `${Number(m.aim).toFixed(0)} %`
: '-' }
</Table.Cell>
<Table.Cell hoverable>{m.kills}</Table.Cell>
<Table.Cell hoverable>{m.deaths}</Table.Cell>
<Table.Cell hoverable>{m.kdr}</Table.Cell>
</Table.Row>
)
})}
</Table.Body>
</Table>
{/* Divider immer vorhanden Sichtbarkeit per :has(.load-more) */}
<div className="table-divider hidden border-t border-gray-200 dark:border-neutral-700" />
{hasMore && (
<div ref={sentinelRef} className="load-more mt-2 flex justify-center">
<Button
size="sm"
color="blue"
onClick={onLoadMoreClick}
disabled={loading}
loading={loading}
className="
mb-3 px-4 py-2 rounded-lg text-sm font-medium
bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus:ring-2 focus:ring-blue-500/50
dark:bg-blue-600 dark:hover:bg-blue-700
"
aria-busy={loading ? 'true' : 'false'}
>
{loading ? 'Lädt …' : 'Mehr laden'}
</Button>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,193 @@
// /src/app/profile/[steamId]/stats/StatsView.tsx
'use client'
import { useSession } from 'next-auth/react'
import Chart from '../../../Chart'
import Card from '../../../Card'
import { MatchStats } from '@/types/match'
type Props = {
stats: { matches: MatchStats[] }
}
const kdr = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number'
? d === 0 ? '∞' : (k / d).toFixed(2)
: '-'
export default function StatsView({ stats }: Props) {
const { data: session } = useSession()
const steamId = session?.user?.steamId ?? ''
const { matches } = stats
const totalKills = matches.reduce((sum, m) => sum + (m.kills ?? 0), 0)
const totalDeaths = matches.reduce((sum, m) => sum + (m.deaths ?? 0), 0)
const totalAssists = matches.reduce((sum, m) => sum + (m.assists ?? 0), 0)
const avgKDR = kdr(totalKills, totalDeaths)
const premierMatches = matches.filter((m) => m.rankNew !== null && m.matchType === 'premier')
const compMatches = matches.filter((m) => m.rankNew !== null && m.matchType !== 'premier')
const killsPerMap = matches.reduce<Record<string, number>>((acc, m) => {
const key = (m.map || '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
acc[key] = (acc[key] || 0) + (m.kills ?? 0)
return acc
}, {})
const matchesPerMap = matches.reduce<Record<string, number>>((acc, m) => {
const key = (m.map || '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
acc[key] = (acc[key] || 0) + 1
return acc
}, {})
return (
<div className="grid grid-cols-4 gap-4">
{/* linke Spalte */}
<div className="space-y-4">
<Card>
<div className="relative mx-auto">
<Chart
type="doughnut"
title="Ø Gesamt-K/D"
labels={['Kills', 'Deaths']}
datasets={[{
label: 'Anzahl',
data: [totalKills, totalDeaths],
backgroundColor: ['rgba(54, 162, 235, 0.6)','rgba(255, 99, 132, 0.6)'],
}]}
hideLabels
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold">{avgKDR}</p>
</div>
</div>
</div>
</Card>
<Card>
<Chart
type="doughnut"
title="Kills vs Assists vs Deaths"
labels={['Kills', 'Assists', 'Deaths']}
datasets={[{
label: 'Anteile',
data: [totalKills, totalAssists, totalDeaths],
backgroundColor: [
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(255, 99, 132, 0.6)',
],
}]}
/>
</Card>
{/* Highlights Beispiel (auskommentiert) */}
{/* {steamId && (
<Card>
<h3 className="text-lg font-semibold mb-2">Highlights</h3>
<UserClips steamId={steamId} />
</Card>
)} */}
</div>
{/* rechte breite Spalte */}
<div className="col-span-3 space-y-6">
<Chart
type="bar"
title="Kills pro Match"
labels={matches.map((m) => m.date)}
datasets={[{ label: 'Kills', data: matches.map((m) => m.kills), backgroundColor: 'rgba(54, 162, 235, 0.6)' }]}
/>
<Chart
type="line"
title="K/D Ratio pro Match"
labels={matches.map((m) => m.date)}
datasets={[{
label: 'K/D',
data: matches.map((m) => (m.deaths === 0 ? m.kills : (m.kills ?? 0) / Math.max(1, m.deaths ?? 0))),
borderColor: 'rgba(255, 99, 132, 0.6)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderWidth: 2,
}]}
/>
<Chart
type="line"
title="Headshot % pro Match"
labels={matches.map((m) => m.date)}
datasets={[{
label: 'HS%',
data: matches.map((m) => m.headshotPct ?? 0),
borderColor: 'rgba(153, 102, 255, 0.6)',
backgroundColor: 'rgba(153, 102, 255, 0.2)',
borderWidth: 2,
}]}
/>
<Chart
type="bar"
title="Gesamtdamage pro Match"
labels={matches.map((m) => m.date)}
datasets={[{
label: 'Damage',
data: matches.map((m) => m.totalDamage ?? 0),
backgroundColor: 'rgba(255, 206, 86, 0.6)',
}]}
/>
{premierMatches.length > 0 && (
<Chart
type="line"
title="Premier Rank-Verlauf"
labels={premierMatches.map((m) => m.date)}
datasets={[{
label: 'Premier Rank',
data: premierMatches.map((m) => m.rankNew ?? 0),
borderColor: 'rgba(75, 192, 192, 0.6)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderWidth: 2,
}]}
/>
)}
{compMatches.length > 0 && (
<Chart
type="line"
title="Competitive Rank-Verlauf"
labels={compMatches.map((m) => m.date)}
datasets={[{
label: 'Comp Rank',
data: compMatches.map((m) => m.rankNew ?? 0),
borderColor: 'rgba(255, 159, 64, 0.6)',
backgroundColor: 'rgba(255, 159, 64, 0.2)',
borderWidth: 2,
}]}
/>
)}
<Chart
type="bar"
title="Kills pro Map"
labels={Object.keys(killsPerMap)}
datasets={[{ label: 'Kills', data: Object.values(killsPerMap), backgroundColor: 'rgba(255, 159, 64, 0.6)' }]}
/>
<Chart
type="radar"
title="Matches pro Map"
labels={Object.keys(matchesPerMap)}
datasets={[{
label: 'Matches',
data: Object.values(matchesPerMap),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
}]}
/>
</div>
</div>
)
}

View File

@ -1,230 +0,0 @@
'use client'
import { useSession } from 'next-auth/react'
import Chart from '../../../Chart'
import { MatchStats } from '@/types/match'
import Card from '../../../Card'
type MatchStatsProps = {
stats: { matches: MatchStats[] }
}
export default function UserProfile({ stats }: MatchStatsProps) {
const { data: session } = useSession()
const steamId = session?.user?.steamId ?? ''
const { matches } = stats
const totalKills = matches.reduce((sum, m) => sum + m.kills, 0)
const totalDeaths = matches.reduce((sum, m) => sum + m.deaths, 0)
const totalAssists = matches.reduce((sum, m) => sum + m.assists, 0)
const avgKDR = totalDeaths > 0 ? (totalKills / totalDeaths).toFixed(2) : '∞'
const premierMatches = matches.filter((m) => m.rankNew !== null && m.matchType === 'premier')
const compMatches = matches.filter((m) => m.rankNew !== null && m.matchType !== 'premier')
const killsPerMap = matches.reduce((acc, match) => {
acc[match.map] = (acc[match.map] || 0) + match.kills
return acc
}, {} as Record<string, number>)
const matchesPerMap = matches.reduce((acc, match) => {
acc[match.map] = (acc[match.map] || 0) + 1
return acc
}, {} as Record<string, number>)
return (
<div className="grid grid-cols-4 gap-4">
{/* K/D-Anteil + Zahl */}
<div className="space-y-4">
<Card>
<div className="relative mx-auto">
<Chart
type="doughnut"
title="Ø Gesamt-K/D"
labels={['Kills', 'Deaths']}
datasets={[
{
label: 'Anzahl',
data: [totalKills, totalDeaths],
backgroundColor: [
'rgba(54, 162, 235, 0.6)',
'rgba(255, 99, 132, 0.6)',
],
},
]}
hideLabels
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold text-gray-800 dark:text-white">{avgKDR}</p>
</div>
</div>
</div>
</Card>
{/* Kills vs Assists vs Deaths */}
<Card>
<Chart
type="doughnut"
title="Kills vs Assists vs Deaths"
labels={['Kills', 'Assists', 'Deaths']}
datasets={[
{
label: 'Anteile',
data: [totalKills, totalAssists, totalDeaths],
backgroundColor: [
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(255, 99, 132, 0.6)',
],
},
]}
/>
</Card>
{/* ► Allstar-Clips des aktuellen Users -------------------------- */}
{/*
{steamId && (
<Card>
<h3 className="text-lg font-semibold mb-2">Highlights</h3>
<UserClips steamId={steamId} />
</Card>
)}
*/}
</div>
{/* Breite Diagramme */}
<div className="col-span-3 space-y-6">
{/* Kills pro Match */}
<Chart
type="bar"
title="Kills pro Match"
labels={matches.map((m) => m.date)}
datasets={[
{
label: 'Kills',
data: matches.map((m) => m.kills),
backgroundColor: 'rgba(54, 162, 235, 0.6)',
},
]}
/>
{/* K/D pro Match */}
<Chart
type="line"
title="K/D Ratio pro Match"
labels={matches.map((m) => m.date)}
datasets={[
{
label: 'K/D',
data: matches.map((m) =>
m.deaths === 0 ? m.kills : m.kills / m.deaths
),
borderColor: 'rgba(255, 99, 132, 0.6)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderWidth: 2,
},
]}
/>
{/* Headshot % */}
<Chart
type="line"
title="Headshot % pro Match"
labels={matches.map((m) => m.date)}
datasets={[
{
label: 'HS%',
data: matches.map((m) => m.headshotPct),
borderColor: 'rgba(153, 102, 255, 0.6)',
backgroundColor: 'rgba(153, 102, 255, 0.2)',
borderWidth: 2,
},
]}
/>
{/* Damage */}
<Chart
type="bar"
title="Gesamtdamage pro Match"
labels={matches.map((m) => m.date)}
datasets={[
{
label: 'Damage',
data: matches.map((m) => m.totalDamage),
backgroundColor: 'rgba(255, 206, 86, 0.6)',
},
]}
/>
{/* Premier Rank-Verlauf */}
{premierMatches.length > 0 && (
<Chart
type="line"
title="Premier Rank-Verlauf"
labels={premierMatches.map((m) => m.date)}
datasets={[
{
label: 'Premier Rank',
data: premierMatches.map((m) => m.rankNew ?? 0),
borderColor: 'rgba(75, 192, 192, 0.6)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderWidth: 2,
},
]}
/>
)}
{/* Competitive Rank-Verlauf */}
{compMatches.length > 0 && (
<Chart
type="line"
title="Competitive Rank-Verlauf"
labels={compMatches.map((m) => m.date)}
datasets={[
{
label: 'Comp Rank',
data: compMatches.map((m) => m.rankNew ?? 0),
borderColor: 'rgba(255, 159, 64, 0.6)',
backgroundColor: 'rgba(255, 159, 64, 0.2)',
borderWidth: 2,
},
]}
/>
)}
{/* Kills pro Map */}
<Chart
type="bar"
title="Kills pro Map"
labels={Object.keys(killsPerMap)}
datasets={[
{
label: 'Kills',
data: Object.values(killsPerMap),
backgroundColor: 'rgba(255, 159, 64, 0.6)',
},
]}
/>
{/* Matches pro Map (Radar) */}
<Chart
type="radar"
title="Matches pro Map"
labels={Object.keys(matchesPerMap)}
datasets={[
{
label: 'Matches',
data: Object.values(matchesPerMap),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
},
]}
/>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
// /src/app/components/UserHeader.tsx
import { Tabs } from '../components/Tabs'
import PremierRankBadge from './PremierRankBadge'
import { Tabs } from '../../components/Tabs'
import PremierRankBadge from '../../components/PremierRankBadge'
type UserHeaderProps = {
steamId: string

View File

@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import Card from '../../components/Card'
import UserHeader from '../../components/UserHeader'
import UserHeader from './UserHeader'
export default async function ProfileLayout({
children,

View File

@ -1,9 +1,6 @@
// /src/app/profile/[steamId]/matches/page.tsx
import UserMatchesList from '@/app/[locale]/components/profile/[steamId]/matches/UserMatchesList'
import MatchesList from '@/app/[locale]/components/profile/[steamId]/matches/MatchesList'
export default function MatchesPage({ params }: { params: { steamId: string } }) {
return (
<UserMatchesList steamId={params.steamId} />
)
return <MatchesList steamId={params.steamId} />
}

View File

@ -1,6 +1,6 @@
// /src/app/profile/[steamId]/page.tsx
import { redirect } from 'next/navigation'
import Profile from '../../components/profile/[steamId]/Profile'
export default function ProfileRedirect({ params }: { params: { steamId: string } }) {
redirect(`/profile/${params.steamId}/stats`)
export default function ProfilePage({ params }: { params: { steamId: string } }) {
return <Profile steamId={params.steamId} /> // ggf. Props geben
}

View File

@ -1,12 +1,9 @@
// /src/app/profile/[steamId]/stats/page.tsx
import UserProfile from '@/app/[locale]/components/profile/[steamId]/stats/UserProfile'
import StatsView from '@/app/[locale]/components/profile/[steamId]/stats/StatsView'
import { MatchStats } from '@/types/match'
async function getStats(steamId: string) {
const res = await fetch(`http://localhost:3000/api/stats/${steamId}`, {
cache: 'no-store',
})
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/api/stats/${steamId}`, { cache: 'no-store' })
if (!res.ok) return null
return res.json()
}
@ -14,6 +11,5 @@ async function getStats(steamId: string) {
export default async function StatsPage({ params }: { params: { steamId: string } }) {
const data = await getStats(params.steamId)
if (!data) return <p>Keine Statistiken verfügbar.</p>
return <UserProfile stats={{ matches: data.stats as MatchStats[] }} />
return <StatsView stats={{ matches: data.stats as MatchStats[] }} />
}

View File

@ -285,7 +285,7 @@ async function ensureVote(matchId: string) {
if (match.mapVote) return { match, vote: match.mapVote }
// Neu anlegen
const bestOf = match.bestOf ?? 3
const bestOf = match.matchType === 'community' ? 3 : 1
const mapPool = MAP_OPTIONS.filter(m => m.active).map(m => m.key)
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
@ -456,7 +456,7 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
const SFTPClient = (await import('ssh2-sftp-client')).default
const mLike: MatchLike = {
id: match.id,
bestOf: match.bestOf ?? vote.bestOf ?? 3,
bestOf: (vote?.bestOf ?? (match.matchType === 'community' ? 3 : 1)),
teamA: { name: match.teamA?.name ?? 'Team_1', players: match.teamAUsers ?? [] },
teamB: { name: match.teamB?.name ?? 'Team_2', players: match.teamBUsers ?? [] },
}
@ -469,7 +469,7 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
}
if (!sLike.locked) return
const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3
const bestOf = (mLike.bestOf ?? sLike.bestOf ?? (match.matchType === 'community' ? 3 : 1))
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
if (chosen.length < bestOf) return

View File

@ -42,21 +42,49 @@ export async function GET(_: Request, { params }: { params: { matchId: string }
: buildDefaultPayload(m);
// ⬇️ Zusatz: opensAt (und leadMinutes) an die Antwort hängen
const baseTs = (m.matchDate ?? m.demoDate)?.getTime?.() ?? null;
const opensAt = m.mapVote?.opensAt ?? null;
const baseTs = (m.matchDate ?? m.demoDate)?.getTime?.() ?? null
const opensAt = m.mapVote?.opensAt ?? null
const leadMinutes =
opensAt && baseTs != null
? Math.max(0, Math.round((baseTs - opensAt.getTime()) / 60000))
: null;
: null
// ⬇️ MapVote aus DB in ein Frontend-Shape bringen (mit echten map-Keys!)
const mapVoteFromDb = m.mapVote
? {
locked : m.mapVote.locked,
isOpen : !m.mapVote.locked && (opensAt ? Date.now() >= opensAt.getTime() : false),
opensAt : opensAt,
leadMinutes,
// Großbuchstaben bleiben erhalten: 'BAN' | 'PICK' | 'DECIDER'
steps : m.mapVote.steps
.sort((a, b) => a.order - b.order)
.map(s => ({
order : s.order,
action : s.action, // 'BAN'|'PICK'|'DECIDER'
map : s.map ?? null, // <-- WICHTIG: Map-Key durchreichen!
teamId : s.teamId,
chosenAt: s.chosenAt ?? null,
chosenBy: s.chosenBy ?? null,
})),
// optional: kompaktes Ergebnisfeld, falls dein FE das nutzt
result: {
maps: m.mapVote.steps
.sort((a, b) => a.order - b.order)
.filter(s => (s.action === 'PICK' || s.action === 'DECIDER') && s.map)
.map(s => s.map as string),
},
}
: null
return NextResponse.json({
...payload,
// Payload-MapVote (vom Builder) mit DB-Werten überschreiben/mergen
mapVote: {
...(payload as any).mapVote,
opensAt, // <- wichtig
leadMinutes, // optional, aber nett zu haben
...(mapVoteFromDb ?? {}),
},
}, { headers: { 'Cache-Control': 'no-store' } });
}, { headers: { 'Cache-Control': 'no-store' } })
} catch (err) {
console.error(`GET /matches/${params.matchId} failed:`, err)
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })

File diff suppressed because one or more lines are too long

View File

@ -185,7 +185,6 @@ exports.Prisma.MatchScalarFieldEnum = {
roundCount: 'roundCount',
roundHistory: 'roundHistory',
winnerTeam: 'winnerTeam',
bestOf: 'bestOf',
matchDate: 'matchDate',
createdAt: 'createdAt',
updatedAt: 'updatedAt',

View File

@ -7629,7 +7629,6 @@ export namespace Prisma {
scoreA: number | null
scoreB: number | null
roundCount: number | null
bestOf: number | null
cs2MatchId: number | null
}
@ -7637,7 +7636,6 @@ export namespace Prisma {
scoreA: number | null
scoreB: number | null
roundCount: number | null
bestOf: number | null
cs2MatchId: number | null
}
@ -7655,7 +7653,6 @@ export namespace Prisma {
demoDate: Date | null
roundCount: number | null
winnerTeam: string | null
bestOf: number | null
matchDate: Date | null
createdAt: Date | null
updatedAt: Date | null
@ -7677,7 +7674,6 @@ export namespace Prisma {
demoDate: Date | null
roundCount: number | null
winnerTeam: string | null
bestOf: number | null
matchDate: Date | null
createdAt: Date | null
updatedAt: Date | null
@ -7701,7 +7697,6 @@ export namespace Prisma {
roundCount: number
roundHistory: number
winnerTeam: number
bestOf: number
matchDate: number
createdAt: number
updatedAt: number
@ -7715,7 +7710,6 @@ export namespace Prisma {
scoreA?: true
scoreB?: true
roundCount?: true
bestOf?: true
cs2MatchId?: true
}
@ -7723,7 +7717,6 @@ export namespace Prisma {
scoreA?: true
scoreB?: true
roundCount?: true
bestOf?: true
cs2MatchId?: true
}
@ -7741,7 +7734,6 @@ export namespace Prisma {
demoDate?: true
roundCount?: true
winnerTeam?: true
bestOf?: true
matchDate?: true
createdAt?: true
updatedAt?: true
@ -7763,7 +7755,6 @@ export namespace Prisma {
demoDate?: true
roundCount?: true
winnerTeam?: true
bestOf?: true
matchDate?: true
createdAt?: true
updatedAt?: true
@ -7787,7 +7778,6 @@ export namespace Prisma {
roundCount?: true
roundHistory?: true
winnerTeam?: true
bestOf?: true
matchDate?: true
createdAt?: true
updatedAt?: true
@ -7898,7 +7888,6 @@ export namespace Prisma {
roundCount: number | null
roundHistory: JsonValue | null
winnerTeam: string | null
bestOf: number
matchDate: Date | null
createdAt: Date
updatedAt: Date
@ -7941,7 +7930,6 @@ export namespace Prisma {
roundCount?: boolean
roundHistory?: boolean
winnerTeam?: boolean
bestOf?: boolean
matchDate?: boolean
createdAt?: boolean
updatedAt?: boolean
@ -7976,7 +7964,6 @@ export namespace Prisma {
roundCount?: boolean
roundHistory?: boolean
winnerTeam?: boolean
bestOf?: boolean
matchDate?: boolean
createdAt?: boolean
updatedAt?: boolean
@ -8002,7 +7989,6 @@ export namespace Prisma {
roundCount?: boolean
roundHistory?: boolean
winnerTeam?: boolean
bestOf?: boolean
matchDate?: boolean
createdAt?: boolean
updatedAt?: boolean
@ -8028,7 +8014,6 @@ export namespace Prisma {
roundCount?: boolean
roundHistory?: boolean
winnerTeam?: boolean
bestOf?: boolean
matchDate?: boolean
createdAt?: boolean
updatedAt?: boolean
@ -8036,7 +8021,7 @@ export namespace Prisma {
exportedAt?: boolean
}
export type MatchOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "title" | "matchType" | "map" | "description" | "scoreA" | "scoreB" | "teamAId" | "teamBId" | "filePath" | "demoDate" | "demoData" | "roundCount" | "roundHistory" | "winnerTeam" | "bestOf" | "matchDate" | "createdAt" | "updatedAt" | "cs2MatchId" | "exportedAt", ExtArgs["result"]["match"]>
export type MatchOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "title" | "matchType" | "map" | "description" | "scoreA" | "scoreB" | "teamAId" | "teamBId" | "filePath" | "demoDate" | "demoData" | "roundCount" | "roundHistory" | "winnerTeam" | "matchDate" | "createdAt" | "updatedAt" | "cs2MatchId" | "exportedAt", ExtArgs["result"]["match"]>
export type MatchInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
teamA?: boolean | Match$teamAArgs<ExtArgs>
teamB?: boolean | Match$teamBArgs<ExtArgs>
@ -8089,7 +8074,6 @@ export namespace Prisma {
roundCount: number | null
roundHistory: Prisma.JsonValue | null
winnerTeam: string | null
bestOf: number
matchDate: Date | null
createdAt: Date
updatedAt: Date
@ -8543,7 +8527,6 @@ export namespace Prisma {
readonly roundCount: FieldRef<"Match", 'Int'>
readonly roundHistory: FieldRef<"Match", 'Json'>
readonly winnerTeam: FieldRef<"Match", 'String'>
readonly bestOf: FieldRef<"Match", 'Int'>
readonly matchDate: FieldRef<"Match", 'DateTime'>
readonly createdAt: FieldRef<"Match", 'DateTime'>
readonly updatedAt: FieldRef<"Match", 'DateTime'>
@ -21053,7 +21036,6 @@ export namespace Prisma {
roundCount: 'roundCount',
roundHistory: 'roundHistory',
winnerTeam: 'winnerTeam',
bestOf: 'bestOf',
matchDate: 'matchDate',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
@ -21800,7 +21782,6 @@ export namespace Prisma {
roundCount?: IntNullableFilter<"Match"> | number | null
roundHistory?: JsonNullableFilter<"Match">
winnerTeam?: StringNullableFilter<"Match"> | string | null
bestOf?: IntFilter<"Match"> | number
matchDate?: DateTimeNullableFilter<"Match"> | Date | string | null
createdAt?: DateTimeFilter<"Match"> | Date | string
updatedAt?: DateTimeFilter<"Match"> | Date | string
@ -21834,7 +21815,6 @@ export namespace Prisma {
roundCount?: SortOrderInput | SortOrder
roundHistory?: SortOrderInput | SortOrder
winnerTeam?: SortOrderInput | SortOrder
bestOf?: SortOrder
matchDate?: SortOrderInput | SortOrder
createdAt?: SortOrder
updatedAt?: SortOrder
@ -21871,7 +21851,6 @@ export namespace Prisma {
roundCount?: IntNullableFilter<"Match"> | number | null
roundHistory?: JsonNullableFilter<"Match">
winnerTeam?: StringNullableFilter<"Match"> | string | null
bestOf?: IntFilter<"Match"> | number
matchDate?: DateTimeNullableFilter<"Match"> | Date | string | null
createdAt?: DateTimeFilter<"Match"> | Date | string
updatedAt?: DateTimeFilter<"Match"> | Date | string
@ -21905,7 +21884,6 @@ export namespace Prisma {
roundCount?: SortOrderInput | SortOrder
roundHistory?: SortOrderInput | SortOrder
winnerTeam?: SortOrderInput | SortOrder
bestOf?: SortOrder
matchDate?: SortOrderInput | SortOrder
createdAt?: SortOrder
updatedAt?: SortOrder
@ -21937,7 +21915,6 @@ export namespace Prisma {
roundCount?: IntNullableWithAggregatesFilter<"Match"> | number | null
roundHistory?: JsonNullableWithAggregatesFilter<"Match">
winnerTeam?: StringNullableWithAggregatesFilter<"Match"> | string | null
bestOf?: IntWithAggregatesFilter<"Match"> | number
matchDate?: DateTimeNullableWithAggregatesFilter<"Match"> | Date | string | null
createdAt?: DateTimeWithAggregatesFilter<"Match"> | Date | string
updatedAt?: DateTimeWithAggregatesFilter<"Match"> | Date | string
@ -23242,7 +23219,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -23276,7 +23252,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -23306,7 +23281,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -23340,7 +23314,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -23372,7 +23345,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -23394,7 +23366,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -23418,7 +23389,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -24825,17 +24795,6 @@ export namespace Prisma {
not?: InputJsonValue | JsonFieldRefInput<$PrismaModel> | JsonNullValueFilter
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | IntFieldRefInput<$PrismaModel>
in?: number[] | ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | ListIntFieldRefInput<$PrismaModel>
lt?: number | IntFieldRefInput<$PrismaModel>
lte?: number | IntFieldRefInput<$PrismaModel>
gt?: number | IntFieldRefInput<$PrismaModel>
gte?: number | IntFieldRefInput<$PrismaModel>
not?: NestedIntFilter<$PrismaModel> | number
}
export type DemoFileNullableScalarRelationFilter = {
is?: DemoFileWhereInput | null
isNot?: DemoFileWhereInput | null
@ -24867,7 +24826,6 @@ export namespace Prisma {
roundCount?: SortOrder
roundHistory?: SortOrder
winnerTeam?: SortOrder
bestOf?: SortOrder
matchDate?: SortOrder
createdAt?: SortOrder
updatedAt?: SortOrder
@ -24879,7 +24837,6 @@ export namespace Prisma {
scoreA?: SortOrder
scoreB?: SortOrder
roundCount?: SortOrder
bestOf?: SortOrder
cs2MatchId?: SortOrder
}
@ -24897,7 +24854,6 @@ export namespace Prisma {
demoDate?: SortOrder
roundCount?: SortOrder
winnerTeam?: SortOrder
bestOf?: SortOrder
matchDate?: SortOrder
createdAt?: SortOrder
updatedAt?: SortOrder
@ -24919,7 +24875,6 @@ export namespace Prisma {
demoDate?: SortOrder
roundCount?: SortOrder
winnerTeam?: SortOrder
bestOf?: SortOrder
matchDate?: SortOrder
createdAt?: SortOrder
updatedAt?: SortOrder
@ -24931,7 +24886,6 @@ export namespace Prisma {
scoreA?: SortOrder
scoreB?: SortOrder
roundCount?: SortOrder
bestOf?: SortOrder
cs2MatchId?: SortOrder
}
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
@ -24961,22 +24915,6 @@ export namespace Prisma {
_max?: NestedJsonNullableFilter<$PrismaModel>
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | IntFieldRefInput<$PrismaModel>
in?: number[] | ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | ListIntFieldRefInput<$PrismaModel>
lt?: number | IntFieldRefInput<$PrismaModel>
lte?: number | IntFieldRefInput<$PrismaModel>
gt?: number | IntFieldRefInput<$PrismaModel>
gte?: number | IntFieldRefInput<$PrismaModel>
not?: NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: NestedIntFilter<$PrismaModel>
_avg?: NestedFloatFilter<$PrismaModel>
_sum?: NestedIntFilter<$PrismaModel>
_min?: NestedIntFilter<$PrismaModel>
_max?: NestedIntFilter<$PrismaModel>
}
export type MatchScalarRelationFilter = {
is?: MatchWhereInput
isNot?: MatchWhereInput
@ -25016,6 +24954,17 @@ export namespace Prisma {
createdAt?: SortOrder
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | IntFieldRefInput<$PrismaModel>
in?: number[] | ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | ListIntFieldRefInput<$PrismaModel>
lt?: number | IntFieldRefInput<$PrismaModel>
lte?: number | IntFieldRefInput<$PrismaModel>
gt?: number | IntFieldRefInput<$PrismaModel>
gte?: number | IntFieldRefInput<$PrismaModel>
not?: NestedIntFilter<$PrismaModel> | number
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | FloatFieldRefInput<$PrismaModel>
in?: number[] | ListFloatFieldRefInput<$PrismaModel>
@ -25201,6 +25150,22 @@ export namespace Prisma {
winCount?: SortOrder
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | IntFieldRefInput<$PrismaModel>
in?: number[] | ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | ListIntFieldRefInput<$PrismaModel>
lt?: number | IntFieldRefInput<$PrismaModel>
lte?: number | IntFieldRefInput<$PrismaModel>
gt?: number | IntFieldRefInput<$PrismaModel>
gte?: number | IntFieldRefInput<$PrismaModel>
not?: NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: NestedIntFilter<$PrismaModel>
_avg?: NestedFloatFilter<$PrismaModel>
_sum?: NestedIntFilter<$PrismaModel>
_min?: NestedIntFilter<$PrismaModel>
_max?: NestedIntFilter<$PrismaModel>
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | FloatFieldRefInput<$PrismaModel>
in?: number[] | ListFloatFieldRefInput<$PrismaModel>
@ -26717,14 +26682,6 @@ export namespace Prisma {
connect?: MatchReadyWhereUniqueInput | MatchReadyWhereUniqueInput[]
}
export type IntFieldUpdateOperationsInput = {
set?: number
increment?: number
decrement?: number
multiply?: number
divide?: number
}
export type TeamUpdateOneWithoutMatchesAsTeamANestedInput = {
create?: XOR<TeamCreateWithoutMatchesAsTeamAInput, TeamUncheckedCreateWithoutMatchesAsTeamAInput>
connectOrCreate?: TeamCreateOrConnectWithoutMatchesAsTeamAInput
@ -27023,6 +26980,14 @@ export namespace Prisma {
connect?: MatchPlayerWhereUniqueInput
}
export type IntFieldUpdateOperationsInput = {
set?: number
increment?: number
decrement?: number
multiply?: number
divide?: number
}
export type FloatFieldUpdateOperationsInput = {
set?: number
increment?: number
@ -27558,6 +27523,17 @@ export namespace Prisma {
not?: InputJsonValue | JsonFieldRefInput<$PrismaModel> | JsonNullValueFilter
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | FloatFieldRefInput<$PrismaModel>
in?: number[] | ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | ListFloatFieldRefInput<$PrismaModel>
lt?: number | FloatFieldRefInput<$PrismaModel>
lte?: number | FloatFieldRefInput<$PrismaModel>
gt?: number | FloatFieldRefInput<$PrismaModel>
gte?: number | FloatFieldRefInput<$PrismaModel>
not?: NestedFloatFilter<$PrismaModel> | number
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | IntFieldRefInput<$PrismaModel>
in?: number[] | ListIntFieldRefInput<$PrismaModel>
@ -27574,17 +27550,6 @@ export namespace Prisma {
_max?: NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | FloatFieldRefInput<$PrismaModel>
in?: number[] | ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | ListFloatFieldRefInput<$PrismaModel>
lt?: number | FloatFieldRefInput<$PrismaModel>
lte?: number | FloatFieldRefInput<$PrismaModel>
gt?: number | FloatFieldRefInput<$PrismaModel>
gte?: number | FloatFieldRefInput<$PrismaModel>
not?: NestedFloatFilter<$PrismaModel> | number
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | FloatFieldRefInput<$PrismaModel>
in?: number[] | ListFloatFieldRefInput<$PrismaModel>
@ -27754,7 +27719,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -27787,7 +27751,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -27821,7 +27784,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -27854,7 +27816,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -28299,7 +28260,6 @@ export namespace Prisma {
roundCount?: IntNullableFilter<"Match"> | number | null
roundHistory?: JsonNullableFilter<"Match">
winnerTeam?: StringNullableFilter<"Match"> | string | null
bestOf?: IntFilter<"Match"> | number
matchDate?: DateTimeNullableFilter<"Match"> | Date | string | null
createdAt?: DateTimeFilter<"Match"> | Date | string
updatedAt?: DateTimeFilter<"Match"> | Date | string
@ -28806,7 +28766,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -28838,7 +28797,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -28878,7 +28836,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -28910,7 +28867,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -30348,7 +30304,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -30381,7 +30336,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -30607,7 +30561,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -30640,7 +30593,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -30932,7 +30884,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -30965,7 +30916,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -31083,7 +31033,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -31116,7 +31065,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -31357,7 +31305,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -31390,7 +31337,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -31671,7 +31617,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -31704,7 +31649,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -31733,7 +31677,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -31766,7 +31709,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -31878,7 +31820,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -31911,7 +31852,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -32153,7 +32093,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -32186,7 +32125,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -32261,7 +32199,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -32294,7 +32231,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -32639,7 +32575,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -32672,7 +32607,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -32784,7 +32718,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -32817,7 +32750,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33018,7 +32950,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33051,7 +32982,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33082,7 +33012,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33104,7 +33033,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33137,7 +33065,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33168,7 +33095,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33522,7 +33448,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -33545,7 +33470,6 @@ export namespace Prisma {
roundCount?: number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: string | null
bestOf?: number
matchDate?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@ -33731,7 +33655,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33763,7 +33686,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33794,7 +33716,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33816,7 +33737,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33848,7 +33768,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -33879,7 +33798,6 @@ export namespace Prisma {
roundCount?: NullableIntFieldUpdateOperationsInput | number | null
roundHistory?: NullableJsonNullValueInput | InputJsonValue
winnerTeam?: NullableStringFieldUpdateOperationsInput | string | null
bestOf?: IntFieldUpdateOperationsInput | number
matchDate?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-fc3f88586732483ac25ea246b89655ccf69ac35e1c6d1e7c4e4311101fe5713a",
"name": "prisma-client-f83ffd6f15d5d09bef7e96b9dc3d4dfe3004d8583cd7a36804111c84705fa416",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",

View File

@ -54,6 +54,9 @@ model User {
pterodactylClientApiKey String?
timeZone String? // IANA-TZ, z.B. "Europe/Berlin"
// ✅ Datenschutz: darf eingeladen werden?
canBeInvited Boolean @default(true)
}
enum UserStatus {
@ -151,7 +154,6 @@ model Match {
roundHistory Json?
winnerTeam String?
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVote MapVote?
@ -173,7 +175,7 @@ model MatchPlayer {
teamId String?
team Team? @relation(fields: [teamId], references: [id])
match Match @relation(fields: [matchId], references: [id])
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
user User @relation(fields: [steamId], references: [steamId])
stats PlayerStats?
@ -239,7 +241,7 @@ model RankHistory {
createdAt DateTime @default(now())
user User @relation("UserRankHistory", fields: [steamId], references: [steamId])
match Match? @relation("MatchRankHistory", fields: [matchId], references: [id])
match Match? @relation("MatchRankHistory", fields: [matchId], references: [id], onDelete: Cascade)
}
model Schedule {
@ -263,7 +265,7 @@ model Schedule {
confirmedBy User? @relation("ConfirmedSchedules", fields: [confirmedById], references: [steamId])
linkedMatchId String? @unique
linkedMatch Match? @relation(fields: [linkedMatchId], references: [id])
linkedMatch Match? @relation(fields: [linkedMatchId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -293,7 +295,7 @@ model DemoFile {
createdAt DateTime @default(now())
match Match @relation(fields: [matchId], references: [id])
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
user User @relation(fields: [steamId], references: [steamId])
}
@ -326,7 +328,7 @@ enum MapVoteAction {
model MapVote {
id String @id @default(uuid())
matchId String @unique
match Match @relation(fields: [matchId], references: [id])
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
bestOf Int @default(3)
mapPool String[]
@ -359,7 +361,7 @@ model MapVoteStep {
chosenBy String?
chooser User? @relation("VoteStepChooser", fields: [chosenBy], references: [steamId])
vote MapVote @relation(fields: [voteId], references: [id])
vote MapVote @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@unique([voteId, order])
@@index([teamId])
@ -371,7 +373,7 @@ model MatchReady {
steamId String
acceptedAt DateTime @default(now())
match Match @relation("MatchReadyMatch", fields: [matchId], references: [id])
match Match @relation("MatchReadyMatch", fields: [matchId], references: [id], onDelete: Cascade)
user User @relation("MatchReadyUser", fields: [steamId], references: [steamId])
@@id([matchId, steamId])

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,34 @@
// /src/hooks/useUserTimeZone.ts
'use client'
import { useEffect, useState } from 'react'
export function useUserTimeZone(deps: any[] = []) {
const [tz, setTz] = useState<string>('Europe/Berlin')
useEffect(() => {
const ctrl = new AbortController()
;(async () => {
try {
const res = await fetch('/api/user/timezone', {
credentials: 'include',
cache: 'no-store',
signal: ctrl.signal,
})
const json = res.ok ? await res.json().catch(() => ({})) : {}
const fromDb = typeof json?.timeZone === 'string' ? json.timeZone : null
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'
setTz(fromDb ?? fallback)
} catch {
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'
setTz(fallback)
}
})()
return () => ctrl.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return tz
}

View File

@ -5,20 +5,9 @@ import Steam from 'next-auth-steam'
import { prisma } from './prisma'
import type { SteamProfile } from '@/types/steam'
function readCookie(req: NextRequest, name: string): string | null {
try {
const raw = req.headers.get('cookie') || ''
const m = raw.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
return m ? decodeURIComponent(m[1]) : null
} catch {
return null
}
}
function isValidIanaTz(tz: unknown): tz is string {
if (typeof tz !== 'string' || !tz) return false
try {
// Werfen lassen, wenn ungültig
new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(0)
return true
} catch {
@ -38,7 +27,7 @@ function guessTzFromCountry(cc?: string | null): string | null {
if (C === 'NL') return 'Europe/Amsterdam'
if (C === 'PL') return 'Europe/Warsaw'
if (C === 'CZ') return 'Europe/Prague'
if (C === 'US') return null // zu viele Zonen lieber Cookie abwarten
if (C === 'US') return null
return null
}
@ -55,19 +44,15 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
const steamProfile = profile as SteamProfile
const location = steamProfile.loccountrycode ?? null
// Gibt es den User schon?
const existing = await prisma.user.findUnique({
where: { steamId: steamProfile.steamid },
select: { timeZone: true },
})
// Cookie lesen & validieren
const tzCookie = readCookie(req, 'tz')
const tzFromCookie = isValidIanaTz(tzCookie) ? tzCookie : null
// Fallback fürs erstmalige Anlegen
const guessedTz = tzFromCookie ?? guessTzFromCountry(location)
// Falls neu: anlegen inkl. geschätzter TZ
if (!existing) {
const guessedTz = guessTzFromCountry(location)
await prisma.user.create({
data: {
steamId: steamProfile.steamid,
@ -75,19 +60,21 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
avatar: steamProfile.avatarfull,
location: location ?? undefined,
isAdmin: false,
timeZone: guessedTz ?? null,
timeZone: guessedTz, // kann null sein
},
})
} else {
// Beim Login Stammdaten aktualisieren,
// und NUR falls timeZone noch NULL ist, ggf. schätzen.
const guessedTz = existing.timeZone ?? guessTzFromCountry(location)
await prisma.user.update({
where: { steamId: steamProfile.steamid },
data: {
name: steamProfile.personaname,
avatar: steamProfile.avatarfull,
...(location && { location }),
// Wenn noch keine TZ in DB und Cookie vorhanden → einmalig setzen
...(existing.timeZone == null && tzFromCookie
? { timeZone: tzFromCookie }
...(existing.timeZone == null && guessedTz
? { timeZone: guessedTz }
: {}),
},
})
@ -110,12 +97,12 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
userInDb.steamId === '76561198000414190'
? true
: userInDb.isAdmin ?? false
}
// ➜ HIER: Cookie jedes Mal bevorzugen, sonst DB fallback
const currentCookie = readCookie(req, 'tz')
const cookieTz = isValidIanaTz(currentCookie) ? currentCookie : undefined
token.timeZone = cookieTz ?? userInDb?.timeZone ?? undefined
// ➜ einzig maßgeblich: DB-TimeZone
token.timeZone = userInDb.timeZone ?? undefined
} else {
token.timeZone = undefined
}
return token
},
@ -130,9 +117,10 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
image: token.image,
team: token.team ?? null,
isAdmin: token.isAdmin ?? false,
// ➜ TZ in die Session durchreichen
timeZone: token.timeZone,
}
// ➜ für UI verfügbar
timeZone: (token as any).timeZone ?? null,
} as typeof session.user & { steamId: string; team: string | null; isAdmin: boolean; timeZone: string | null }
return session
},

View File

@ -7,7 +7,6 @@ declare module 'next-auth' {
steamId?: string
isAdmin?: boolean
team?: string | null
timeZone?: string
}
}
@ -15,7 +14,6 @@ declare module 'next-auth' {
steamId: string
isAdmin: boolean
team?: string | null
timeZone?: string
}
}
@ -26,6 +24,5 @@ declare module 'next-auth/jwt' {
team?: string | null
name?: string
image?: string
timeZone?: string
}
}