update
This commit is contained in:
parent
f27c6feedb
commit
530425a82c
@ -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 |
@ -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],
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
105
src/app/[locale]/components/ScrollSpyTabs.tsx
Normal file
105
src/app/[locale]/components/ScrollSpyTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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")}
|
||||
{tSettings("sections.account.find-code")}
|
||||
<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>
|
||||
|
||||
@ -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")}
|
||||
{tSettings("sections.account.find-code")}
|
||||
<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")}
|
||||
{tSettings("sections.account.page.ShareCodeSettings.invalid-share-code")}
|
||||
<Link
|
||||
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
|
||||
target="_blank"
|
||||
|
||||
@ -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>
|
||||
|
||||
136
src/app/[locale]/components/settings/privacy/PrivacySettings.tsx
Normal file
136
src/app/[locale]/components/settings/privacy/PrivacySettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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()}</>
|
||||
}
|
||||
25
src/app/[locale]/settings/_sections/AccountSection.tsx
Normal file
25
src/app/[locale]/settings/_sections/AccountSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
src/app/[locale]/settings/_sections/AppearanceSection.tsx
Normal file
18
src/app/[locale]/settings/_sections/AppearanceSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
src/app/[locale]/settings/_sections/PrivacySection.tsx
Normal file
18
src/app/[locale]/settings/_sections/PrivacySection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
src/app/[locale]/settings/_sections/UserSection.tsx
Normal file
18
src/app/[locale]/settings/_sections/UserSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 } })
|
||||
})
|
||||
|
||||
|
||||
40
src/app/api/user/privacy/route.ts
Normal file
40
src/app/api/user/privacy/route.ts
Normal 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
@ -135,7 +135,8 @@ exports.Prisma.UserScalarFieldEnum = {
|
||||
status: 'status',
|
||||
lastActiveAt: 'lastActiveAt',
|
||||
pterodactylClientApiKey: 'pterodactylClientApiKey',
|
||||
timeZone: 'timeZone'
|
||||
timeZone: 'timeZone',
|
||||
canBeInvited: 'canBeInvited'
|
||||
};
|
||||
|
||||
exports.Prisma.TeamScalarFieldEnum = {
|
||||
|
||||
94
src/generated/prisma/index.d.ts
vendored
94
src/generated/prisma/index.d.ts
vendored
@ -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
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-8cab75712245f682edd2dce1ca5952c94f39f37ac5ea8dd517ddd6b716af1837",
|
||||
"name": "prisma-client-fc3f88586732483ac25ea246b89655ccf69ac35e1c6d1e7c4e4311101fe5713a",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "default.js",
|
||||
|
||||
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp28872
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp28872
Normal file
Binary file not shown.
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp3888
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp3888
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
117
src/lib/auth.ts
117
src/lib/auth.ts
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
3
src/types/next-auth.d.ts
vendored
3
src/types/next-auth.d.ts
vendored
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user