This commit is contained in:
Linrador 2025-09-25 15:06:29 +02:00
parent f27c6feedb
commit 530425a82c
50 changed files with 1533 additions and 619 deletions

View File

@ -54,6 +54,9 @@
pterodactylClientApiKey String?
timeZone String? // IANA-TZ, z.B. "Europe/Berlin"
// ✅ Datenschutz: darf eingeladen werden?
canBeInvited Boolean @default(true)
}
enum UserStatus {
@ -173,7 +176,7 @@
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 +242,7 @@
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 +266,7 @@
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 +296,7 @@
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 +329,7 @@
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 +362,7 @@
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 +374,7 @@
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])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@ -60,7 +60,7 @@ export default function Card({
<div
style={style}
className={[
'flex flex-col rounded-xl border border-gray-200 bg-white shadow-2xs',
'flex flex-col rounded-xl border border-gray-200 bg-white shadow-2xs p-3',
'dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70',
alignClasses,
widthClasses[maxWidth],

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl'
@ -56,14 +56,82 @@ function getMapVoteState(m: Match, nowMs: number) {
return { hasVote: true as const, isOpen, opensAt, opensInMs }
}
// ---- Timezone Utils ----
type ZonedParts = {
year: number; month: number; day: number; hour: number; minute: number;
};
function getUserTimeZone(sessionTz?: string): string {
return (
sessionTz ||
Intl.DateTimeFormat().resolvedOptions().timeZone ||
'Europe/Berlin'
);
}
function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts {
const d = typeof date === 'string' ? new Date(date) : date;
const parts = new Intl.DateTimeFormat(locale, {
timeZone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(d);
const g = (t: string) => Number(parts.find(p => p.type === t)?.value ?? '0');
return { year: g('year'), month: g('month'), day: g('day'), hour: g('hour'), minute: g('minute') };
}
function formatDateInTZ(date: Date | string, timeZone: string, locale = 'de-DE') {
const p = getZonedParts(date, timeZone, locale);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(p.day)}.${pad(p.month)}.${p.year}`;
}
function formatTimeInTZ(date: Date | string, timeZone: string, locale = 'de-DE') {
const p = getZonedParts(date, timeZone, locale);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(p.hour)}:${pad(p.minute)}`;
}
function dateKeyInTZ(date: Date | string, timeZone: string): string {
const p = getZonedParts(date, timeZone);
const pad = (n: number) => String(n).padStart(2, '0');
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 weekdayFmt = useMemo(() =>
new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-GB', {
weekday: 'long',
timeZone: userTZ,
}),
[locale, userTZ])
const tMatches = useTranslations('matches')
const tMapvote = useTranslations('mapvote')
const { lastEvent } = useSSEStore()
@ -94,6 +162,24 @@ 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)
@ -265,14 +351,14 @@ export default function CommunityMatchList({ matchType }: Props) {
const grouped = (() => {
const sorted = [...matches].sort(
(a, b) => new Date(a.demoDate).getTime() - new Date(b.demoDate).getTime(),
)
const map = new Map<string, Match[]>()
);
const map = new Map<string, Match[]>();
for (const m of sorted) {
const key = toDateKey(new Date(m.demoDate))
map.set(key, [...(map.get(key) ?? []), m])
const key = dateKeyInTZ(m.demoDate, userTZ);
map.set(key, [...(map.get(key) ?? []), m]);
}
return Array.from(map.entries())
})()
return Array.from(map.entries());
})();
return (
<div className="max-w-7xl mx-auto py-8 px-4 space-y-6">
@ -302,9 +388,9 @@ export default function CommunityMatchList({ matchType }: Props) {
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{grouped.map(([dateKey, dayMatches], dayIdx) => {
const dateObj = new Date(dateKey + 'T00:00:00')
const weekdayFmt = locale === 'de' ? weekdayDE : weekdayEN
const dayLabel = `${tMatches('day')} #${dayIdx + 1} ${weekdayFmt.format(dateObj)}`
const dateObj = new Date(dateKey + 'T00:00:00');
const weekday = weekdayFmt.format(dateObj);
const dayLabel = `${tMatches('day')} #${dayIdx + 1} ${weekday}`;
return (
<div key={dateKey} className="flex flex-col gap-4">
<div className="bg-yellow-300 dark:bg-yellow-500 text-center py-2 font-bold tracking-wider">
@ -322,7 +408,7 @@ export default function CommunityMatchList({ matchType }: Props) {
const mv = getMapVoteState(m, now)
const opensText =
mv.hasVote && !mv.isOpen && mv.opensAt
? `${tMatches("opens-in")} ${formatCountdown(mv.opensInMs)}`
? `${tMapvote("opens-in")} ${formatCountdown(mv.opensInMs)}`
: null
return (
@ -390,19 +476,20 @@ export default function CommunityMatchList({ matchType }: Props) {
{/* Zeile 3: Datum & Uhrzeit */}
<div className="flex flex-col items-center -mt-1 space-y-1">
{/* Datum-Badge */}
<span
className={`px-3 py-1 rounded-full text-[13px] font-bold shadow ring-1 ring-black/10
${isLive ? 'bg-red-500 text-white' : 'bg-yellow-400 text-gray-900 dark:bg-yellow-500 dark:text-black'}
`}
>
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
{formatDateInTZ(m.demoDate, userTZ, locale === 'de' ? 'de-DE' : 'en-GB')}
</span>
<span className="flex items-center gap-1 text-xs font-semibold opacity-90">
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 512 512">
<path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" />
</svg>
{format(new Date(m.demoDate), 'HH:mm', { locale: de })} Uhr
{formatTimeInTZ(m.demoDate, userTZ, locale === 'de' ? 'de-DE' : 'en-GB')} Uhr
</span>
</div>
</Link>

View File

@ -1,13 +1,12 @@
// src/app/components/GameBanner.tsx
'use client'
import React, { useEffect, useRef } from 'react'
import React, {useEffect, useRef, useState} from 'react'
import Link from 'next/link'
import Button from './Button'
import { useUiChromeStore } from '@/lib/useUiChromeStore'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl'
import {useUiChromeStore} from '@/lib/useUiChromeStore'
import {MAP_OPTIONS} from '@/lib/mapOptions'
import { useTranslations } from 'next-intl'
export type GameBannerVariant = 'connected' | 'disconnected'
@ -15,31 +14,25 @@ type Props = {
variant: GameBannerVariant
visible: boolean
zIndex?: number
// gemeinsam
connectedCount: number
totalExpected: number
connectUri: string
onReconnect: () => void
// neu: zum harten Trennen (X-Button)
onDisconnect?: () => void
// nur für "connected"
serverLabel?: string
mapKey?: string
mapLabel?: string
phase?: string
score?: string
// nur für "disconnected"
missingCount?: number
// optional: wenn im Dock unter Main gerendert
inline?: boolean
}
/* ---------- helpers ---------- */
function hashStr(s: string): number {
const hashStr = (s: string) => {
let h = 5381
for (let i = 0; i < s.length; i++) h = ((h << 5) + h) + s.charCodeAt(i)
return h | 0
}
function pickMapImageFromOptions(mapKey?: string): string | null {
if (!mapKey) return null
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
@ -47,44 +40,48 @@ function pickMapImageFromOptions(mapKey?: string): string | null {
const idx = Math.abs(hashStr(mapKey)) % opt.images.length
return opt.images[idx] ?? null
}
function pickMapIcon(mapKey?: string): string | null {
if (!mapKey) return null
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
return opt?.icon ?? null
}
// Banner auf mobilen Bildschirmen gar nicht rendern (unter sm)
function useIsSmDown() {
const [smDown, setSmDown] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(max-width: 639.98px)')
setSmDown(mq.matches)
const onChange = (e: MediaQueryListEvent) => setSmDown(e.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return smDown
}
/* ---------- component ---------- */
export default function GameBanner({
variant,
visible,
zIndex = 9999,
connectedCount,
totalExpected,
connectUri,
onReconnect,
onDisconnect,
serverLabel,
mapKey,
mapLabel,
phase,
score,
missingCount,
inline = false,
}: Props) {
export default function GameBanner(props: Props) {
const {
variant, visible, zIndex = 9999, connectedCount, totalExpected,
onReconnect, onDisconnect, serverLabel, mapKey, mapLabel, phase, score, inline = false,
} = props
const ref = useRef<HTMLDivElement | null>(null)
const setBannerPx = useUiChromeStore(s => s.setGameBannerPx)
// ▼ Phase normalisieren und Sichtbarkeit nur erlauben, wenn nicht "unknown"
const phaseStr = String(phase ?? 'unknown').toLowerCase()
const show = visible && phaseStr !== 'unknown'
// Übersetzungen
const tGameBanner = useTranslations('game-banner')
const isSmDown = useIsSmDown()
const t = useTranslations('game-banner')
const phaseStr = String(phase ?? 'unknown').toLowerCase()
const show = !isSmDown && visible && phaseStr !== 'unknown'
// <-- Hook wird IMMER aufgerufen, macht aber nichts wenn !show
useEffect(() => {
if (!show) { setBannerPx(0); return }
const el = ref.current
if (!el) return
if (!show || !el) { // guard
setBannerPx(0)
return
}
const report = () => setBannerPx(el.getBoundingClientRect().height)
report()
const ro = new ResizeObserver(report)
@ -92,35 +89,43 @@ export default function GameBanner({
return () => { ro.disconnect(); setBannerPx(0) }
}, [show, setBannerPx])
// Ableitungen vor dem Guard
const outerBase = inline ? '' : 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
const outerStyle = inline ? {} : { zIndex }
// ab hier darfst du bedingt rendern
if (!show) return null
const wrapperClass =
variant === 'connected'
? 'bg-emerald-700/95 text-white ring-1 ring-black/10'
: 'bg-amber-700/95 text-white ring-1 ring-black/10'
const outerBase = inline ? '' : 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
const outerStyle = inline ? undefined : ({ zIndex } as React.CSSProperties)
const isConnected = variant === 'connected'
const wrapperClass = isConnected
? 'bg-emerald-700/95 text-white ring-1 ring-black/10'
: 'bg-amber-700/95 text-white ring-1 ring-black/10'
const bgUrl = pickMapImageFromOptions(mapKey)
const mapIconConnected = pickMapIcon(mapKey)
const iconUrl = variant === 'connected' ? (mapIconConnected ?? '') : '/assets/img/icons/ui/disconnect.svg'
const iconUrl = isConnected ? (pickMapIcon(mapKey) ?? '') : '/assets/img/icons/ui/disconnect.svg'
const prettyMap = mapLabel ?? mapKey ?? '—'
const prettyPhase = phaseStr || 'unknown' // nutzt die normalisierte Phase
const prettyScore = score ?? ' : '
const pretty = {
map: mapLabel ?? mapKey ?? '—',
phase: phaseStr || 'unknown',
score: score ?? ' : ',
}
const handleFocusGame = (e: React.MouseEvent) => {
e.preventDefault()
const openGame = () => {
try { window.location.href = 'steam://rungameid/730' } catch {}
}
// ▼ nichts rendern, wenn Phase unbekannt oder sichtbar=false
if (!show) return null
const InfoRow = () => (
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
<span>Map: <span className="font-semibold">{pretty.map}</span></span>
<span>Phase: <span className="font-semibold">{pretty.phase}</span></span>
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
<span>{t('player-connected')}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
</div>
)
return (
<div className={outerBase} style={outerStyle} ref={ref}>
<div className={`relative overflow-hidden shadow-lg ${wrapperClass} transition duration-300 ease-in-out`}>
{/* Subtiler Map-Hintergrund */}
{/* Hintergrundbild (Map) */}
{bgUrl && (
<div
aria-hidden
@ -140,64 +145,39 @@ export default function GameBanner({
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background: 'linear-gradient(180deg, rgba(0,0,0,0.20) 0%, rgba(0,0,0,0.10) 40%, rgba(0,0,0,0.25) 100%)',
}}
style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.20) 0%, rgba(0,0,0,0.10) 40%, rgba(0,0,0,0.25) 100%)' }}
/>
{/* Inhalt */}
<div className="relative p-3 flex items-center gap-3">
{/* Icon links */}
{iconUrl ? (
{/* Icon */}
{iconUrl && (
<div className="shrink-0 relative z-[1]">
<div className="h-9 w-9 rounded-md bg-black/15 flex items-center justify-center ring-1 ring-black/20">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconUrl}
alt={variant === 'connected' ? (prettyMap || 'Map') : 'Disconnected'}
alt={isConnected ? (pretty.map || 'Map') : 'Disconnected'}
className="h-6 w-6 object-contain"
loading="eager"
decoding="async"
/>
</div>
</div>
) : null}
)}
{/* Text */}
<div className="flex-1 min-w-0">
{variant === 'connected' ? (
<>
<div className="text-sm flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
{serverLabel ?? 'CS2 Server'}
</span>
</div>
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
<span>Map: <span className="font-semibold">{prettyMap}</span></span>
<span>Phase: <span className="font-semibold">{prettyPhase}</span></span>
<span>Score: <span className="font-semibold">{prettyScore}</span></span>
<span>{tGameBanner("player-connected")}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
</div>
</>
) : (
<>
<div className="text-sm flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
{tGameBanner("disconnected")}
</span>
</div>
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
<span>Map: <span className="font-semibold">{prettyMap}</span></span>
<span>Phase: <span className="font-semibold">{prettyPhase}</span></span>
<span>Score: <span className="font-semibold">{prettyScore}</span></span>
<span>{tGameBanner("player-connected")}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
</div>
</>
)}
<div className="text-sm flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
{isConnected ? (serverLabel ?? 'CS2 Server') : t('disconnected')}
</span>
</div>
<InfoRow />
</div>
{/* Buttons rechts */}
{/* Actions */}
<div className="relative z-[1] flex items-center gap-2">
{/* Radar */}
<Link href="/radar" className="inline-flex">
<Button
variant="white"
@ -207,15 +187,12 @@ export default function GameBanner({
title={undefined}
>
<span className="relative mr-2 h-4 w-4 rounded-full overflow-hidden ring-1 ring-black/20 bg-black/20">
{/* zentraler Punkt */}
<span className="absolute inset-0 flex items-center justify-center">
<span className="h-1.5 w-1.5 rounded-full bg-white/90 shadow-[0_0_6px_rgba(255,255,255,0.8)]" />
</span>
{/* expandierende Ringe */}
<span className="absolute inset-0 rounded-full border border-white/50 opacity-70 animate-ping-slow" />
<span className="absolute inset-0 rounded-full border border-white/30 opacity-50 animate-ping-slower" />
<span className="absolute inset-0 rounded-full border border-white/20 opacity-30 animate-ping-slowest" />
{/* rotierender Sweep */}
<span className="absolute inset-0 rounded-full overflow-hidden">
<span className="absolute left-1/2 top-1/2 origin-left -translate-y-1/2 h-[1.2px] w-full bg-gradient-to-r from-white/70 via-white/30 to-transparent animate-sweep" />
</span>
@ -224,53 +201,32 @@ export default function GameBanner({
</Button>
</Link>
{/* Spiel öffnen / Neu verbinden */}
{variant === 'connected' ? (
<Button
color="green"
variant="solid"
size="md"
onClick={(e) => {
e.preventDefault()
try { window.location.href = 'steam://rungameid/730' } catch {}
}}
title="Spiel öffnen"
/>
{isConnected ? (
<Button color="green" variant="solid" size="md" onClick={openGame} title="Spiel öffnen" />
) : (
<Button
color="green"
variant="solid"
size="md"
onClick={() => onReconnect()}
title={tGameBanner("reconnect")}
/>
<Button color="green" variant="solid" size="md" onClick={onReconnect} title={t('reconnect')} />
)}
{/* X nur im verbundenen Zustand anzeigen */}
{variant === 'connected' && (
{isConnected && (
<Button
color="transparent"
variant="ghost"
size="md"
className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none"
onClick={() => onDisconnect?.()}
aria-label={tGameBanner("disconnected")}
aria-label={t('disconnected')}
title={undefined}
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5 block"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" className="h-5 w-5 block" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<span className="mt-0.5 text-[11px] font-medium opacity-90">{tGameBanner("quit")}</span>
<span className="mt-0.5 text-[11px] font-medium opacity-90">{t('quit')}</span>
</Button>
)}
</div>
</div>
{/* CSS für Radar-Ping & Sweep */}
{/* Radar-Animationen */}
<style jsx>{`
@keyframes ringPing {
0% { transform: scale(0.4); opacity: 0.85; }

View File

@ -1,10 +1,8 @@
// src/app/components/GameBannerSpacer.tsx
'use client'
import {useEffect, useState} from 'react'
import {useUiChromeStore} from '@/lib/useUiChromeStore'
export default function GameBannerSpacer() {
const bannerPx = useUiChromeStore(s => s.gameBannerPx) // oder passender Selector
// Nur Höhe setzen, wenn inline-Banner genutzt wird sonst 0
return <div style={{height: bannerPx ?? 0}} aria-hidden />
export default function GameBannerSpacer({ className = '' }: { className?: string }) {
const bannerPx = useUiChromeStore(s => s.gameBannerPx)
return <div className={className} style={{ height: bannerPx ?? 0 }} aria-hidden />
}

View File

@ -1,19 +1,24 @@
// /src/app/components/LoadingSpinner.tsx
'use client'
import { useTranslations } from 'next-intl'
export default function LoadingSpinner() {
return (
<div className="flex flex-auto flex-col justify-center items-center p-2">
<div className="flex justify-center">
<div
className="animate-spin inline-block size-6 border-3 border-current border-t-transparent text-blue-600 rounded-full dark:text-blue-500"
role="status"
aria-label="loading"
>
<span className="sr-only">Loading...</span>
// Übersetzungen
const tCommon = useTranslations('common')
return (
<div className="flex flex-auto flex-col justify-center items-center p-2">
<div className="flex justify-center">
<div
className="animate-spin inline-block size-6 border-3 border-current border-t-transparent text-blue-600 rounded-full dark:text-blue-500"
role="status"
aria-label="loading"
>
<span className="sr-only">{tCommon("loading")}...</span>
</div>
</div>
</div>
</div>
)
)
}

View File

@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/lib/useSSEStore'
import type { MapVoteState } from '../../../types/mapvote'
import { useTranslations } from 'next-intl'
type Props = {
match: any
@ -50,6 +51,9 @@ export default function MapVoteBanner({
const [now, setNow] = useState(initialNow)
useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, [])
// Übersetzungen
const tCommon = useTranslations('common')
const load = useCallback(async () => {
try {

View File

@ -70,15 +70,6 @@ export default function MapVoteProfileCard({
<span className="truncate font-medium text-gray-900 dark:text-neutral-100">
{name}
</span>
{isActiveTurn ? (
<span className="mt-0.5 text-[11px] font-medium text-blue-700 dark:text-blue-300">
am Zug
</span>
) : (
<span className="mt-0.5 text-[11px] text-gray-500 dark:text-neutral-400">
bereit
</span>
)}
</div>
{/* PremierRank ganz außen */}

View File

@ -26,7 +26,7 @@ import Link from 'next/link'
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
/* ─────────────────── Helpers ─────────────────── */
const kdr = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number'
? d === 0
@ -46,10 +46,8 @@ 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')
)
const k = (key ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
return MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD')
}
// Maps aus dem MapVote (nur PICK/DECIDER, sortiert)
@ -57,12 +55,25 @@ 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))
.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)
}
// Wählt ein konsistentes Hintergrundbild pro Map
const pickMapImage = (key?: string) => {
const k = (key ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
const opt = MAP_OPTIONS.find(o => o.key === k)
if (!opt?.images?.length) return null
// simple hash → gleiche Map → gleiches Bild
let h = 5381
for (let i = 0; i < k.length; i++) h = ((h << 5) + h) + k.charCodeAt(i)
const idx = Math.abs(h) % opt.images.length
return opt.images[idx] as string
}
/* ─────────────────── UI-Snippets ─────────────────── */
function SeriesStrip({
bestOf,
scoreA = 0,
@ -84,20 +95,15 @@ function SeriesStrip({
return (
<div className="w-full">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-400">
Best of {bestOf} First to {needed}
</div>
<div className="text-sm font-semibold">
{winsA}:{winsB}
</div>
<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 sm:grid-cols-2 md:grid-cols-3 gap-2">
{Array.from({ length: total }).map((_, i) => {
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{Array.from({length: total}).map((_, i) => {
const key = maps[i] ?? ''
const label = mapLabelFromKey(key)
const isDone = i < winsA + winsB
const isCurrent = i === currentIdx
const isFuture = i > winsA + winsB
@ -106,25 +112,22 @@ function SeriesStrip({
<div
key={`series-map-${i}`}
className={[
'rounded-md px-3 py-2 border flex items-center justify-between',
'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',
? 'border-emerald-500 bg-emerald-500/10'
: 'border-gray-600 bg-neutral-800/40',
].join(' ')}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs opacity-70 shrink-0">Map {i + 1}</span>
<span className="font-medium truncate">{label}</span>
<div 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="flex items-center gap-1 shrink-0">
{isDone && (
<span className="text-emerald-400 text-xs font-semibold"></span>
)}
<div 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-blue-400 text-[11px] font-semibold">LIVE / als Nächstes</span>
<span className="text-[11px] font-semibold text-blue-400">LIVE / als Nächstes</span>
)}
</div>
</div>
@ -135,24 +138,72 @@ function SeriesStrip({
)
}
/* ─────────────────── Komponente ─────────────────────────────── */
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
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
// Hydration-sicher: keine sich ändernden Werte im SSR rendern
// Wir brauchen "now" nur, um zu entscheiden, ob Mapvote schon gestartet ist
const [now, setNow] = useState(initialNow)
const [editMetaOpen, setEditMetaOpen] = useState(false)
// Alle Maps der Serie (BO3/BO5)
const allMaps = useMemo(() => extractSeriesMaps(match), [match.mapVote?.steps, match.bestOf])
const [activeMapIdx, setActiveMapIdx] = useState(0)
// Lokale Overrides (analog MapVoteBanner), damit die Clients sofort reagieren
// Zeit / Modals
const [now, setNow] = useState(initialNow)
const [editMetaOpen, setEditMetaOpen] = useState(false)// Modal-State
const [editSide, setEditSide] = useState<EditSide | null>(null)
// Mapvote Overrides (SSE-reaktiv)
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
const [leadOverride, setLeadOverride] = useState<number | null>(null)
const lastHandledKeyRef = useRef<string>('')
/* ─── Rollen & Rechte ─────────────────────────────────────── */
// Rollen & Rechte
const me = session?.user
const userId = me?.steamId
const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId
@ -163,39 +214,56 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
/* ─── Map-Label ───────────────────────────────────────────── */
const mapKey = normalizeMapKey(match.map)
// Aktiv-Map aus Query (?m=2) initialisieren
useEffect(() => {
const sp = new URLSearchParams(window.location.search)
const m = Number(sp.get('m'))
if (Number.isFinite(m) && m >= 0 && m < allMaps.length) setActiveMapIdx(m)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allMaps.length])
const setActive = (idx: number) => {
setActiveMapIdx(idx)
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)
}
// Map-Kontext
const mapKey = normalizeMapKey(allMaps[activeMapIdx] ?? match.map)
const mapLabel =
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ??
'Unbekannte Map'
/* ─── Match-Zeitpunkt (vom Server; ändert sich via router.refresh) ─── */
const dateString = match.matchDate ?? match.demoDate
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
// Mapvote läuft / Map noch nicht final gewählt?
const isPickBanPhase = Boolean(match.mapVote?.isOpen) || !mapKey || mapKey === 'lobby_mapvote'
// Datum
const dateString = match.matchDate ?? match.demoDate
const readableDate = dateString ? format(new Date(dateString), 'PPpp', {locale: de}) : 'Unbekannt'
// „Serie komplett“ (für SeriesStrip)
const seriesMaps = useMemo(() => {
const fromVote = extractSeriesMaps(match)
const n = Math.max(1, match.bestOf ?? 1)
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')]
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({length: n - fromVote.length}, () => '')]
}, [match.bestOf, match.mapVote?.steps?.length])
/* ─── Modal-State ─────────────────────────────────────────── */
const [editSide, setEditSide] = useState<EditSide | null>(null)
/* ─── Live-Uhr für Mapvote-Startfenster ───────────────────── */
// Ticker für Mapvote-Zeitfenster
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
// Basiszeit des Matches (stabil; für Berechnung von opensAt-Fallback)
// Basiszeit des Matches (Fallback)
const matchBaseTs = useMemo(() => {
const raw = match.matchDate ?? match.demoDate ?? initialNow
return new Date(raw).getTime()
}, [match.matchDate, match.demoDate, initialNow])
// Zeitpunkt, wann der Mapvote öffnet (Parent errechnet und an Banner gereicht)
// Öffnet der Mapvote wann?
const voteOpensAtTs = useMemo(() => {
if (opensAtOverride != null) return opensAtOverride
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime()
@ -205,20 +273,17 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
return matchBaseTs - lead * 60_000
}, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride])
const sseOpensAtTs = voteOpensAtTs
const sseOpensAtTs = voteOpensAtTs
const sseLeadMinutes = leadOverride
const endDate = new Date(voteOpensAtTs)
const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs
const showEditA = canEditA && !mapvoteStarted
const showEditB = canEditB && !mapvoteStarted
/* ─── SSE-Listener (nur map-vote-updated & Co.) ───────────── */
// SSE-Listener (nur relevante Events)
useEffect(() => {
if (!lastEvent) return
// robustes Unwrap
const outer = lastEvent as any
const maybeInner = outer?.payload
const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner)
@ -226,23 +291,15 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
: outer
const type = base?.type
const evt = base?.payload ?? base
const evt = base?.payload ?? base
if (!evt?.matchId || evt.matchId !== match.id) return
// Dedupe-Key
const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}`
if (key === lastHandledKeyRef.current) {
// identisches Event bereits verarbeitet → ignorieren
return
}
if (key === lastHandledKeyRef.current) return
lastHandledKeyRef.current = key
// eigentliche Verarbeitung
if (type === 'map-vote-updated') {
if (evt?.opensAt) {
const ts = new Date(evt.opensAt).getTime()
setOpensAtOverride(ts)
}
if (evt?.opensAt) setOpensAtOverride(new Date(evt.opensAt).getTime())
if (Number.isFinite(evt?.leadMinutes)) {
const lead = Number(evt.leadMinutes)
setLeadOverride(lead)
@ -251,33 +308,28 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
setOpensAtOverride(baseTs - lead * 60_000)
}
}
// damit match.matchDate & Co. neu vom Server kommen
router.refresh()
return
}
const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) {
router.refresh()
}
if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) router.refresh()
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
// Tabellen-Layout
const ColGroup = () => (
<colgroup>
<col style={{ width: '24%' }} />
<col style={{ width: '8%' }} />
{Array.from({ length: 13 }).map((_, i) => (
<col key={i} style={{ width: '5.666%' }} />
))}
<col style={{width: '24%'}} />
<col style={{width: '8%'}} />
{Array.from({length: 13}).map((_, i) => <col key={i} style={{width: '5.666%'}} />)}
</colgroup>
)
/* ─── Match löschen ────────────────────────────────────────── */
// Löschen
const handleDelete = async () => {
if (!confirm('Match wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return
try {
const res = await fetch(`/api/matches/${match.id}/delete`, { method: 'POST' })
const res = await fetch(`/api/matches/${match.id}/delete`, {method: 'POST'})
if (!res.ok) {
const j = await res.json().catch(() => ({}))
alert(j.message ?? 'Löschen fehlgeschlagen')
@ -290,7 +342,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
}
}
/* ─── Spieler-Tabelle (pure; keine Hooks hier drin!) ──────── */
// Spieler-Tabelle
const renderTable = (players: MatchPlayer[]) => {
const sorted = [...players].sort(
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
@ -305,10 +357,8 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
'Spieler', 'Rank', 'Aim', 'K', 'A', 'D',
'1K', '2K', '3K', '4K', '5K',
'K/D', 'ADR', 'HS%', 'Damage',
].map((h) => (
<Table.Cell key={h} as="th">
{h}
</Table.Cell>
].map(h => (
<Table.Cell key={h} as="th">{h}</Table.Cell>
))}
</Table.Row>
</Table.Head>
@ -317,16 +367,16 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{sorted.map((p) => (
<Table.Row key={p.user.steamId}>
<Table.Cell
className="py-1 flex items-center gap-2"
className="flex items-center gap-2 py-1"
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
<img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name}
className="w-8 h-8 rounded-full mr-3"
className="mr-3 h-8 w-8 rounded-full"
/>
<div className="font-semibold text-base">{p.user.name ?? 'Unbekannt'}</div>
<div className="text-base font-semibold">{p.user.name ?? 'Unbekannt'}</div>
</Table.Cell>
<Table.Cell>
@ -337,24 +387,18 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span
className={`text-sm ${
p.stats.rankChange > 0
? 'text-green-500'
: p.stats.rankChange < 0
? 'text-red-500'
: ''
p.stats.rankChange > 0 ? 'text-green-500'
: p.stats.rankChange < 0 ? 'text-red-500' : ''
}`}
>
{p.stats.rankChange > 0 ? '+' : ''}
{p.stats.rankChange}
{p.stats.rankChange > 0 ? '+' : ''}{p.stats.rankChange}
</span>
)}
</div>
</Table.Cell>
<Table.Cell>
{Number.isFinite(Number(p.stats?.aim))
? `${Number(p.stats?.aim).toFixed(0)} %`
: '-'}
{Number.isFinite(Number(p.stats?.aim)) ? `${Number(p.stats?.aim).toFixed(0)} %` : '-'}
</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
@ -375,28 +419,26 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
)
}
/* ─── Render ─────────────────────────────────────────────── */
/* ─────────────────── Render ─────────────────── */
return (
<div className="space-y-6">
{/* Kopfzeile: Zurück + Admin-Buttons */}
{/* Topbar: Back + Admin */}
<div className="flex items-center justify-between">
<Link href="/schedule">
<Button color="gray" variant="outline">
Zurück
</Button>
<Button color="gray" variant="outline"> Zurück</Button>
</Link>
{isAdmin && (
<div className="flex gap-2">
<Button
onClick={() => setEditMetaOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md"
className="rounded-md bg-blue-600 px-3 py-1.5 text-white hover:bg-blue-700"
>
Match bearbeiten
</Button>
<Button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"
className="rounded-md bg-red-600 px-3 py-1.5 text-white hover:bg-red-700"
>
Match löschen
</Button>
@ -404,34 +446,160 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
)}
</div>
<h1 className="text-2xl font-bold">
Match auf {mapLabel} ({match.matchType})
</h1>
{/* HEADER */}
<div id="match-header" className="relative overflow-hidden rounded-xl ring-1 ring-black/10">
{/* Map-BG */}
{(() => {
// wenn Pick/Ban → immer Fallback anzeigen
const wanted = isPickBanPhase ? null : pickMapImage(mapKey)
const bg = wanted || '/assets/img/maps/lobby_mapveto_png.webp'
return (
<div
aria-hidden
className="absolute inset-0"
style={{
backgroundImage: `url(${bg})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(2px)',
transform: 'scale(1.02)',
opacity: 0.55
}}
/>
)
})()}
{/* Hydration-sicher: Datum kommt vom Server und ändert sich nach SSE via router.refresh() */}
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
{/* Gradient + Shine */}
<div
aria-hidden
className="absolute inset-0"
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%)',
}}
/>
<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]" />
{(match.bestOf ?? 1) > 1 && (
<div className="mt-3">
<SeriesStrip
bestOf={match.bestOf ?? 3}
scoreA={match.scoreA}
scoreB={match.scoreB}
maps={extractSeriesMaps(match)}
/>
{/* Content */}
<div className="relative p-4 sm:p-6">
{/* Meta-Zeile */}
<div className="flex items-center justify-between 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>
<time className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
{readableDate}
</time>
</>
)}
</div>
</div>
{/* Teams + Score */}
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:gap-6">
{/* Team A */}
<div className="min-w-0">
<div className="flex items-center gap-3">
{match.teamA?.logo && (
<img
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamA.name ?? 'Team A'}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
/>
)}
<div className="min-w-0">
<div className="text-xs text-white/80">Team A</div>
<div className="truncate text-lg font-semibold text-white sm:text-xl">
{match.teamA?.name ?? 'Unbekannt'}
</div>
</div>
</div>
</div>
{/* Score */}
<div className="text-center">
<div className="text-xs text-white/80">Score</div>
<div className="mx-auto mt-1 inline-flex items-center gap-3 rounded-lg bg-black/30 px-3 py-1.5 ring-1 ring-white/10">
<span className="animate-[pop_350ms_ease-out] text-2xl font-bold text-white drop-shadow-sm sm:text-3xl">
{match.scoreA ?? 0}
</span>
<span className="font-semibold text-white/60">:</span>
<span className="animate-[pop_350ms_ease-out_120ms] text-2xl font-bold text-white drop-shadow-sm sm:text-3xl">
{match.scoreB ?? 0}
</span>
</div>
<div className="mt-2 text-xs text-white/75">{`on ${mapLabel}`}</div>
</div>
{/* Team B */}
<div className="min-w-0 justify-self-end">
<div className="flex items-center justify-end gap-3">
<div className="min-w-0 text-right">
<div className="text-xs text-white/80">Team B</div>
<div className="truncate text-lg font-semibold text-white sm:text-xl">
{match.teamB?.name ?? 'Unbekannt'}
</div>
</div>
{match.teamB?.logo && (
<img
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt={match.teamB.name ?? 'Team B'}
className="h-10 w-10 rounded-md object-cover ring-1 ring-white/20 bg-black/20 sm:h-12 sm:w-12"
/>
)}
</div>
</div>
</div>
{/* Map-Tabs bei Serie */}
{allMaps.length > 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}
scoreA={match.scoreA}
scoreB={match.scoreB}
maps={seriesMaps}
/>
</div>
)}
</div>
)}
<div className="text-md mt-2">
<strong>Teams:</strong> {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
{/* 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; }
}
`}</style>
</div>
<div className="text-md">
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div>
{/* MapVote-Banner erhält die aktuell berechneten (SSE-konformen) Werte */}
{(match.matchType === 'community' &&
{/* MapVote-Banner (mit berechneten Werten) */}
{match.matchType === 'community' && (
<MapVoteBanner
match={match}
initialNow={initialNow}
@ -441,14 +609,14 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
/>
)}
{/* ───────── Team-Blöcke ───────── */}
<div className="border-t pt-4 mt-4 space-y-10">
{/* Teams / Tabellen */}
<div className="mt-4 space-y-10 border-t pt-4">
{/* Team A */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xl font-semibold">
{match.teamA?.logo && (
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
<span className="relative mr-2 inline-block h-8 w-8 align-middle">
<Image
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
alt="Teamlogo"
@ -459,25 +627,24 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
/>
</span>
)}
{match.teamB?.name ?? 'Team B'}
{match.teamA?.name ?? 'Team A'}
</h2>
{canEditA && !mapvoteStarted && (
{showEditA && (
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<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>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
</span>
</div>
<Button
size="sm"
onClick={() => setEditSide('A')}
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Spieler bearbeiten
</Button>
@ -490,10 +657,10 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{/* Team B */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xl font-semibold">
{match.teamB?.logo && (
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
<span className="relative mr-2 inline-block h-8 w-8 align-middle">
<Image
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt="Teamlogo"
@ -507,22 +674,21 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{match.teamB?.name ?? 'Team B'}
</h2>
{canEditB && !mapvoteStarted && (
{showEditB && (
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<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>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
</span>
</div>
<Button
size="sm"
onClick={() => setEditSide('B')}
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Spieler bearbeiten
</Button>
@ -534,7 +700,8 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
</div>
</div>
{/* ───────── Modal ───────── */}
{/* Echte Modals (außerhalb IIFE, State oben): */}
{/* Spieler-Modal */}
{editSide && (
<EditMatchPlayersModal
show
@ -543,12 +710,13 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
teamA={match.teamA}
teamB={match.teamB}
side={editSide}
initialA={teamAPlayers.map((mp) => mp.user.steamId)}
initialB={teamBPlayers.map((mp) => mp.user.steamId)}
initialA={teamAPlayers.map(mp => mp.user.steamId)}
initialB={teamBPlayers.map(mp => mp.user.steamId)}
onSaved={() => router.refresh()}
/>
)}
{/* Meta-Modal */}
{editMetaOpen && (
<EditMatchMetaModal
show

View File

@ -1,3 +1,4 @@
// /src/app/components/Modal.tsx
'use client'
import { useEffect } from 'react'
@ -70,9 +71,7 @@ export default function Modal({
hs.close?.(modalEl)
destroyIfExists()
}
} catch (err) {
console.error('[Modal] HSOverlay Fehler:', err)
}
} catch (err) {}
return () => {
modalEl.removeEventListener('hsOverlay:close', handleClose)

View File

@ -1,4 +1,4 @@
// NotificationCenter.tsx
// /src/app/[locale]/components/NotificationCenter.tsx
'use client'
import { useEffect, useRef, useMemo, useState } from 'react'
@ -169,7 +169,10 @@ export default function NotificationCenter({
) : (
!n.read && (
<Button
onClick={() => onSingleRead(n.id)}
onClick={(e) => {
e.stopPropagation();
onSingleRead(n.id);
}}
title="Als gelesen markieren"
className="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-white"
color="gray"

View File

@ -1,31 +1,26 @@
'use client'
import React, { useState, useRef, useEffect } from 'react'
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
interface PopoverProps {
text: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
gap?: number // Abstand (px) zwischen Button und Popover
}
export default function Popover({ text, children, size = 'sm' }: PopoverProps) {
export default function Popover({
text,
children,
size = 'sm',
gap = 8,
}: PopoverProps) {
const [open, setOpen] = useState(false)
const [style, setStyle] = useState<React.CSSProperties>({ visibility: 'hidden' })
const buttonRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!buttonRef.current?.contains(event.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 })
const ticking = useRef(false)
const sizeClass = {
sm: 'max-w-xs',
@ -34,21 +29,138 @@ export default function Popover({ text, children, size = 'sm' }: PopoverProps) {
xl: 'max-w-lg',
}[size]
// außerhalb klicken -> schließen
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
const t = e.target as Node
if (popoverRef.current?.contains(t)) return
if (buttonRef.current?.contains(t)) return
setOpen(false)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open])
// Größe 1x nach Öffnen messen (ohne Blinken)
useLayoutEffect(() => {
if (!open) return
const pop = popoverRef.current
if (!pop) return
// kurz offscreen, um zuverlässige Maße zu bekommen
const prev = pop.style.cssText
pop.style.position = 'fixed'
pop.style.left = '-9999px'
pop.style.top = '-9999px'
pop.style.visibility = 'hidden'
pop.style.maxWidth = '' // durch Tailwind-Klasse
sizeRef.current = { w: pop.offsetWidth, h: pop.offsetHeight }
pop.style.cssText = prev
// initial platzieren & sichtbar
place(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// rAF-throttled positionierung
const place = (makeVisible = false) => {
if (!open) return
const btn = buttonRef.current
const pop = popoverRef.current
if (!btn || !pop) return
const rect = btn.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const mobile = vw < 640
const { w, h } = sizeRef.current
// Standard: unter dem Trigger
let top = rect.bottom + gap
// wenn unten zu wenig Platz: oberhalb anzeigen
if (top + h + 8 > vh) top = Math.max(8, rect.top - gap - h)
let left: number
let transform = ''
if (mobile) {
left = rect.left + rect.width / 2
transform = 'translateX(-50%)'
const half = w / 2
if (left - half < 8) left = 8 + half
if (left + half > vw - 8) left = vw - 8 - half
} else {
// rechts am Button ausrichten
left = rect.right - w
if (left < 8) left = 8
if (left + w > vw - 8) left = vw - 8 - w
}
setStyle((s) => ({
...s,
position: 'fixed',
top,
left,
transform,
zIndex: 50,
visibility: makeVisible ? 'visible' : s.visibility, // beim ersten Mal sichtbar schalten
}))
}
useEffect(() => {
if (!open) return
const onScrollOrResize = () => {
if (ticking.current) return
ticking.current = true
requestAnimationFrame(() => {
place()
ticking.current = false
})
}
window.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
return () => {
window.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, gap])
return (
<div className="relative inline-block">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((prev) => !prev)}
className="mt-1 text-xs text-gray-400 dark:text-neutral-500"
onClick={() => setOpen((v) => !v)}
className="mt-1 text-xs text-gray-400 dark:text-neutral-500 text-left inline-flex"
>
{text}
</button>
{/* mobiles Overlay optional */}
{open && (
<div
className="fixed inset-0 z-40 sm:hidden"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
)}
{open && (
<div
ref={popoverRef}
className={`fixed z-10 mt-2 ${sizeClass} rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 shadow-md dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300`}
style={style}
className={[
'w-[min(92vw,32rem)] sm:w-auto',
sizeClass,
'rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 shadow-md',
'dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300',
'whitespace-normal break-words',
'will-change-transform', // smoother
].join(' ')}
role="dialog"
aria-modal="true"
>
{children}
</div>

View File

@ -0,0 +1,105 @@
'use client'
import {useEffect, useMemo, useRef, useState} from 'react'
import type {RefObject} from 'react'
export type SpyItem = { id: string; label: string }
type Props = {
items: SpyItem[]
/** Scroll-Container; wenn nicht gesetzt, wird `document` beobachtet */
containerRef?: RefObject<HTMLElement | null>
className?: string
activeClassName?: string
inactiveClassName?: string
}
export default function ScrollSpyTabs({
items,
containerRef,
className = 'flex flex-col gap-1',
activeClassName = 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white',
inactiveClassName = 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700'
}: Props) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
const observerRef = useRef<IntersectionObserver | null>(null)
// Sichtbarkeits-Beobachtung
useEffect(() => {
const rootEl = containerRef?.current ?? null
const sections = items
.map(i => (rootEl ?? document).querySelector<HTMLElement>(`#${CSS.escape(i.id)}`))
.filter(Boolean) as HTMLElement[]
if (sections.length === 0) return
// etwas Toleranz: Sektion gilt als aktiv, wenn ~40% sichtbar sind
observerRef.current?.disconnect()
observerRef.current = new IntersectionObserver(
(entries) => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
if (visible[0]) {
const id = (visible[0].target as HTMLElement).id
setActiveId(id)
} else {
// Fallback: oberste Sektion, wenn nichts “offiziell” intersected
const first = sections.find(s => {
const rect = s.getBoundingClientRect()
const top = rect.top - (rootEl?.getBoundingClientRect().top ?? 0)
return top >= -10 // nahe am Anfang
})
if (first) setActiveId(first.id)
}
},
{
root: rootEl ?? null,
// top padding, damit schon etwas früher aktiv markiert wird:
rootMargin: '-20% 0px -40% 0px',
threshold: [0.2, 0.4, 0.6, 0.8],
}
)
sections.forEach((s) => observerRef.current?.observe(s))
return () => observerRef.current?.disconnect()
}, [items, containerRef])
const onJump = (id: string) => {
const rootEl = containerRef?.current ?? null
const el = (rootEl ?? document).querySelector<HTMLElement>(`#${CSS.escape(id)}`)
if (!el) return
if (rootEl) {
// innerhalb eines Scroll-Containers:
const rootTop = rootEl.getBoundingClientRect().top
const targetTop = el.getBoundingClientRect().top
const delta = targetTop - rootTop + rootEl.scrollTop - 12 /* kleiner offset */
rootEl.scrollTo({ top: delta, behavior: 'smooth' })
} else {
// Fenster scrollen
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
return (
<nav className={className} aria-label="Section navigation" role="tablist" aria-orientation="vertical">
{items.map((it) => {
const isActive = activeId === it.id
return (
<button
key={it.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onJump(it.id)}
className={`text-left py-2 px-3 rounded-lg transition-colors ${isActive ? activeClassName : inactiveClassName}`}
>
{it.label}
</button>
)
})}
</nav>
)
}

View File

@ -13,16 +13,38 @@ export type TabProps = {
type TabsProps = {
children: ReactNode
/** optional kontrollierter Modus */
value?: string // aktiver Tab-Name
value?: string
onChange?: (name: string) => void
/** neu: Ausrichtung */
orientation?: 'horizontal' | 'vertical'
/** optional: Styling */
className?: string
tabClassName?: string
}
export function Tabs({ children, value, onChange }: TabsProps) {
export function Tabs({
children,
value,
onChange,
orientation = 'horizontal',
className = '',
tabClassName = ''
}: TabsProps) {
const pathname = usePathname()
const tabs = Array.isArray(children) ? children : [children]
const isVertical = orientation === 'vertical'
return (
<nav className="flex gap-x-1" aria-label="Tabs" role="tablist" aria-orientation="horizontal">
<nav
className={[
'flex',
isVertical ? 'flex-col gap-y-1' : 'flex-row gap-x-1',
className
].join(' ')}
aria-label="Tabs"
role="tablist"
aria-orientation={isVertical ? 'vertical' : 'horizontal'}
>
{tabs
.filter(
(tab): tab is ReactElement<TabProps> =>
@ -32,7 +54,10 @@ export function Tabs({ children, value, onChange }: TabsProps) {
typeof tab.props.href === 'string'
)
.map((tab, index) => {
// Unkontrolliert (Routing) vs. kontrolliert (onChange)
const baseClasses =
'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
// kontrollierter Modus
if (onChange && value !== undefined) {
const isActive = value === tab.props.name
return (
@ -42,18 +67,20 @@ export function Tabs({ children, value, onChange }: TabsProps) {
onClick={() => onChange(tab.props.name)}
role="tab"
aria-selected={isActive}
className={`py-2 px-4 text-sm rounded-lg transition-colors ${
isActive
className={
baseClasses +
' ' +
(isActive
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700'
}`}
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
}
>
{tab.props.name}
</button>
)
}
// Standard: Link-basiert
// Link-basiert
const base = tab.props.href.replace(/\/$/, '')
const current = pathname.replace(/\/$/, '')
const isActive = current === base || current.startsWith(base + '/')
@ -64,11 +91,13 @@ export function Tabs({ children, value, onChange }: TabsProps) {
href={tab.props.href}
role="tab"
aria-selected={isActive}
className={`py-2 px-4 text-sm rounded-lg transition-colors ${
isActive
className={
baseClasses +
' ' +
(isActive
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700'
}`}
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
}
>
{tab.props.name}
</Link>
@ -78,5 +107,4 @@ export function Tabs({ children, value, onChange }: TabsProps) {
)
}
// Dummy-Komponente nur zur statischen Verwendung
Tabs.Tab = function Tab(_props: TabProps) { return null }

View File

@ -78,6 +78,11 @@ function TeamCardComponent(
const lastInviteCheck = useRef<number>(0)
// 🔒 Flood-Guards
const lastHandledRef = useRef<string>('') // Event-Dedupe
const softReloadInFlight = useRef(false) // keine Parallel-Reloads
const lastSoftReloadAt = useRef(0) // einfacher Throttle (ms)
/* ------- User+Teams laden (einmalig) ------- */
const loadUserTeams = async () => {
try {
@ -87,18 +92,13 @@ function TeamCardComponent(
const data = await res.json()
const teams: Team[] = Array.isArray(data?.teams) ? data.teams : []
setMyTeams(prev => {
if (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i]))) return prev
return teams
})
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
// Auto-Auswahl
if (teams.length === 1) {
setSelectedTeam(teams[0])
} else {
if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) {
setSelectedTeam(null)
}
} else if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) {
setSelectedTeam(null)
}
// Einladungen leeren, wenn ich mind. ein Team habe
@ -112,8 +112,14 @@ function TeamCardComponent(
useEffect(() => { loadUserTeams() }, []) // eslint-disable-line react-hooks/exhaustive-deps
/* ------- SSE-gestützte Soft-Reloads ------- */
/* ------- Gedrosseltes Soft-Reload ------- */
const softReload = async () => {
const now = Date.now()
if (softReloadInFlight.current) return
if (now - lastSoftReloadAt.current < 500) return // 500ms Cooldown
softReloadInFlight.current = true
lastSoftReloadAt.current = now
try {
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return
@ -126,6 +132,7 @@ function TeamCardComponent(
else if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) setSelectedTeam(null)
if (teams.length > 0 && pendingInvitations.length) setPendingInvitations([])
if (teams.length === 0 && Date.now() - lastInviteCheck.current > 1500) {
lastInviteCheck.current = Date.now()
const inv = await fetch('/api/user/invitations', { cache: 'no-store' })
@ -137,17 +144,42 @@ function TeamCardComponent(
setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all))
}
}
} catch { /* noop */ }
} finally {
softReloadInFlight.current = false
}
}
/* ------- SSE-gestützte Updates (dedupliziert) ------- */
useEffect(() => {
if (!lastEvent) return
if (!isSseEventType(lastEvent.type)) return
const { type, payload } = lastEvent
const { type, payload } = lastEvent as any
if (SELF_EVENTS.has(type)) { softReload(); return }
// Dedupe-Key (Type + teamId + version + invitationId)
const key = [
type,
payload?.teamId ?? '',
payload?.version ?? '',
payload?.invitationId ?? ''
].join('|')
if (key === lastHandledRef.current) return
lastHandledRef.current = key
// Logo-Event: nur lokal updaten, KEIN /api/user-Reload
if (type === 'team-logo-updated' && payload?.teamId && payload?.filename) {
setMyTeams(prev => prev.map(t => (t.id === payload.teamId ? { ...t, logo: payload.filename } as Team : t)))
if (selectedTeam?.id === payload.teamId) {
setSelectedTeam(prev =>
prev && prev.id === payload.teamId
? { ...prev, logo: payload.filename }
: prev
)
}
return
}
// Invite revoked: Liste anpassen, dann gedrosseltes Reload
if (type === 'team-invite-revoked') {
const revokedId = payload?.invitationId as string | undefined
const revokedTeamId = payload?.teamId as string | undefined
@ -163,10 +195,13 @@ function TeamCardComponent(
return
}
// Relevante Gruppen → gedrosseltes Reload
if (SELF_EVENTS.has(type)) { softReload(); return }
if (TEAM_EVENTS.has(type)) { softReload(); return }
if (INVITE_EVENTS.has(type) && myTeams.length === 0) { softReload(); return }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEvent, myTeams.length, selectedTeam, pendingInvitations])
}, [lastEvent, myTeams.length]) // bewusst schlanke Dependencies
/* ------- Render-Zweige ------- */
@ -177,7 +212,7 @@ function TeamCardComponent(
return (
<>
{pendingInvitations.length > 0 && (
<div className="space-y-4 mb-4">
<div className="mb-4 space-y-4">
{pendingInvitations.map(inv => (
<TeamInvitationBanner
key={inv.id}
@ -272,16 +307,16 @@ function TeamCardComponent(
focus:outline-none focus:ring-2 focus:ring-blue-500
"
>
<div className="flex items-center justify-between gap-3 mb-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt={team.name ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border
className="h-12 w-12 rounded-full border object-cover
border-gray-200 dark:border-neutral-600"
/>
<div className="flex flex-col">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
<span className="truncate font-medium text-gray-800 dark:text-neutral-200">
{team.name ?? 'Team'}
</span>
<span className="text-xs text-gray-500 dark:text-neutral-400">
@ -289,8 +324,7 @@ function TeamCardComponent(
</span>
</div>
</div>
{/* Chevron */}
<svg className="w-4 h-4 text-gray-400" viewBox="0 0 24 24">
<svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
</svg>
</div>
@ -302,7 +336,7 @@ function TeamCardComponent(
src={p.avatar}
alt={p.name}
title={p.name}
className="w-8 h-8 rounded-full border-2 border-white
className="h-8 w-8 rounded-full border-2 border-white
dark:border-neutral-800 object-cover"
/>
))}
@ -314,20 +348,21 @@ function TeamCardComponent(
)
}
// selectedTeam gesetzt → Detailansicht mit "Zurück" oben links
// selectedTeam gesetzt → Detailansicht
return (
<div>
<div className="mb-4 xl:mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="mb-2 flex items-center gap-3">
<button
type="button"
onClick={() => setSelectedTeam(null)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm border border-gray-300
dark:border-neutral-600 bg-white dark:bg-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-700
focus:outline-none focus:ring-2 focus:ring-blue-500"
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300
bg-white px-3 py-1.5 text-sm hover:bg-gray-50 focus:outline-none
focus:ring-2 focus:ring-blue-500 dark:border-neutral-600
dark:bg-neutral-800 dark:hover:bg-neutral-700"
aria-label="Zurück zur Teamübersicht"
>
<svg viewBox="0 0 24 24" className="w-4 h-4" aria-hidden>
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden>
<path fill="currentColor" d="M15 18l-6-6l6-6v12z" />
</svg>
<span>Zurück</span>
@ -337,7 +372,6 @@ function TeamCardComponent(
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
Teamverwaltung
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-500">
Verwalte dein Team und lade Mitglieder ein
</p>

View File

@ -527,7 +527,7 @@ export default function TeamMemberView({
}}
className="h-[34px] px-3 flex items-center justify-center"
>
<span className="text-green-600"></span>
</Button>
<Button
title="Abbrechen"
@ -540,7 +540,7 @@ export default function TeamMemberView({
}}
className="h-[34px] px-3 flex items-center justify-center"
>
<span className="text-red-600"></span>
</Button>
</>
) : (

View File

@ -73,7 +73,7 @@ export default function ServerView({
defaultValue=""
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
placeholder={cfg.pterodactylServerApiKey ? '•••••••• (gesetzt)' : 'noch nicht gesetzt'}
placeholder={cfg.pterodactylServerApiKey ? '•••••••• (gesetzt)' : 'ptla_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'}
autoComplete="off"
/>
<p className="mt-1 text-xs text-neutral-500">
@ -118,7 +118,7 @@ export default function ServerView({
defaultValue=""
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
placeholder={meUser?.pterodactylClientApiKey ? '•••••••• (gesetzt)' : 'noch nicht gesetzt'}
placeholder={meUser?.pterodactylClientApiKey ? '•••••••• (gesetzt)' : 'ptlc_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'}
autoComplete="off"
/>
<p className="mt-1 text-xs text-neutral-500">

View File

@ -1,52 +0,0 @@
'use client'
import AppearanceSettings from "./account/AppearanceSettings"
import AuthCodeSettings from "./account/AuthCodeSettings"
import LatestKnownCodeSettings from "./account/ShareCodeSettings"
import { useTranslations, useLocale } from 'next-intl'
import UserSettings from "./account/UserSettings"
export default function AccountSettings() {
// Übersetzungen
const tSettings = useTranslations('settings')
return (
<>{/* Account Card */}
<div className="">
{/* Title */}
<div className="mb-4 xl:mb-8">
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
{tSettings("tabs.account.title")}
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-500">
{tSettings("tabs.account.description")}
</p>
</div>
{/* End Title */}
{/* Form */}
<form>
{/* User Settings */}
<UserSettings />
{/* End User Settings */}
{/* Auth Code Settings */}
<AuthCodeSettings />
{/* End Auth Code Settings */}
{/* Auth Code Settings */}
<LatestKnownCodeSettings />
{/* End Auth Code Settings */}
{/* Appearance */}
<AppearanceSettings />
{/* End Appearance */}
</form>
{/* End Form */}
</div>
{/* End Account Card */}
</>
)
}

View File

@ -18,31 +18,31 @@ export default function AppearanceSettings() {
if (!mounted) return null
const options = [
{ id: 'system', label: tSettings("tabs.account.page.AppearanceSettings.theme.system"), img: 'account-system-image.svg' },
{ id: 'light', label: tSettings("tabs.account.page.AppearanceSettings.theme.light"), img: 'account-light-image.svg' },
{ id: 'dark', label: tSettings("tabs.account.page.AppearanceSettings.theme.dark"), img: 'account-dark-image.svg' },
{ id: 'system', label: tSettings("sections.account.page.AppearanceSettings.theme.system"), img: 'account-system-image.svg' },
{ id: 'light', label: tSettings("sections.account.page.AppearanceSettings.theme.light"), img: 'account-light-image.svg' },
{ id: 'dark', label: tSettings("sections.account.page.AppearanceSettings.theme.dark"), img: 'account-dark-image.svg' },
]
return (
<div className="py-6 sm:py-8 space-y-5 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="py-3 sm:py-4 space-y-5">
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
<div className="sm:col-span-4 2xl:col-span-2">
<label className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings("tabs.account.page.AppearanceSettings.name")}
{tSettings("sections.account.page.AppearanceSettings.name")}
</label>
</div>
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
<p className="text-sm text-gray-500 dark:text-neutral-500">
{tSettings("tabs.account.page.AppearanceSettings.description")}
{tSettings("sections.account.page.AppearanceSettings.description")}
</p>
<h3 className="mt-3 text-sm font-semibold text-gray-800 dark:text-neutral-200">
{tSettings("tabs.account.page.AppearanceSettings.theme-mode")}
{tSettings("sections.account.page.AppearanceSettings.theme-mode")}
</h3>
<p className="text-sm text-gray-500 dark:text-neutral-500">
{tSettings("tabs.account.page.AppearanceSettings.theme-mode-description")}
{tSettings("sections.account.page.AppearanceSettings.theme-mode-description")}
</p>
<div className="mt-5">

View File

@ -1,3 +1,5 @@
// AuthCodeSettings.tsx
'use client'
import { useEffect, useMemo, useState } from 'react'
@ -101,20 +103,25 @@ export default function AuthCodeSettings() {
}, [])
return (
<div className="py-6 sm:py-8 space-y-5 border-t border-gray-200 dark:border-neutral-700">
<div className="py-3 sm:py-4 space-y-5">
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
<div className="sm:col-span-4 2xl:col-span-2">
<label htmlFor="auth-code" className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings("tabs.account.page.AuthCodeSettings.name")}
{tSettings("sections.account.page.AuthCodeSettings.name")}
</label>
<div className="mt-1">
<Popover text={tSettings("tabs.account.page.AuthCodeSettings.question")} size="xl">
<Popover text={tSettings("sections.account.page.AuthCodeSettings.question")} size="xl">
<div className="space-y-3">
<i><q>{tSettings("tabs.account.page.AuthCodeSettings.description")}</q></i>
<i className="block w-full">
<q className="block w-full text-justify hyphens-auto">
{tSettings("sections.account.page.AuthCodeSettings.description")}
</q>
</i>
<p>
{tSettings("tabs.account.find-code")}&nbsp;
{tSettings("sections.account.find-code")}&nbsp;
<Link
href={tSettings("tabs.account.url")}
href={tSettings("sections.account.url")}
target="_blank"
className="text-blue-600 underline hover:text-blue-800"
>
@ -158,7 +165,7 @@ export default function AuthCodeSettings() {
{touched && (
<p className={`text-sm mt-2 ${authCodeValid ? 'text-teal-600' : 'text-red-600'}`}>
{authCodeValid ? `${tCommon("saved")}!` : `${tSettings("tabs.account.page.AuthCodeSettings.invalid")}`}
{authCodeValid ? `${tCommon("saved")}!` : `${tSettings("sections.account.page.AuthCodeSettings.invalid")}`}
</p>
)}
</div>

View File

@ -126,20 +126,20 @@ export default function LatestKnownCodeSettings() {
const showError = touched && !isValid
return (
<div className="py-6 sm:py-8 space-y-5 border-t border-gray-200 dark:border-neutral-700">
<div className="py-3 sm:py-4 space-y-5">
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
<div className="sm:col-span-4 2xl:col-span-2">
<label htmlFor="known-code" className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings("tabs.account.page.ShareCodeSettings.name")}
{tSettings("sections.account.page.ShareCodeSettings.name")}
</label>
<div className="mt-1">
<Popover text={tSettings("tabs.account.page.ShareCodeSettings.question")} size="xl">
<Popover text={tSettings("sections.account.page.ShareCodeSettings.question")} size="xl">
<div className="space-y-3">
<i><q>{tSettings("tabs.account.page.ShareCodeSettings.description")}</q></i>
<i><q>{tSettings("sections.account.page.ShareCodeSettings.description")}</q></i>
<p>
{tSettings("tabs.account.find-code")}&nbsp;
{tSettings("sections.account.find-code")}&nbsp;
<Link
href={tSettings("tabs.account.url")}
href={tSettings("sections.account.url")}
target="_blank"
className="text-blue-600 underline hover:text-blue-800"
>
@ -175,7 +175,7 @@ export default function LatestKnownCodeSettings() {
{showError && (
<p className="text-sm text-red-600 mt-2">
{tSettings("tabs.account.page.ShareCodeSettings.invalid-share-code")}&nbsp;
{tSettings("sections.account.page.ShareCodeSettings.invalid-share-code")}&nbsp;
<Link
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
target="_blank"

View File

@ -2,7 +2,7 @@
import {useEffect, useMemo, useRef, useState} from 'react'
import Popover from '../../Popover'
import {useTranslations} from 'next-intl'
import { useTranslations } from 'next-intl'
// Versuche, native Liste zu nehmen; fallback auf gängige Auswahl.
const FALLBACK_TIMEZONES = [
@ -38,6 +38,7 @@ export default function UserSettings() {
const [saving, setSaving] = useState(false)
const [savedOk, setSavedOk] = useState<boolean | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [touched, setTouched] = useState(false)
const timeZones = useMemo(() => getTimeZones(), [])
const debounceTimer = useRef<number | null>(null)
@ -84,6 +85,7 @@ export default function UserSettings() {
}
setInitialTz(tz)
setSavedOk(true)
setTouched(false) // <- hinzu
// kleines Auto-Reset des „Gespeichert“-Hinweises
window.setTimeout(() => setSavedOk(null), 2000)
} catch (e: any) {
@ -111,21 +113,13 @@ export default function UserSettings() {
}
}, [timeZone, initialTz, loading])
if (loading) {
return (
<div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700">
<p className="text-sm text-gray-500 dark:text-neutral-500">Loading</p>
</div>
)
}
return (
<div className="py-6 sm:py-8 space-y-5 border-t border-gray-200 dark:border-neutral-700">
<div className="py-3 sm:py-4 space-y-5">
<div className="grid sm:grid-cols-12 gap-y-1.5 sm:gap-y-0 sm:gap-x-5">
{/* Label + Hilfe */}
<div className="sm:col-span-4 2xl:col-span-2">
<label htmlFor="user-timezone" className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings('tabs.account.page.UserSettings.timezone-label')}
{tSettings('sections.user.timezone-label')}
</label>
</div>
@ -134,18 +128,30 @@ export default function UserSettings() {
<div className="flex items-center gap-2">
<select
id="user-timezone"
value={timeZone ?? ''}
onChange={(e) => setTimeZone(e.target.value || null)}
disabled={saving}
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm disabled:opacity-70"
value={timeZone ?? ''} // bleibt kontrolliert
onChange={(e) => { setTimeZone(e.target.value || null); setTouched(true) }}
disabled={saving || loading} // während Lade-/Speicherphase sperren
aria-busy={loading ? 'true' : undefined} // a11y
className="max-w-md rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm disabled:opacity-70"
>
<option value="">{tSettings('tabs.account.page.UserSettings.timezone-system')}</option>
{timeZones.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
{loading ? (
// Nur während des Ladens:
<option value="" disabled>
{tCommon("loading")}...
</option>
) : (
<>
<option value="">
{tSettings('sections.user.timezone-system')}
</option>
{timeZones.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</>
)}
</select>
{/* Live-Status (optional dezent) */}
{/* Live-Status rechts (optional) */}
<span className="text-xs min-w-[80px] text-right">
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving')}</span>}
{savedOk === true && <span className="text-teal-600"> {tCommon('saved')}</span>}
@ -153,13 +159,9 @@ export default function UserSettings() {
</span>
</div>
{/* Fehlertext detaillierter */}
{errorMsg && (
<p className="text-xs mt-1 text-red-600">{errorMsg}</p>
)}
{errorMsg && <p className="text-xs mt-1 text-red-600">{errorMsg}</p>}
{/* Hinweis, wenn nichts geändert wurde */}
{savedOk === null && timeZone === initialTz && (
{touched && !saving && !loading && savedOk === null && timeZone === initialTz && (
<p className="text-xs mt-2 text-gray-500 dark:text-neutral-400">
{tCommon('no-changes')}
</p>

View File

@ -0,0 +1,136 @@
// app/[locale]/settings/_components/PrivacySettings.tsx
'use client'
import {useEffect, useRef, useState} from 'react'
import { useTranslations } from 'next-intl'
export default function PrivacySettings() {
const tSettings = useTranslations('settings')
const tCommon = useTranslations('common')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [savedOk, setSavedOk] = useState<boolean | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
// Wert (default=true falls API nichts liefert)
const [canBeInvited, setCanBeInvited] = useState<boolean>(true)
const [initial, setInitial] = useState<boolean>(true)
const debounceTimer = useRef<number | null>(null)
const inFlight = useRef<AbortController | null>(null)
// Laden
useEffect(() => {
;(async () => {
try {
const res = await fetch('/api/user/privacy', { cache: 'no-store' })
const data = await res.json().catch(() => ({}))
const value = typeof data?.canBeInvited === 'boolean' ? data.canBeInvited : true
setCanBeInvited(value)
setInitial(value)
} catch (e) {
console.error('[PrivacySettings] load failed:', e)
} finally {
setLoading(false)
}
})()
}, [])
// Speichern (debounced)
const persist = async (value: boolean) => {
inFlight.current?.abort()
const ctrl = new AbortController()
inFlight.current = ctrl
setSaving(true)
setSavedOk(null)
setErrorMsg(null)
try {
const res = await fetch('/api/user/privacy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ canBeInvited: value }),
signal: ctrl.signal
})
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j?.message || `HTTP ${res.status}`)
}
setInitial(value)
setSavedOk(true)
window.setTimeout(() => setSavedOk(null), 2000)
} catch (e: any) {
if (e?.name === 'AbortError') return
console.error('[PrivacySettings] save failed:', e)
setSavedOk(false)
setErrorMsg(e?.message ?? 'Save failed')
} finally {
setSaving(false)
}
}
useEffect(() => {
if (loading) return
if (canBeInvited === initial) return
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
debounceTimer.current = window.setTimeout(() => {
persist(canBeInvited)
}, 400) as unknown as number
return () => {
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
}
}, [canBeInvited, initial, loading])
return (
<div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700">
<div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-start">
{/* Label-Spalte */}
<div className="sm:col-span-4 2xl:col-span-2">
<span className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings('sections.privacy.invites.label')}
</span>
</div>
{/* Inhalt-Spalte */}
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
{/* Toggle */}
<button
type="button"
disabled={loading || saving}
onClick={() => setCanBeInvited(v => !v)}
className={[
'relative inline-flex h-6 w-11 items-center rounded-full transition',
canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700',
'disabled:opacity-60 disabled:cursor-not-allowed'
].join(' ')}
aria-pressed={canBeInvited}
aria-label={tSettings('sections.privacy.invites.label')}
title={undefined}
>
<span
className={[
'inline-block h-5 w-5 transform rounded-full bg-white transition',
canBeInvited ? 'translate-x-5' : 'translate-x-1',
].join(' ')}
/>
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-neutral-400">
{tSettings('sections.privacy.invites.help')}
</p>
{/* Status/Fehler */}
<div className="mt-1 text-xs min-h-[1rem]">
{loading && <span className="text-gray-500 dark:text-neutral-400">{tCommon('loading') ?? 'Laden…'}</span>}
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving') ?? 'Speichern…'}</span>}
{savedOk === true && <span className="text-teal-600"> {tCommon('saved') ?? 'Gespeichert'}</span>}
{savedOk === false && <span className="text-red-600">{tCommon('save-failed') ?? 'Speichern fehlgeschlagen'}</span>}
{errorMsg && <p className="text-red-600">{errorMsg}</p>}
</div>
</div>
</div>
</div>
)
}

View File

@ -59,7 +59,7 @@ export default async function RootLayout({children, params}: Props) {
<main className="flex-1 min-w-0 overflow-hidden">
<div className="h-full box-border p-4 sm:p-6">{children}</div>
</main>
<GameBannerSpacer />
<GameBannerSpacer className="hidden sm:block" />
</div>
</div>

View File

@ -1,7 +1,7 @@
// app/match-details/[matchId]/vote/VoteClient.tsx
'use client'
import MapVotePanel from '../components/MapVotePanel'
import MapVotePanel from '@/app/[locale]/components/MapVotePanel'
import { useMatch } from '../MatchContext' // aus dem Layout-Context
export default function VoteClient() {

View File

@ -1,5 +1,5 @@
// app/match-details/[matchId]/vote/page.tsx
import Card from '../components/Card'
import Card from '@/app/[locale]/components/Card'
import VoteClient from './VoteClient' // Client-Komponente
export default function VotePage() {

View File

@ -1,31 +0,0 @@
'use client'
import { use, useState } from 'react'
import { notFound } from 'next/navigation'
import Card from '../../components/Card'
import CreateTeamButton from '../../components/CreateTeamButton'
import TeamCardComponent from '../../components/TeamCardComponent'
import AccountSettings from '../../components/settings/AccountSettings'
export default function SettingsTabPage({ params }: { params: Promise<{ tab: string }> }) {
const { tab } = use(params)
const renderTabContent = () => {
switch (tab) {
case 'account':
return (
<Card maxWidth='auto'>
<AccountSettings />
</Card>
)
case 'privacy':
return (
<Card maxWidth='auto' />
)
default:
return notFound()
}
}
return <>{renderTabContent()}</>
}

View File

@ -0,0 +1,25 @@
// app/[locale]/settings/_sections/AccountSection.tsx
import AuthCodeSettings from "../../components/settings/account/AuthCodeSettings";
import LatestKnownCodeSettings from "../../components/settings/account/ShareCodeSettings";
import { useTranslations } from 'next-intl'
export default function AccountSection() {
const tSettings = useTranslations('settings')
return (
<section id="account" className="scroll-mt-16 pb-10">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{tSettings("sections.account.short")}</h2>
<form className="border-t border-gray-200 dark:border-neutral-700">
{/* Auth Code Settings */}
<AuthCodeSettings />
{/* End Auth Code Settings */}
{/* Auth Code Settings */}
<LatestKnownCodeSettings />
{/* End Auth Code Settings */}
</form>
</section>
)
}

View File

@ -0,0 +1,18 @@
// app/[locale]/settings/_sections/AppearanceSection.tsx
import AppearanceSettings from "../../components/settings/account/AppearanceSettings";
import { useTranslations } from 'next-intl'
export default function AppearanceSection() {
const tSettings = useTranslations('settings')
return (
<section id="account" className="scroll-mt-16 pb-10">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{tSettings("sections.appearance.short")}</h2>
<form className="border-t border-gray-200 dark:border-neutral-700">
<AppearanceSettings />
</form>
</section>
)
}

View File

@ -0,0 +1,18 @@
// app/[locale]/settings/_sections/PrivacySection.tsx
import PrivacySettings from "../../components/settings/privacy/PrivacySettings";
import { useTranslations } from 'next-intl'
export default function PrivacySection() {
const tSettings = useTranslations('settings')
return (
<section id="privacy" className="scroll-mt-16 pb-10">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{tSettings("sections.privacy.short")}</h2>
<form className="border-t border-gray-200 dark:border-neutral-700">
<PrivacySettings />
</form>
</section>
)
}

View File

@ -0,0 +1,18 @@
// app/[locale]/settings/_sections/UserSection.tsx
import UserSettings from "../../components/settings/account/UserSettings";
import { useTranslations } from 'next-intl'
export default function UserSection() {
const tSettings = useTranslations('settings')
return (
<section id="account" className="scroll-mt-16 pb-10">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{tSettings("sections.user.short")}</h2>
<form className="border-t border-gray-200 dark:border-neutral-700">
<UserSettings />
</form>
</section>
)
}

View File

@ -1,21 +1,43 @@
import { Tabs } from '../components/Tabs'
import Tab from '../components/Tab'
import { useTranslations, useLocale } from 'next-intl'
'use client'
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
import {useRef} from 'react'
import { useTranslations } from 'next-intl'
import ScrollSpyTabs from '../components/ScrollSpyTabs'
import Card from '../components/Card'
// Übersetzungen
export default function SettingsLayoutSettings({ children }: { children: React.ReactNode }) {
const tSettings = useTranslations('settings')
const mainRef = useRef<HTMLDivElement | null>(null)
const items = [
{ id: 'user', label: tSettings('sections.user.short') },
{ id: 'privacy', label: tSettings('sections.privacy.short') },
{ id: 'account', label: tSettings('sections.account.short') },
{ id: 'appearance', label: tSettings('sections.appearance.short') },
]
return (
<div className="container mx-auto">
<Tabs>
<Tab name={tSettings("tabs.account.short")} href="/settings/account" />
<Tab name={tSettings("tabs.privacy.short")} href="/settings/privacy" />
</Tabs>
<div className="mt-6">
{children}
<Card maxWidth='full'>
<div className="container mx-auto h-[calc(100vh-4rem)] grid gap-6 md:grid-cols-[220px_1fr] min-h-0">
{/* Sidebar */}
<aside className="min-h-0 pr-2">
<div className="sticky top-0 pt-2">
<ScrollSpyTabs
items={items}
containerRef={mainRef} // <- rechter Scroll-Container
className="flex flex-col gap-1"
/>
</div>
</aside>
{/* rechter, durchgehend scrollbarer Bereich */}
<main
ref={mainRef}
className="min-h-0 h-full overflow-y-auto pr-1 md:pr-2 overscroll-contain"
>
{children}
</main>
</div>
</div>
</Card>
)
}

View File

@ -1,5 +1,17 @@
import { redirect } from 'next/navigation'
// app/[locale]/settings/page.tsx
import Card from '../components/Card'
import AccountSection from './_sections/AccountSection'
import AppearanceSection from './_sections/AppearanceSection'
import PrivacySection from './_sections/PrivacySection'
import UserSection from './_sections/UserSection'
export default function SettingPage() {
redirect('/settings/account')
export default function SettingsPage() {
return (
<Card maxWidth='full'>
<UserSection />
<PrivacySection />
<AccountSection />
<AppearanceSection />
</Card>
)
}

View File

@ -26,14 +26,33 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
}
await prisma.$transaction(async (tx) => {
// Tiefste Ebene in der MapVote-Kette
await tx.mapVoteStep.deleteMany({ where: { vote: { matchId } } })
await tx.mapVote.deleteMany({ where: { matchId } })
// Stats/Players (Stats zuerst)
await tx.playerStats.deleteMany({ where: { matchId } })
await tx.matchPlayer.deleteMany({ where: { matchId } })
// Sonstiges, das direkt/indirekt auf Match zeigt
await tx.rankHistory.deleteMany({ where: { matchId } })
await tx.demoFile.deleteMany({ where: { matchId } })
await tx.serverRequest.deleteMany({ where: { matchId } })
await tx.serverRequest.deleteMany({ where: { matchId } }) // hat zwar keinen FK, aber aufräumen ist ok
await tx.schedule.deleteMany({ where: { linkedMatchId: matchId } })
// ✅ Neu: Ready-Entries
await tx.matchReady.deleteMany({ where: { matchId } })
// ✅ Optional: M-N Join-Table Einträge sicher entfernen
await tx.match.update({
where: { id: matchId },
data: {
teamAUsers: { set: [] },
teamBUsers: { set: [] },
},
})
// Jetzt erst das Match selbst
await tx.match.delete({ where: { id: matchId } })
})

View File

@ -0,0 +1,40 @@
// src/app/api/user/privacy/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const user = await prisma.user.findUnique({
where: { steamId: session.user.steamId },
select: { canBeInvited: true },
})
return NextResponse.json({ canBeInvited: user?.canBeInvited ?? true })
}
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const body = await req.json().catch(() => ({}))
const { canBeInvited } = body ?? {}
if (typeof canBeInvited !== 'boolean') {
return NextResponse.json({ message: 'Ungültiger Wert für canBeInvited' }, { status: 400 })
}
await prisma.user.update({
where: { steamId: session.user.steamId },
data: { canBeInvited },
})
return NextResponse.json({ ok: true })
}

File diff suppressed because one or more lines are too long

View File

@ -135,7 +135,8 @@ exports.Prisma.UserScalarFieldEnum = {
status: 'status',
lastActiveAt: 'lastActiveAt',
pterodactylClientApiKey: 'pterodactylClientApiKey',
timeZone: 'timeZone'
timeZone: 'timeZone',
canBeInvited: 'canBeInvited'
};
exports.Prisma.TeamScalarFieldEnum = {

View File

@ -2537,6 +2537,7 @@ export namespace Prisma {
lastActiveAt: Date | null
pterodactylClientApiKey: string | null
timeZone: string | null
canBeInvited: boolean | null
}
export type UserMaxAggregateOutputType = {
@ -2555,6 +2556,7 @@ export namespace Prisma {
lastActiveAt: Date | null
pterodactylClientApiKey: string | null
timeZone: string | null
canBeInvited: boolean | null
}
export type UserCountAggregateOutputType = {
@ -2573,6 +2575,7 @@ export namespace Prisma {
lastActiveAt: number
pterodactylClientApiKey: number
timeZone: number
canBeInvited: number
_all: number
}
@ -2601,6 +2604,7 @@ export namespace Prisma {
lastActiveAt?: true
pterodactylClientApiKey?: true
timeZone?: true
canBeInvited?: true
}
export type UserMaxAggregateInputType = {
@ -2619,6 +2623,7 @@ export namespace Prisma {
lastActiveAt?: true
pterodactylClientApiKey?: true
timeZone?: true
canBeInvited?: true
}
export type UserCountAggregateInputType = {
@ -2637,6 +2642,7 @@ export namespace Prisma {
lastActiveAt?: true
pterodactylClientApiKey?: true
timeZone?: true
canBeInvited?: true
_all?: true
}
@ -2742,6 +2748,7 @@ export namespace Prisma {
lastActiveAt: Date | null
pterodactylClientApiKey: string | null
timeZone: string | null
canBeInvited: boolean
_count: UserCountAggregateOutputType | null
_avg: UserAvgAggregateOutputType | null
_sum: UserSumAggregateOutputType | null
@ -2779,6 +2786,7 @@ export namespace Prisma {
lastActiveAt?: boolean
pterodactylClientApiKey?: boolean
timeZone?: boolean
canBeInvited?: boolean
team?: boolean | User$teamArgs<ExtArgs>
ledTeam?: boolean | User$ledTeamArgs<ExtArgs>
matchesAsTeamA?: boolean | User$matchesAsTeamAArgs<ExtArgs>
@ -2812,6 +2820,7 @@ export namespace Prisma {
lastActiveAt?: boolean
pterodactylClientApiKey?: boolean
timeZone?: boolean
canBeInvited?: boolean
team?: boolean | User$teamArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@ -2831,6 +2840,7 @@ export namespace Prisma {
lastActiveAt?: boolean
pterodactylClientApiKey?: boolean
timeZone?: boolean
canBeInvited?: boolean
team?: boolean | User$teamArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@ -2850,9 +2860,10 @@ export namespace Prisma {
lastActiveAt?: boolean
pterodactylClientApiKey?: boolean
timeZone?: boolean
canBeInvited?: boolean
}
export type UserOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"steamId" | "name" | "avatar" | "location" | "isAdmin" | "teamId" | "premierRank" | "authCode" | "lastKnownShareCode" | "lastKnownShareCodeDate" | "createdAt" | "status" | "lastActiveAt" | "pterodactylClientApiKey" | "timeZone", ExtArgs["result"]["user"]>
export type UserOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"steamId" | "name" | "avatar" | "location" | "isAdmin" | "teamId" | "premierRank" | "authCode" | "lastKnownShareCode" | "lastKnownShareCodeDate" | "createdAt" | "status" | "lastActiveAt" | "pterodactylClientApiKey" | "timeZone" | "canBeInvited", ExtArgs["result"]["user"]>
export type UserInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
team?: boolean | User$teamArgs<ExtArgs>
ledTeam?: boolean | User$ledTeamArgs<ExtArgs>
@ -2911,6 +2922,7 @@ export namespace Prisma {
lastActiveAt: Date | null
pterodactylClientApiKey: string | null
timeZone: string | null
canBeInvited: boolean
}, ExtArgs["result"]["user"]>
composites: {}
}
@ -3363,6 +3375,7 @@ export namespace Prisma {
readonly lastActiveAt: FieldRef<"User", 'DateTime'>
readonly pterodactylClientApiKey: FieldRef<"User", 'String'>
readonly timeZone: FieldRef<"User", 'String'>
readonly canBeInvited: FieldRef<"User", 'Boolean'>
}
@ -20978,7 +20991,8 @@ export namespace Prisma {
status: 'status',
lastActiveAt: 'lastActiveAt',
pterodactylClientApiKey: 'pterodactylClientApiKey',
timeZone: 'timeZone'
timeZone: 'timeZone',
canBeInvited: 'canBeInvited'
};
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
@ -21413,6 +21427,7 @@ export namespace Prisma {
lastActiveAt?: DateTimeNullableFilter<"User"> | Date | string | null
pterodactylClientApiKey?: StringNullableFilter<"User"> | string | null
timeZone?: StringNullableFilter<"User"> | string | null
canBeInvited?: BoolFilter<"User"> | boolean
team?: XOR<TeamNullableScalarRelationFilter, TeamWhereInput> | null
ledTeam?: XOR<TeamNullableScalarRelationFilter, TeamWhereInput> | null
matchesAsTeamA?: MatchListRelationFilter
@ -21445,6 +21460,7 @@ export namespace Prisma {
lastActiveAt?: SortOrderInput | SortOrder
pterodactylClientApiKey?: SortOrderInput | SortOrder
timeZone?: SortOrderInput | SortOrder
canBeInvited?: SortOrder
team?: TeamOrderByWithRelationInput
ledTeam?: TeamOrderByWithRelationInput
matchesAsTeamA?: MatchOrderByRelationAggregateInput
@ -21480,6 +21496,7 @@ export namespace Prisma {
lastActiveAt?: DateTimeNullableFilter<"User"> | Date | string | null
pterodactylClientApiKey?: StringNullableFilter<"User"> | string | null
timeZone?: StringNullableFilter<"User"> | string | null
canBeInvited?: BoolFilter<"User"> | boolean
team?: XOR<TeamNullableScalarRelationFilter, TeamWhereInput> | null
ledTeam?: XOR<TeamNullableScalarRelationFilter, TeamWhereInput> | null
matchesAsTeamA?: MatchListRelationFilter
@ -21512,6 +21529,7 @@ export namespace Prisma {
lastActiveAt?: SortOrderInput | SortOrder
pterodactylClientApiKey?: SortOrderInput | SortOrder
timeZone?: SortOrderInput | SortOrder
canBeInvited?: SortOrder
_count?: UserCountOrderByAggregateInput
_avg?: UserAvgOrderByAggregateInput
_max?: UserMaxOrderByAggregateInput
@ -21538,6 +21556,7 @@ export namespace Prisma {
lastActiveAt?: DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null
pterodactylClientApiKey?: StringNullableWithAggregatesFilter<"User"> | string | null
timeZone?: StringNullableWithAggregatesFilter<"User"> | string | null
canBeInvited?: BoolWithAggregatesFilter<"User"> | boolean
}
export type TeamWhereInput = {
@ -22802,6 +22821,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -22834,6 +22854,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -22864,6 +22885,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -22896,6 +22918,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -22927,6 +22950,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
}
export type UserUpdateManyMutationInput = {
@ -22944,6 +22968,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
}
export type UserUncheckedUpdateManyInput = {
@ -22962,6 +22987,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
}
export type TeamCreateInput = {
@ -24510,6 +24536,7 @@ export namespace Prisma {
lastActiveAt?: SortOrder
pterodactylClientApiKey?: SortOrder
timeZone?: SortOrder
canBeInvited?: SortOrder
}
export type UserAvgOrderByAggregateInput = {
@ -24532,6 +24559,7 @@ export namespace Prisma {
lastActiveAt?: SortOrder
pterodactylClientApiKey?: SortOrder
timeZone?: SortOrder
canBeInvited?: SortOrder
}
export type UserMinOrderByAggregateInput = {
@ -24550,6 +24578,7 @@ export namespace Prisma {
lastActiveAt?: SortOrder
pterodactylClientApiKey?: SortOrder
timeZone?: SortOrder
canBeInvited?: SortOrder
}
export type UserSumOrderByAggregateInput = {
@ -28589,6 +28618,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchCreateNestedManyWithoutTeamBUsersInput
@ -28620,6 +28650,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
invites?: TeamInviteUncheckedCreateNestedManyWithoutUserInput
@ -28654,6 +28685,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchCreateNestedManyWithoutTeamBUsersInput
@ -28684,6 +28716,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -29039,6 +29072,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUpdateManyWithoutTeamBUsersNestedInput
@ -29070,6 +29104,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
invites?: TeamInviteUncheckedUpdateManyWithoutUserNestedInput
@ -29119,6 +29154,7 @@ export namespace Prisma {
lastActiveAt?: DateTimeNullableFilter<"User"> | Date | string | null
pterodactylClientApiKey?: StringNullableFilter<"User"> | string | null
timeZone?: StringNullableFilter<"User"> | string | null
canBeInvited?: BoolFilter<"User"> | boolean
}
export type TeamInviteUpsertWithWhereUniqueWithoutTeamInput = {
@ -29248,6 +29284,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -29279,6 +29316,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -29363,6 +29401,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -29394,6 +29433,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -29468,6 +29508,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -29499,6 +29540,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -29544,6 +29586,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -29575,6 +29618,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -29682,6 +29726,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamB?: MatchCreateNestedManyWithoutTeamBUsersInput
@ -29713,6 +29758,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
invites?: TeamInviteUncheckedCreateNestedManyWithoutUserInput
@ -29747,6 +29793,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -29778,6 +29825,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
invites?: TeamInviteUncheckedCreateNestedManyWithoutUserInput
@ -30368,6 +30416,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -30399,6 +30448,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -30631,6 +30681,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -30662,6 +30713,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -30814,6 +30866,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -30845,6 +30898,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -30957,6 +31011,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -30988,6 +31043,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -31168,6 +31224,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -31199,6 +31256,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -31233,6 +31291,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -31264,6 +31323,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -31466,6 +31526,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -31497,6 +31558,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -31537,6 +31599,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -31568,6 +31631,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -31737,6 +31801,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -31768,6 +31833,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -31886,6 +31952,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -31917,6 +31984,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -31946,6 +32014,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -31977,6 +32046,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -32022,6 +32092,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -32053,6 +32124,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -32307,6 +32379,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -32338,6 +32411,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -32463,6 +32537,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -32494,6 +32569,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -32631,6 +32707,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
team?: TeamCreateNestedOneWithoutMembersInput
ledTeam?: TeamCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchCreateNestedManyWithoutTeamAUsersInput
@ -32662,6 +32739,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
ledTeam?: TeamUncheckedCreateNestedOneWithoutLeaderInput
matchesAsTeamA?: MatchUncheckedCreateNestedManyWithoutTeamAUsersInput
matchesAsTeamB?: MatchUncheckedCreateNestedManyWithoutTeamBUsersInput
@ -32780,6 +32858,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -32811,6 +32890,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -33410,6 +33490,7 @@ export namespace Prisma {
lastActiveAt?: Date | string | null
pterodactylClientApiKey?: string | null
timeZone?: string | null
canBeInvited?: boolean
}
export type TeamInviteCreateManyTeamInput = {
@ -33527,6 +33608,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUpdateManyWithoutTeamBUsersNestedInput
@ -33557,6 +33639,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
@ -33587,6 +33670,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
}
export type TeamInviteUpdateWithoutTeamInput = {
@ -33960,6 +34044,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamB?: MatchUpdateManyWithoutTeamBUsersNestedInput
@ -33991,6 +34076,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamB?: MatchUncheckedUpdateManyWithoutTeamBUsersNestedInput
invites?: TeamInviteUncheckedUpdateManyWithoutUserNestedInput
@ -34021,6 +34107,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
}
export type UserUpdateWithoutMatchesAsTeamBInput = {
@ -34038,6 +34125,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
team?: TeamUpdateOneWithoutMembersNestedInput
ledTeam?: TeamUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUpdateManyWithoutTeamAUsersNestedInput
@ -34069,6 +34157,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
ledTeam?: TeamUncheckedUpdateOneWithoutLeaderNestedInput
matchesAsTeamA?: MatchUncheckedUpdateManyWithoutTeamAUsersNestedInput
invites?: TeamInviteUncheckedUpdateManyWithoutUserNestedInput
@ -34099,6 +34188,7 @@ export namespace Prisma {
lastActiveAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pterodactylClientApiKey?: NullableStringFieldUpdateOperationsInput | string | null
timeZone?: NullableStringFieldUpdateOperationsInput | string | null
canBeInvited?: BoolFieldUpdateOperationsInput | boolean
}
export type MatchPlayerUpdateWithoutMatchInput = {

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,47 @@
// /src/lib/auth.ts
import type { NextAuthOptions } from 'next-auth'
import { NextRequest } from 'next/server'
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 {
return false
}
}
// sehr kleiner Fallback-Guesser aus Ländercode -> TZ (optional)
function guessTzFromCountry(cc?: string | null): string | null {
if (!cc) return null
const C = cc.toUpperCase()
if (['DE','AT','CH'].includes(C)) return 'Europe/Berlin'
if (C === 'GB' || C === 'UK') return 'Europe/London'
if (C === 'FR') return 'Europe/Paris'
if (C === 'ES') return 'Europe/Madrid'
if (C === 'IT') return 'Europe/Rome'
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
return null
}
export const authOptions = (req: NextRequest): NextAuthOptions => ({
secret: process.env.NEXTAUTH_SECRET,
providers: [
@ -19,42 +55,68 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
const steamProfile = profile as SteamProfile
const location = steamProfile.loccountrycode ?? null
await prisma.user.upsert({
const existing = await prisma.user.findUnique({
where: { steamId: steamProfile.steamid },
update: {
name: steamProfile.personaname,
avatar: steamProfile.avatarfull,
...(location && { location }),
},
create: {
steamId: steamProfile.steamid,
name: steamProfile.personaname,
avatar: steamProfile.avatarfull,
location: steamProfile.loccountrycode,
isAdmin: false,
timeZone:
...(location && { location }),
},
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)
if (!existing) {
await prisma.user.create({
data: {
steamId: steamProfile.steamid,
name: steamProfile.personaname,
avatar: steamProfile.avatarfull,
location: location ?? undefined,
isAdmin: false,
timeZone: guessedTz ?? null,
},
})
} else {
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 }
: {}),
},
})
}
token.steamId = steamProfile.steamid
token.name = steamProfile.personaname
token.image = steamProfile.avatarfull
}
// DB laden (inkl. timeZone), Team & Admin setzen
const userInDb = await prisma.user.findUnique({
where: { steamId: token.steamId || token.sub || '' },
select: { teamId: true, isAdmin: true, steamId: true, timeZone: true },
})
if (userInDb) {
token.team = userInDb.teamId ?? null
if (userInDb.steamId === '76561198000414190') {
token.isAdmin = true
} else {
token.isAdmin = userInDb.isAdmin ?? false
}
token.isAdmin =
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
return token
},
@ -68,6 +130,8 @@ 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,
}
return session
},
@ -76,15 +140,8 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`);
const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`);
if (isSignOut) {
return `${baseUrl}/`; // Nach Logout auf Startseite
}
// Standard-Redirect nach Login
if (isSignIn || url === baseUrl) {
return `${baseUrl}/dashboard`; // z.B. Dashboard als Startpunkt
}
if (isSignOut) return `${baseUrl}/`;
if (isSignIn || url === baseUrl) return `${baseUrl}/dashboard`;
return url.startsWith(baseUrl) ? url : baseUrl;
}
},

View File

@ -5,9 +5,12 @@
"saved": "Gespeichert",
"save-failed": "Speichern fehlgeschlagen",
"no-changes": "Keine Änderungen",
"loading": "Lädt",
"here": "hier",
"disconnect": "Trennen",
"reset": "Zurücksetzen",
"back": "Zurück",
"edit": "Bearbeiten",
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
@ -28,7 +31,7 @@
"overview": "Überblick",
"stats": "Statistiken"
},
"schedule": "Spielpan"
"schedule": "Spielplan"
},
"dashboard": {
"title": "Willkommen!"
@ -49,7 +52,25 @@
}
},
"settings": {
"tabs": {
"sections": {
"user": {
"title": "Benutzereinstellungen",
"short": "Benutzer",
"description": "Verwalte deine Datenschutzeinstellungen.",
"timezone-label": "Zeitzone",
"timezone-help": "Wird für Datums-/Zeitangaben in der App genutzt.",
"timezone-hint": "Wähle deine IANA-Zeitzone (z. B. Europe/Berlin).",
"timezone-system": "Systemstandard verwenden"
},
"privacy": {
"title": "Datenschutzeinstellungen",
"short": "Datenschutz",
"description": "Verwalte deine Datenschutzeinstellungen.",
"invites": {
"label": "Einladungen erlauben",
"help": "Erlaube anderen, dich in ihre Teams einzuladen."
}
},
"account": {
"title": "Accounteinstellungen",
"short": "Account",
@ -79,18 +100,12 @@
"light": "Hell",
"dark": "Dunkel"
}
},
"UserSettings": {
"timezone-label": "Zeitzone",
"timezone-help": "Wird für Datums-/Zeitangaben in der App genutzt.",
"timezone-hint": "Wähle deine IANA-Zeitzone (z. B. Europe/Berlin).",
"timezone-system": "Systemstandard verwenden"
}
}
},
"privacy": {
"title": "Datenschutzeinstellungen",
"short": "Datenschutz",
"appearance": {
"title": "Darstellungseinstellungen",
"short": "Darstellung",
"description": "Verwalte deine Datenschutzeinstellungen."
}
}
@ -107,7 +122,10 @@
"description": "Keine Matches geplant.",
"filter": "Nur meine Matches anzeigen",
"create-match": "Match erstellen",
"day": "Tag",
"day": "Tag"
},
"mapvote": {
"open": "offen",
"opens-in": "öffnet in"
},
"notifications": {

View File

@ -5,9 +5,12 @@
"saved": "Saved",
"save-failed": "Save failed",
"no-changes": "No changes",
"loading": "Loading",
"here": "here",
"disconnect": "Disconnect",
"reset": "Reset",
"back": "Back",
"edit": "Edit",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
@ -49,49 +52,61 @@
}
},
"settings": {
"tabs": {
"sections": {
"user": {
"title": "User Settings",
"short": "User",
"description": "Manage your privacy settings.",
"timezone-label": "Time Zone",
"timezone-help": "Used for date/time display in the app.",
"timezone-hint": "Choose your IANA time zone (e.g., Europe/Berlin).",
"timezone-system": "Use system default"
},
"privacy": {
"title": "Privacy Settings",
"short": "Privacy",
"description": "Manage your privacy settings.",
"invites": {
"label": "Allow invitations",
"help": "Let others invite you to their teams."
}
},
"account": {
"title": "Account Settings",
"short": "Account",
"description": "Manage your account settings and connected services.",
"find-code": "You can find your code",
"url": "https://help.steampowered.com/en/wizard/HelpWithGameIssue/?appid=730&issueid=128",
"url": "https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128",
"page": {
"AuthCodeSettings": {
"name": "Authentication Code",
"question": "What is the Authentication Code?",
"description": "Third-party websites and applications can use this authentication code to access your match history, your overall performance in those matches, download replays of your matches, and analyze your gameplay.",
"invalid": "Invalid Authentication Code"
"question": "What is the authentication code?",
"description": "Third-party websites and applications can use this authentication code to access your match history, your overall performance in those matches, download replays, and analyze your gameplay.",
"invalid": "Invalid authentication code"
},
"ShareCodeSettings": {
"name": "Match Share Code",
"question": "What is the Match Share Code?",
"description": "With the share code, applications can find and analyze your most recent official match.",
"invalid-share-code": "Invalid Share Code! You can find your new Share Code"
"name": "Share Code",
"question": "What is the share code?",
"description": "With the share code, applications can find and analyze your most recently played official match.",
"invalid-share-code": "Expired share code! You can find your new share code"
},
"AppearanceSettings": {
"name": "Appearance",
"description": "Choose your preferred theme. You can use a fixed style or follow your system setting.",
"description": "Choose your preferred theme. You can use a fixed style or follow the system setting.",
"theme-mode": "Theme Mode",
"theme-mode-description": "If you choose “System”, the appearance will automatically match your device.",
"theme-mode-description": "If “System” is selected, the appearance automatically follows your device.",
"theme": {
"system": "System",
"light": "Light",
"dark": "Dark"
}
},
"UserSettings": {
"timezone-label": "Time zone",
"timezone-help": "Used for date/time formatting in the app.",
"timezone-hint": "Choose your IANA time zone (e.g., Europe/Berlin).",
"timezone-system": "Use system default"
}
}
},
"privacy": {
"title": "Privacy Settings",
"short": "Privacy",
"description": "Manage your privacy preferences."
"appearance": {
"title": "Appearance Settings",
"short": "Appearance",
"description": "Manage your privacy settings."
}
}
},
@ -107,7 +122,10 @@
"description": "No matches scheduled.",
"filter": "Show my matches only",
"create-match": "Create Match",
"day": "Day",
"day": "Day"
},
"mapvote": {
"open": "open",
"opens-in": "opens in"
},
"notifications": {

View File

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