This commit is contained in:
Linrador 2025-09-23 23:29:21 +02:00
parent bb7ac51509
commit 51ae2c80a1
36 changed files with 723 additions and 531 deletions

View File

@ -1,47 +0,0 @@
{
"nav": {
"dashboard": "Dashboard",
"teams": {
"label": "Teams",
"overview": "Übersicht",
"manage": "Teamverwaltung"
},
"players": {
"label": "Spieler",
"overview": "Übersicht",
"stats": "Statistiken"
},
"schedule": "Spielplan"
},
"dashboard": {
"title": "Willkommen im Dashboard!"
},
"sidebar": {
"brand": "Iron:e",
"language": {
"de": "Deutsch",
"en": "Englisch"
},
"footer": {
"login": "Mit Steam anmelden",
"profile": "Profil",
"team": "Team",
"settings": "Einstellungen",
"administration": "Administration",
"signout": "Abmelden"
}
},
"game-banner": {
"disconnected": "Verbindung getrennt",
"player-connected": "Spieler verbunden",
"open-game": "Spiel öffnen",
"quit": "Verlassen",
"reconnect": "Neu verbinden"
},
"matches": {
"title": "Geplante Matches",
"description": "Keine Matches geplant.",
"filter": "Nur mein Team anzeigen",
"create-match": "Neues Match erstellen"
}
}

View File

@ -1,47 +0,0 @@
{
"nav": {
"dashboard": "Dashboard",
"teams": {
"label": "Teams",
"overview": "Overview",
"manage": "Team Management"
},
"players": {
"label": "Players",
"overview": "Overview",
"stats": "Statistics"
},
"schedule": "Schedule"
},
"dashboard": {
"title": "Welcome!"
},
"sidebar": {
"brand": "Iron:e",
"language": {
"de": "German",
"en": "English"
},
"footer": {
"login": "Login with Steam",
"profile": "Profile",
"team": "Team",
"settings": "Settings",
"administration": "Administration",
"signout": "Sign out"
}
},
"game-banner": {
"disconnected": "Disconnected",
"player-connected": "Players connected",
"open-game": "Open game",
"quit": "Quit",
"reconnect": "Reconnect"
},
"matches": {
"title": "Scheduled matches",
"description": "No matches scheduled.",
"filter": "Show my team only",
"create-match": "Create new match"
}
}

View File

@ -1,9 +1,9 @@
'use client'
import { notFound, usePathname } from 'next/navigation'
import Card from '../components/Card'
import MatchesAdminManager from '../components/admin/MatchesAdminManager'
import AdminTeamsView from '../components/admin/teams/AdminTeamsView'
import Card from '../../components/Card'
import MatchesAdminManager from '../../components/admin/MatchesAdminManager'
import AdminTeamsView from '../../components/admin/teams/AdminTeamsView'
export default function AdminPage() {
const pathname = usePathname()

View File

@ -5,8 +5,8 @@ import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
import Card from '../components/Card'
import ServerView from '../components/admin/server/ServerView'
import Card from '../../components/Card'
import ServerView from '../../components/admin/server/ServerView'
export const dynamic = 'force-dynamic'

View File

@ -3,11 +3,11 @@
import { useCallback, useEffect, useState, useRef } from 'react'
import { useSession } from 'next-auth/react'
import LoadingSpinner from '../components/LoadingSpinner'
import TeamMemberView from '../components/TeamMemberView'
import LoadingSpinner from '../../../components/LoadingSpinner'
import TeamMemberView from '../../../components/TeamMemberView'
import { useTeamStore } from '@/lib/stores'
import { reloadTeam } from '@/lib/sse-actions'
import type { Player } from '../types/team'
import type { Player } from '@/types/team'
type Props = { teamId: string }

View File

@ -2,8 +2,8 @@
'use client'
import Card from '../components/Card'
import AdminTeamsView from '../components/admin/teams/AdminTeamsView'
import Card from '../../components/Card'
import AdminTeamsView from '../../components/admin/teams/AdminTeamsView'
export default function AdminTeamsPage() {
return (

View File

@ -13,8 +13,8 @@ type Props = {
invitationId?: string
onUpdateInvitation: (teamId: string, newValue: string | null | 'pending') => void
adminMode?: boolean
/** Vom Page-Container gesetzt: ob der Nutzer grundsätzlich Beitritte anfragen darf
* (false, wenn /api/user ein team liefert). Default: true (abwärtskompatibel). */
/** (historisch) Ob Join-Anfragen grundsätzlich erlaubt sind.
* Mehrere Teams sind jetzt erlaubt diese Prop wird nicht mehr zum Sperren verwendet. */
canRequestJoin?: boolean
}
@ -24,7 +24,7 @@ export default function TeamCard({
invitationId,
onUpdateInvitation,
adminMode = false,
canRequestJoin = true,
canRequestJoin = true, // bleibt für Abwärtskompatibilität erhalten, hat aber keinen Einfluss mehr auf disabled
}: Props) {
const router = useRouter()
const [joining, setJoining] = useState(false)
@ -35,15 +35,15 @@ export default function TeamCard({
const isMemberOfThisTeam = useMemo(() => {
const inActive = (team.activePlayers ?? []).some(p => String(p.steamId) === String(currentUserSteamId))
const inInactive = (team.inactivePlayers ?? []).some(p => String(p.steamId) === String(currentUserSteamId))
const isLeader = team.leader?.steamId && String(team.leader.steamId) === String(currentUserSteamId)
// robust: leader?.steamId ODER leaderId unterstützen
const leaderSteamId = team.leader?.steamId ?? (team as any).leaderId
const isLeader = leaderSteamId && String(leaderSteamId) === String(currentUserSteamId)
return Boolean(inActive || inInactive || isLeader)
}, [team, currentUserSteamId])
// Button sperren, wenn:
// - gerade Request läuft
// - bereits Mitglied dieses Teams
// - global keine Join-Anfragen erlaubt (User hat bereits ein Team)
const isDisabled = joining || isMemberOfThisTeam || !canRequestJoin
// ❗Mehrere Teams erlaubt → NICHT mehr wegen "hat schon Team" blocken
// Gesperrt nur, wenn bereits Mitglied DIESES Teams oder Request läuft
const isDisabled = joining || isMemberOfThisTeam
const handleClick = async () => {
if (joining || isDisabled) return
@ -85,8 +85,8 @@ export default function TeamCard({
Lädt
</>
)
: (!canRequestJoin || isMemberOfThisTeam)
? 'Beitritt nicht möglich'
: isMemberOfThisTeam
? 'Schon Mitglied'
: isRequested
? 'Angefragt (zurückziehen)'
: 'Beitritt anfragen'
@ -150,16 +150,33 @@ export default function TeamCard({
</div>
<div className="flex -space-x-3">
{[...team.activePlayers, ...team.inactivePlayers].map(p => (
<img
key={p.steamId}
src={p.avatar}
alt={p.name}
title={p.name}
className="w-8 h-8 rounded-full border-2 border-white
dark:border-neutral-800 object-cover"
/>
))}
{(() => {
const seen = new Set<string>();
const members: any[] = [];
const pushUnique = (p?: any) => {
if (!p || !p.steamId || seen.has(p.steamId)) return;
seen.add(p.steamId);
members.push(p);
};
// 1) Leader (falls vorhanden) zuerst
if (team.leader) pushUnique(team.leader);
// 2) aktive & inaktive
(team.activePlayers ?? []).forEach(pushUnique);
(team.inactivePlayers ?? []).forEach(pushUnique);
return members.map(p => (
<img
key={p.steamId}
src={p.avatar}
alt={p.name}
title={p.name}
className="w-8 h-8 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
/>
));
})()}
</div>
</div>
)

View File

@ -1,8 +1,7 @@
// /src/app/components/TeamCardComponent.tsx
'use client'
import { forwardRef, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, forwardRef } from 'react'
import { useSession } from 'next-auth/react'
import TeamInvitationBanner from './TeamInvitationBanner'
@ -13,58 +12,44 @@ import CreateTeamButton from './CreateTeamButton'
import type { Player, Team } from '../../../types/team'
import type { Invitation } from '../../../types/invitation'
import { useSSEStore } from '@/lib/useSSEStore'
import {
INVITE_EVENTS,
TEAM_EVENTS,
SELF_EVENTS,
isSseEventType,
} from '@/lib/sseEvents'
import { INVITE_EVENTS, TEAM_EVENTS, SELF_EVENTS, isSseEventType } from '@/lib/sseEvents'
type Props = {
refetchKey?: string
/** vom Server geliefert (Page) */
initialTeams: Team[]
initialInvitationMap: Record<string, string>
/** optional, falls du Banner o.ä. daraus nimmst */
initialInvites?: Invitation[]
}
/** flache, stabile Equality-Checks */
function eqPlayers(a: Player[] = [], b: Player[] = []) {
/* ---------- kleine Helper ---------- */
const sortPlayers = (ps: Player[] = []) => [...ps].sort((a, b) => a.steamId.localeCompare(b.steamId))
const eqPlayers = (a: Player[] = [], b: Player[] = []) => {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
return true
}
function sameLeader(a?: {steamId?: string} | null, b?: {steamId?: string} | null) {
const eqTeam = (a?: Team | null, b?: Team | null) => {
if (!a && !b) return true
if (!a || !b) return false
return a.steamId === b.steamId
if (a.id !== b.id) return false
if ((a.name ?? '') !== (b.name ?? '')) return false
if ((a.logo ?? '') !== (b.logo ?? '')) return false
const la = a.leader?.steamId ?? (a as any).leaderId ?? null
const lb = b.leader?.steamId ?? (b as any).leaderId ?? null
if (la !== lb) return false
return (
eqPlayers(sortPlayers(a.activePlayers), sortPlayers(b.activePlayers)) &&
eqPlayers(sortPlayers(a.inactivePlayers), sortPlayers(b.inactivePlayers))
)
}
function eqTeam(a: Team | null, b: Team | null) {
if (!a && !b) return true
if (!a || !b) return false
const leaderA = a.leader?.steamId ?? null
const leaderB = b.leader?.steamId ?? null
if (a.id !== b.id || a.name !== b.name || a.logo !== b.logo || leaderA !== leaderB) {
return false
}
const sort = (arr: Player[] = []) => [...arr].sort((x, y) => x.steamId.localeCompare(y.steamId))
return eqPlayers(sort(a.activePlayers), sort(b.activePlayers)) &&
eqPlayers(sort(a.inactivePlayers), sort(b.inactivePlayers))
}
function eqInvites(a: Invitation[] = [], b: Invitation[] = []) {
const eqInviteList = (a: Invitation[] = [], b: Invitation[] = []) => {
if (a.length !== b.length) return false
const A = a.map(x => x.id).sort().join(',')
const B = b.map(x => x.id).sort().join(',')
const A = a.map((x) => x.id).sort().join(',')
const B = b.map((x) => x.id).sort().join(',')
return A === B
}
/* ---------- Komponente ---------- */
function TeamCardComponent(
{ initialTeams, initialInvitationMap, initialInvites = [] }: Props,
_ref: any
@ -73,89 +58,86 @@ function TeamCardComponent(
const steamId = session?.user?.steamId ?? ''
const { lastEvent } = useSSEStore()
// Ladezustand nur für "habe ich ein Team?" wir versuchen zuerst lokal über /api/teams (einmalig via Server)
const [initialLoading, setInitialLoading] = useState(false)
const [initialLoading, setInitialLoading] = useState(true)
// Team des Users (falls vorhanden). Das bekommst du NICHT aus initialTeams raus
// wir laden es on-demand, wenn nötig (einmalig), oder du reichst es auch serverseitig rein.
const [team, setTeam] = useState<Team | null>(null)
// Alle Teams, in denen ich Mitglied bin (Leader/aktiv/inaktiv)
const [myTeams, setMyTeams] = useState<Team[]>([])
// Für die Inline-Detailansicht bei mehreren Teams
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null)
// Pending Team-Einladungen (nur relevant, wenn man in KEINEM Team ist)
// Einladungen (nur relevant, wenn ich in KEINEM Team bin)
const [pendingInvitations, setPendingInvitations] = useState<Invitation[]>(
initialInvites.filter(i => i.type === 'team-invite' && i.team) as any
)
// lokale States (unverändert)
// Drag/Modals für TeamMemberView
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
// Refs
const currentTeamIdRef = useRef<string | null>(null)
const lastReloadAt = useRef<number>(0)
const lastInviteCheck = useRef<number>(0)
// Optional: EINMAL prüfen, ob ich bereits ein Team habe per leichten Call /api/teams (liefert "team" oder nicht)
// Wenn du wirklich GAR KEINEN Client-Initial-Call willst, kannst du das auch serverseitig ermitteln und per Prop reichen.
useEffect(() => {
let ignore = false
const checkOwnTeam = async () => {
try {
setInitialLoading(true)
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) throw new Error('failed /api/user')
const data = await res.json()
if (ignore) return
if (data.team) {
setTeam(prev => (eqTeam(prev, data.team) ? prev : data.team))
currentTeamIdRef.current = data.team.id
if (pendingInvitations.length) setPendingInvitations([])
} else {
currentTeamIdRef.current = null
}
} catch (e) {
console.error('[TeamCardComponent] checkOwnTeam error:', e)
} finally {
if (!ignore) setInitialLoading(false)
}
}
checkOwnTeam()
return () => { ignore = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/* ------- User+Teams laden (einmalig) ------- */
const loadUserTeams = async () => {
try {
setInitialLoading(true)
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) throw new Error('failed /api/user')
const data = await res.json()
// SSE-Reaktionen bei Änderungen nachladen (soft)
const fetchData = async () => {
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
})
// Auto-Auswahl
if (teams.length === 1) {
setSelectedTeam(teams[0])
} else {
if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) {
setSelectedTeam(null)
}
}
// Einladungen leeren, wenn ich mind. ein Team habe
if (teams.length > 0 && pendingInvitations.length) {
setPendingInvitations([])
}
} finally {
setInitialLoading(false)
}
}
useEffect(() => { loadUserTeams() }, []) // eslint-disable-line react-hooks/exhaustive-deps
/* ------- SSE-gestützte Soft-Reloads ------- */
const softReload = async () => {
try {
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json()
const userTeam = data?.team ?? null
if (userTeam) {
setTeam(prev => (eqTeam(prev, userTeam) ? prev : userTeam))
currentTeamIdRef.current = userTeam.id
if (pendingInvitations.length) setPendingInvitations([])
} else {
currentTeamIdRef.current = null
// (optional) Invites nachladen wie gehabt …
if (team !== null) setTeam(null)
// Nur sporadisch Invites neu ziehen (wenn man kein Team hat)
if (Date.now() - lastInviteCheck.current > 1500) {
lastInviteCheck.current = Date.now()
const inviteRes = await fetch('/api/user/invitations', { cache: 'no-store' })
if (inviteRes.ok) {
const inviteData = await inviteRes.json()
const all: Invitation[] = (inviteData.invitations ?? [])
.filter((i: any) => i.type === 'team-invite' && i.team)
.map((i: any) => ({ id: i.id, team: i.team }))
setPendingInvitations(prev => (eqInvites(prev, all) ? prev : all))
}
const teams: Team[] = Array.isArray(data?.teams) ? data.teams : []
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
if (teams.length === 1) setSelectedTeam(teams[0])
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' })
if (inv.ok) {
const json = await inv.json()
const all: Invitation[] = (json.invitations ?? [])
.filter((i: any) => i.type === 'team-invite' && i.team)
.map((i: any) => ({ id: i.id, team: i.team }))
setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all))
}
if (team !== null) setTeam(null)
}
} catch (e) {
console.error('[TeamCardComponent] fetchData error:', e)
}
} catch { /* noop */ }
}
useEffect(() => {
@ -164,7 +146,8 @@ function TeamCardComponent(
const { type, payload } = lastEvent
if (SELF_EVENTS.has(type)) { fetchData(); return }
if (SELF_EVENTS.has(type)) { softReload(); return }
if (type === 'team-invite-revoked') {
const revokedId = payload?.invitationId as string | undefined
const revokedTeamId = payload?.teamId as string | undefined
@ -176,57 +159,50 @@ function TeamCardComponent(
)
)
}
fetchData()
softReload()
return
}
if (TEAM_EVENTS.has(type)) { fetchData(); return }
if (INVITE_EVENTS.has(type) && !currentTeamIdRef.current) { fetchData(); 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, pendingInvitations])
}, [lastEvent, myTeams.length, selectedTeam, pendingInvitations])
/* ------- Render-Zweige ------- */
if (initialLoading) return <LoadingSpinner />
// 1) Kein Team, aber Pending-Einladungen -> Banner + NoTeamView (mit initialen Teams & Mapping)
if (!team && pendingInvitations.length > 0) {
// (A) Ich habe gar kein Team → ggf. Banner + NoTeamView
if (myTeams.length === 0) {
return (
<>
<div className="space-y-4">
{pendingInvitations.map(inv => (
<TeamInvitationBanner
key={inv.id}
invitation={inv}
notificationId={inv.id}
onMarkAsRead={async () => {}}
onAction={async (action) => {
try {
await fetch(`/api/user/invitations/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId: inv.id, teamId: inv.team.id }),
})
} catch (e) {
console.error('Invite respond fehlgeschlagen:', e)
} finally {
// lokal entfernen + soft reload
setPendingInvitations(list => list.filter(x => x.id !== inv.id))
await fetchData()
}
}}
/>
))}
</div>
<NoTeamView
initialTeams={initialTeams}
initialInvitationMap={initialInvitationMap}
/>
</>
)
}
{pendingInvitations.length > 0 && (
<div className="space-y-4 mb-4">
{pendingInvitations.map(inv => (
<TeamInvitationBanner
key={inv.id}
invitation={inv}
notificationId={inv.id}
onMarkAsRead={async () => {}}
onAction={async (action) => {
try {
await fetch(`/api/user/invitations/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId: inv.id, teamId: inv.team.id }),
})
} catch (e) {
console.error('Invite respond fehlgeschlagen:', e)
} finally {
setPendingInvitations(list => list.filter(x => x.id !== inv.id))
await softReload()
}
}}
/>
))}
</div>
)}
// 2) Kein Team & keine Einladung → reine Teamliste (aus Server-Props)
if (!team) {
return (
<>
<NoTeamView
initialTeams={initialTeams}
initialInvitationMap={initialInvitationMap}
@ -238,13 +214,130 @@ function TeamCardComponent(
)
}
// 3) Team vorhanden
// (B) Genau 1 Team → direkt TeamMemberView
if (myTeams.length === 1 && selectedTeam) {
return (
<div>
<div className="mb-4 xl:mb-8">
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
Teameinstellungen
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-500">
Verwalte dein Team und lade Mitglieder ein
</p>
</div>
<form>
<TeamMemberView
team={selectedTeam}
currentUserSteamId={steamId}
adminMode={false}
activeDragItem={activeDragItem}
setActiveDragItem={setActiveDragItem}
isDragging={isDragging}
setIsDragging={setIsDragging}
showLeaveModal={showLeaveModal}
setShowLeaveModal={setShowLeaveModal}
showInviteModal={showInviteModal}
setShowInviteModal={setShowInviteModal}
/>
</form>
</div>
)
}
// (C) Mehrere Teams: Liste meiner Teams oder Detailansicht
if (!selectedTeam) {
return (
<div className="space-y-6">
<div className="mb-1">
<h2 className="text-lg font-semibold text-gray-900 dark:text-neutral-100">
Deine Teams
</h2>
<p className="text-sm text-gray-500 dark:text-neutral-400">
Wähle ein Team, um die Mitglieder zu verwalten.
</p>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{myTeams.map(team => (
<button
key={team.id}
type="button"
onClick={() => setSelectedTeam(team)}
className="
text-left p-4 border rounded-lg bg-white dark:bg-neutral-800
dark:border-neutral-700 shadow-sm hover:shadow-md transition
hover:scale-105 hover:bg-neutral-200 hover:dark:bg-neutral-700
focus:outline-none focus:ring-2 focus:ring-blue-500
"
>
<div className="flex items-center justify-between gap-3 mb-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
border-gray-200 dark:border-neutral-600"
/>
<div className="flex flex-col">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
{team.name ?? 'Team'}
</span>
<span className="text-xs text-gray-500 dark:text-neutral-400">
{(team.activePlayers?.length ?? 0) + (team.inactivePlayers?.length ?? 0)} Mitglieder
</span>
</div>
</div>
{/* Chevron */}
<svg className="w-4 h-4 text-gray-400" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 6l6 6l-6 6V6z" />
</svg>
</div>
<div className="flex -space-x-3">
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].slice(0, 6).map(p => (
<img
key={p.steamId}
src={p.avatar}
alt={p.name}
title={p.name}
className="w-8 h-8 rounded-full border-2 border-white
dark:border-neutral-800 object-cover"
/>
))}
</div>
</button>
))}
</div>
</div>
)
}
// selectedTeam gesetzt → Detailansicht mit "Zurück" oben links
return (
<div>
<div className="mb-4 xl:mb-8">
<div className="flex items-center gap-3 mb-2">
<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"
aria-label="Zurück zur Teamübersicht"
>
<svg viewBox="0 0 24 24" className="w-4 h-4" aria-hidden>
<path fill="currentColor" d="M15 18l-6-6l6-6v12z" />
</svg>
<span>Zurück</span>
</button>
</div>
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
Teameinstellungen
Teamverwaltung
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-500">
Verwalte dein Team und lade Mitglieder ein
</p>
@ -252,7 +345,7 @@ function TeamCardComponent(
<form>
<TeamMemberView
team={team}
team={selectedTeam}
currentUserSteamId={steamId}
adminMode={false}
activeDragItem={activeDragItem}

View File

@ -3,7 +3,7 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { useSession } from 'next-auth/react'
import Button from '../../Button'
import Modal from '../../Modal'
import Input from '../../Input'

View File

@ -8,8 +8,8 @@ import StaticEffects from './StaticEffects';
import RadarHeader from './RadarHeader';
import RadarCanvas from './RadarCanvas';
import { useAvatarDirectoryStore } from '../../lib/useAvatarDirectoryStore';
import { useTelemetryStore } from '../../lib/useTelemetryStore';
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore';
import { useTelemetryStore } from '@/lib/useTelemetryStore';
import { useBombBeep } from './hooks/useBombBeep';
import { useOverview } from './hooks/useOverview';

View File

@ -1,7 +1,7 @@
// /src/app/radar/TeamSidebar.tsx
'use client'
import React, { useEffect, useState } from 'react'
import { useAvatarDirectoryStore } from '../../lib/useAvatarDirectoryStore'
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'
export type Team = 'T' | 'CT'

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { BombState } from '@/lib/types';
import { BombState } from '../lib/types';
const BOMB_FUSE_MS = 40_000;

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Mapper, Overview } from '@/lib/types';
import { defaultWorldToPx, parseOverviewJson, parseValveKvOverview } from '@/lib/helpers';
import { Mapper, Overview } from '../lib/types';
import { defaultWorldToPx, parseOverviewJson, parseValveKvOverview } from '../lib/helpers';
export function useOverview(activeMapKey: string | null, playersForAutoFit: {x:number;y:number}[]) {
const [overview, setOverview] = useState<Overview | null>(null);

View File

@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '@/lib/types';
import { UI } from '@/lib/ui';
import { asNum, mapTeam, steamIdOf } from '@/lib/helpers';
import { normalizeGrenades } from '@/lib/grenades';
import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '../lib/types';
import { UI } from '../lib/ui';
import { asNum, mapTeam, steamIdOf } from '../lib/helpers';
import { normalizeGrenades } from '../lib/grenades';
export function useRadarState(mySteamId: string | null) {
// WS / Map

View File

@ -4,18 +4,23 @@ import DeleteAccountSettings from "./account/DeleteAccountSettings"
import AppearanceSettings from "./account/AppearanceSettings"
import AuthCodeSettings from "./account/AuthCodeSettings"
import LatestKnownCodeSettings from "./account/ShareCodeSettings"
import { useTranslations, useLocale } from 'next-intl'
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">
Accounteinstellungen
{tSettings("tabs.account.title")}
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-500">
Passe das Erscheinungsbild der Webseite an
{tSettings("tabs.account.description")}
</p>
</div>
{/* End Title */}

View File

@ -2,10 +2,14 @@
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { useTranslations, useLocale } from 'next-intl'
export default function AppearanceSettings() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Übersetzungen
const tSettings = useTranslations('settings')
useEffect(() => {
setMounted(true)
@ -24,7 +28,7 @@ export default function AppearanceSettings() {
<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">
Darstellung
{tSettings("tabs.account.page.AppearanceSettings.name")}
</label>
</div>

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import Popover from '../../Popover'
import Button from '../../Button'
import { useTranslations, useLocale } from 'next-intl'
export default function AuthCodeSettings() {
const [authCode, setAuthCode] = useState('')
@ -11,6 +12,9 @@ export default function AuthCodeSettings() {
const [touched, setTouched] = useState(false)
const [manuallySet, setManuallySet] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// Übersetzungen
const tSettings = useTranslations('settings')
const showInput = !isLoading && (!authCode || manuallySet)
@ -100,20 +104,20 @@ export default function AuthCodeSettings() {
<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">
Authentifizierungscode
{tSettings("tabs.account.page.AuthCodeSettings.name")}
</label>
<div className="mt-1">
<Popover text="Was ist der Authentifizierungscode?" size="xl">
<Popover text={tSettings("tabs.account.page.AuthCodeSettings.question")} size="xl">
<div className="space-y-3">
<i><q>Websites und Anwendungen von Drittanbietern haben mit diesem Authentifizierungscode Zugriff auf deinen Spielverlauf und können dein Gameplay analysieren.</q></i>
<i><q>{tSettings("tabs.account.page.AuthCodeSettings.description")}</q></i>
<p>
Deinen Code findest du&nbsp;
{tSettings("tabs.account.find-code")}&nbsp;
<Link
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
href={tSettings("tabs.account.url")}
target="_blank"
className="text-blue-600 underline hover:text-blue-800"
>
hier
{tSettings("tabs.account.here")}
</Link>.
</p>
</div>
@ -154,7 +158,7 @@ export default function AuthCodeSettings() {
</>
) : (
<Button color="red" variant="ghost" onClick={handleDisconnect}>
Verbindung trennen
{tSettings("tabs.account.page.AuthCodeSettings.button-disconnect")}
</Button>
)}
</div>

View File

@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import Popover from '../../Popover'
import Button from '../../Button'
import { useTranslations, useLocale } from 'next-intl'
export default function LatestKnownCodeSettings() {
const [lastKnownShareCode, setLastKnownShareCode] = useState('')
@ -14,6 +15,9 @@ export default function LatestKnownCodeSettings() {
const [isSaved, setIsSaved] = useState(false)
const [touched, setTouched] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// Übersetzungen
const tSettings = useTranslations('settings')
const shareCodeExpired = useMemo(() => {
if (!lastKnownShareCodeDate) return false
@ -125,20 +129,20 @@ export default function LatestKnownCodeSettings() {
<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">
Austauschcode für dein letztes Spiel
{tSettings("tabs.account.page.ShareCodeSettings.name")}
</label>
<div className="mt-1">
<Popover text="Was ist der Austauschcode?" size="xl">
<Popover text={tSettings("tabs.account.page.ShareCodeSettings.question")} size="xl">
<div className="space-y-3">
<i><q>Mit dem Austauschcode können Anwendungen dein letztes offizielles Match finden und analysieren.</q></i>
<i><q>{tSettings("tabs.account.page.ShareCodeSettings.description")}</q></i>
<p>
Du findest deinen Code&nbsp;
{tSettings("tabs.account.find-code")}&nbsp;
<Link
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
href={tSettings("tabs.account.url")}
target="_blank"
className="text-blue-600 underline hover:text-blue-800"
>
hier
{tSettings("tabs.account.here")}
</Link>.
</p>
</div>

View File

@ -1,7 +1,9 @@
// /src/app/[locale]/layout.tsx
import type {Metadata} from 'next';
import {Geist, Geist_Mono} from 'next/font/google';
import {NextIntlClientProvider, hasLocale} from 'next-intl';
import {NextIntlClientProvider} from 'next-intl';
import {getLocale, getMessages} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {notFound} from 'next/navigation';
import {routing} from '@/i18n/routing';
@ -25,15 +27,6 @@ export const metadata: Metadata = {
description: 'Steam Auth Dashboard'
};
async function getMessages(locale: string) {
try {
const messages = (await import(`/messages/${locale}.json`)).default;
return messages;
} catch {
return null;
}
}
type Props = {
children: React.ReactNode;
params: Promise<{locale: string}>;
@ -41,40 +34,32 @@ type Props = {
export default async function RootLayout({children, params}: Props) {
const {locale} = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
const messages = await getMessages(locale);
if (!messages) notFound();
if (!hasLocale(routing.locales, locale)) notFound();
// ⬇️ holt die von request.ts gemergten Namespaces
const messages = await getMessages();
const lang = await getLocale();
return (
<html lang={locale} suppressHydrationWarning>
<html lang={lang} suppressHydrationWarning>
<body className={`antialiased bg-white dark:bg-black min-h-dvh ${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<NextIntlClientProvider locale={locale} messages={messages}>
<NextIntlClientProvider locale={lang} messages={messages}>
<Providers>
<SSEHandler />
<UserActivityTracker />
<AudioPrimer />
<ReadyOverlayHost />
<TelemetrySocket />
{/* App-Shell: Sidebar | Main */}
<div className="min-h-dvh grid grid-cols-1 sm:grid-cols-[16rem_1fr]">
<Sidebar />
{/* rechte Spalte */}
<div className="min-w-0 flex flex-col">
<main className="flex-1 in-w-0 overflow-hidden">
<div className="h-full box-border p-4 sm:p-6">
{children}
</div>
<div className="h-full box-border p-4 sm:p-6">{children}</div>
</main>
<div id="telemetry-banner-dock" className="h-full max-h-[65px]" />
</div>
</div>
<NotificationBell />
</Providers>
</NextIntlClientProvider>

View File

@ -1,12 +1,17 @@
import { Tabs } from '../components/Tabs'
import Tab from '../components/Tab'
import { useTranslations, useLocale } from 'next-intl'
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
// Übersetzungen
const tSettings = useTranslations('settings')
return (
<div className="container mx-auto">
<Tabs>
<Tab name="Account" href="/settings/account" />
<Tab name="Datenschutz" href="/settings/privacy" />
<Tab name={tSettings("tabs.account.short")} href="/settings/account" />
<Tab name={tSettings("tabs.privacy.short")} href="/settings/privacy" />
</Tabs>
<div className="mt-6">
{children}

View File

@ -2,7 +2,7 @@
'use server'
import { NextResponse } from 'next/server'
import { prisma } from '../../lib/prisma'
import { prisma } from '@/lib/prisma'
// Helper: Prisma-User -> Player
const toPlayer = (u: any) => ({

View File

@ -1,60 +1,49 @@
// /src/app/api/team/available-users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const teamId = searchParams.get('teamId') ?? undefined
const teamId = searchParams.get('teamId')
if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
}
// 1) Nur Pending-Invites DIESES Teams laden (damit wir doppelte Einladungen dieses Teams vermeiden)
// 1) Dieses Team laden, um bestehende Mitglieder zu kennen
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { activePlayers: true, inactivePlayers: true }
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
const membersOfThisTeam = new Set([
...team.activePlayers,
...team.inactivePlayers
])
// 2) Pending-Invites DIESES Teams
const pendingInvites = await prisma.teamInvite.findMany({
where: teamId ? { teamId } : undefined,
include: {
user: {
select: {
steamId : true,
name : true,
avatar : true,
location : true,
premierRank: true,
team : true,
},
},
},
where: { teamId },
select: { steamId: true }
})
const invitedByThisTeam = new Set(pendingInvites.map(i => i.steamId))
// 2) Nur die von DIESEM Team bereits eingeladenen Steam-IDs
const invitedByThisTeam = new Set(
pendingInvites.map(inv => inv.user?.steamId).filter(Boolean) as string[]
)
// 3) (Optional/robust) Mitglieder aller Teams sammeln doppelt gemoppelt zu where: { team: null },
// aber falls du später das where lockerst, bleibt es sicher.
const teams = await prisma.team.findMany({
select: { activePlayers: true, inactivePlayers: true },
})
const teamMemberIds = new Set(
teams.flatMap(t => [...t.activePlayers, ...t.inactivePlayers])
)
// 4) Nur Nutzer ohne Team laden
// 3) Alle User (oder mit Suche filtern, wenn du willst)
const allUsers = await prisma.user.findMany({
where: { team: null }, // hat noch kein Team
select: {
steamId : true,
name : true,
avatar : true,
location : true,
premierRank: true,
premierRank: true
},
orderBy: { name: 'asc' },
orderBy: { name: 'asc' }
})
// 5) Verfügbar = kein Mitglied + NICHT bereits von DIESEM Team eingeladen
// 4) Verfügbar = NICHT schon Mitglied DIESES Teams + NICHT von DIESEM Team eingeladen
const availableUsers = allUsers.filter(u =>
!teamMemberIds.has(u.steamId) &&
!invitedByThisTeam.has(u.steamId)
!membersOfThisTeam.has(u.steamId) && !invitedByThisTeam.has(u.steamId)
)
return NextResponse.json({ users: availableUsers })

View File

@ -8,7 +8,6 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const { teamId, newLeaderSteamId } = await req.json()
if (!teamId || !newLeaderSteamId) {
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
}
@ -17,28 +16,36 @@ export async function POST(req: NextRequest) {
where: { id: teamId },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
if (!team) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
// ❗ Bereits Leader eines anderen Teams?
const otherLedTeam = await prisma.team.findFirst({
where: { leaderId: newLeaderSteamId, NOT: { id: teamId } },
select: { id: true, name: true }
})
if (otherLedTeam) {
return NextResponse.json(
{ message: `Dieser Spieler ist bereits Leader von "${otherLedTeam.name}".` },
{ status: 400 }
)
}
const allPlayerIds = Array.from(new Set([
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
team.leaderId, // alter Leader (kann null sein)
team.leaderId,
].filter(Boolean) as string[]))
// Neuer Leader muss Mitglied sein
if (!allPlayerIds.includes(newLeaderSteamId)) {
return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 })
}
// Leader setzen
await prisma.team.update({
where: { id: teamId },
data : { leaderId: newLeaderSteamId },
})
// Namen neuer Leader
// --- Benachrichtigung & SSE unverändert ---
const newLeader = await prisma.user.findUnique({
where : { steamId: newLeaderSteamId },
select: { name: true },
@ -47,7 +54,6 @@ export async function POST(req: NextRequest) {
const textForOthers =
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
// 1) Notification an neuen Leader (sichtbar + live)
const leaderNote = await prisma.notification.create({
data: {
steamId : newLeaderSteamId,
@ -58,47 +64,37 @@ export async function POST(req: NextRequest) {
},
})
await sendServerSSEMessage({
type : 'notification',
type: 'notification',
targetUserIds: [newLeaderSteamId],
message : leaderNote.message,
id : leaderNote.id,
actionType : leaderNote.actionType ?? undefined,
actionData : leaderNote.actionData ?? undefined,
createdAt : leaderNote.createdAt.toISOString(),
message: leaderNote.message,
id: leaderNote.id,
actionType: leaderNote.actionType ?? undefined,
actionData: leaderNote.actionData ?? undefined,
createdAt: leaderNote.createdAt.toISOString(),
})
// 2) Info an alle anderen (sichtbar + live)
const others = allPlayerIds.filter(id => id !== newLeaderSteamId)
if (others.length) {
const notes = await Promise.all(
others.map(steamId =>
prisma.notification.create({
data: {
steamId,
title: 'Neuer Teamleader',
message: textForOthers,
actionType: 'team-leader-changed',
actionData: newLeaderSteamId,
},
})
)
)
await Promise.all(
notes.map(n =>
sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
actionData: n.actionData ?? undefined,
createdAt: n.createdAt.toISOString(),
})
)
)
// zusätzliches Team-Event (für SSEHandler → soft reload)
const notes = await Promise.all(others.map(steamId =>
prisma.notification.create({
data: {
steamId,
title: 'Neuer Teamleader',
message: textForOthers,
actionType: 'team-leader-changed',
actionData: newLeaderSteamId,
},
})
))
await Promise.all(notes.map(n => sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
actionData: n.actionData ?? undefined,
createdAt: n.createdAt.toISOString(),
})))
await sendServerSSEMessage({
type: 'team-leader-changed',
targetUserIds: others,
@ -108,18 +104,20 @@ export async function POST(req: NextRequest) {
})
}
// 3) Zielgerichtetes “team-updated” an ALLE (inkl. neuem Leader)
const reloadTargets = Array.from(new Set([...allPlayerIds, newLeaderSteamId]))
if (reloadTargets.length) {
await sendServerSSEMessage({
type: 'team-updated',
targetUserIds: reloadTargets,
teamId,
})
await sendServerSSEMessage({ type: 'team-updated', targetUserIds: reloadTargets, teamId })
}
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
} catch (error) {
} catch (error: any) {
// Falls du zusätzlich im Prisma-Schema @@unique([leaderId]) gesetzt hast:
if (error?.code === 'P2002' && error?.meta?.target?.includes('leaderId')) {
return NextResponse.json(
{ message: 'Dieser Spieler ist bereits Leader eines anderen Teams.' },
{ status: 400 }
)
}
console.error('Fehler beim Leaderwechsel:', error)
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
}

View File

@ -3,52 +3,85 @@ import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import type { Player } from '../../../types/team'
export const dynamic = 'force-dynamic' // optional: Caching aus
// export const revalidate = 0
export const dynamic = 'force-dynamic'
export async function GET() {
try {
const teams = await prisma.team.findMany({
select: { id: true, name: true, logo: true, leaderId: true, createdAt: true,
activePlayers: true, inactivePlayers: true },
select: {
id: true,
name: true,
logo: true,
leaderId: true,
createdAt: true,
activePlayers: true,
inactivePlayers: true
},
orderBy: { name: 'asc' }
})
// Alle benötigten SteamIDs sammeln (aktive, inaktive, Leader)
const uniqueIds = new Set<string>()
teams.forEach(t => {
for (const t of teams) {
t.activePlayers.forEach(id => uniqueIds.add(id))
t.inactivePlayers.forEach(id => uniqueIds.add(id))
})
if (t.leaderId) uniqueIds.add(t.leaderId)
}
// Nutzer-Daten für alle IDs holen
const users = await prisma.user.findMany({
where: { steamId: { in: [...uniqueIds] } },
select: { steamId: true, name: true, avatar: true, location: true, premierRank: true },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true
}
})
// Lookup-Map aufbauen
const byId: Record<string, Player> = {}
const DEFAULT_AVATAR = '/assets/img/avatars/default.png'
const UNKNOWN_NAME = 'Unbekannt'
users.forEach(u => {
for (const u of users) {
byId[u.steamId] = {
steamId: u.steamId,
name: u.name ?? UNKNOWN_NAME,
avatar: u.avatar ?? DEFAULT_AVATAR,
location: u.location ?? '',
premierRank: u.premierRank ?? 0,
premierRank: u.premierRank ?? 0
}
}
// Ergebnis formen Leader als komplettes Player-Objekt mitsenden
const result = teams.map(t => {
const leaderPlayer: Player | undefined =
t.leaderId
? (byId[t.leaderId] ?? {
steamId: t.leaderId,
name: UNKNOWN_NAME,
avatar: DEFAULT_AVATAR,
location: '',
premierRank: 0
})
: undefined
return {
id: t.id,
name: t.name,
logo: t.logo,
createdAt: t.createdAt,
leaderId: t.leaderId,
leader: leaderPlayer, // ⬅️ voll befüllt
activePlayers: t.activePlayers.map(id => byId[id]).filter(Boolean) as Player[],
inactivePlayers: t.inactivePlayers.map(id => byId[id]).filter(Boolean) as Player[]
}
})
const result = teams.map(t => ({
id: t.id,
name: t.name,
logo: t.logo,
leaderId: t.leaderId,
createdAt: t.createdAt,
activePlayers: t.activePlayers .map(id => byId[id]).filter(Boolean) as Player[],
inactivePlayers:t.inactivePlayers.map(id => byId[id]).filter(Boolean) as Player[],
}))
return NextResponse.json(
{ items: result, hasMore: false },
{ teams: result, hasMore: false },
{ headers: { 'Cache-Control': 'no-store' } }
)
} catch (err) {

View File

@ -1,95 +1,137 @@
// /src/app/api/user/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { NextResponse, type NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic'; // kein Static Caching
type SlimPlayer = {
steamId: string;
name: string;
avatar: string;
location?: string | null;
premierRank?: number | null;
};
// Hilfstyp für Session, damit TS weiß, dass es user?.steamId gibt
type SessionShape = { user?: { steamId?: string } } | null;
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
// ⚠️ Typen fixen: getServerSession korrekt casten
const session = (await getServerSession(authOptions(req) as any)) as SessionShape;
const steamId = session?.user?.steamId;
if (!steamId) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 });
}
// 1) User + Team (nur skalare Felder + Leader-Relation laden)
const userRaw = await prisma.user.findUnique({
// 1) Basisdaten des Users
const me = await prisma.user.findUnique({
where: { steamId },
select: {
name: true,
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
isAdmin: true,
status: true,
team: {
select: {
id: true,
name: true,
logo: true,
leaderId: true,
leader: {
select: {
steamId: true,
name: true,
avatar: true,
},
},
activePlayers: true,
inactivePlayers: true,
},
},
},
})
});
if (!userRaw) {
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 })
if (!me) {
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 });
}
// 2) Falls Team vorhanden: active/inactive IDs in User-Objekte auflösen
let teamResolved: any = null
if (userRaw.team) {
const activeIds = userRaw.team.activePlayers ?? []
const inactiveIds = userRaw.team.inactivePlayers ?? []
// 2) Alle Teams laden, in denen der User Leader/aktiv/inaktiv ist
const teamsRaw = await prisma.team.findMany({
where: {
OR: [
{ leaderId: steamId },
{ activePlayers: { has: steamId } },
{ inactivePlayers: { has: steamId } },
],
},
select: {
id: true,
name: true,
logo: true,
leaderId: true,
createdAt: true,
activePlayers: true,
inactivePlayers: true,
},
});
// findMany mit leeren Arrays ist ok und liefert []
const [activeUsers, inactiveUsers] = await Promise.all([
prisma.user.findMany({
where: { steamId: { in: activeIds } },
select: { steamId: true, name: true, avatar: true, premierRank: true },
}),
prisma.user.findMany({
where: { steamId: { in: inactiveIds } },
select: { steamId: true, name: true, avatar: true, premierRank: true },
}),
// 3) Einmalig alle benötigten Steam-IDs sammeln und als User ziehen
const ids = new Set<string>();
for (const t of teamsRaw) {
t.activePlayers.forEach(ids.add, ids);
t.inactivePlayers.forEach(ids.add, ids);
if (t.leaderId) ids.add(t.leaderId);
}
const users = ids.size
? await prisma.user.findMany({
where: { steamId: { in: [...ids] } },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
},
})
: [];
const DEFAULT_AVATAR = '/assets/img/avatars/default.png';
const UNKNOWN_NAME = 'Unbekannt';
const byId: Record<string, SlimPlayer> = Object.fromEntries(
users.map((u) => [
u.steamId,
{
steamId: u.steamId,
name: u.name ?? UNKNOWN_NAME,
avatar: u.avatar ?? DEFAULT_AVATAR,
location: u.location ?? null,
premierRank: u.premierRank ?? 0,
},
])
);
// Optional: Reihenfolge gemäß IDs beibehalten
const byId = (ids: string[], users: any[]) => {
const map = new Map(users.map((u) => [u.steamId, u]))
return ids.map((id) => map.get(id)).filter(Boolean)
}
const mapIds = (arr: string[]) =>
arr.map((id) => byId[id]).filter(Boolean) as SlimPlayer[];
teamResolved = {
id: userRaw.team.id,
name: userRaw.team.name,
logo: userRaw.team.logo,
leader: userRaw.team.leader ?? null,
activePlayers: byId(activeIds, activeUsers),
inactivePlayers: byId(inactiveIds, inactiveUsers),
}
}
// 4) Teams auflösen (Leader + aktive/inaktive Spieler)
const teams = teamsRaw.map((t) => ({
id: t.id,
name: t.name,
logo: t.logo,
createdAt: t.createdAt,
leader: t.leaderId
? byId[t.leaderId] ?? { steamId: t.leaderId, name: UNKNOWN_NAME, avatar: DEFAULT_AVATAR }
: null,
activePlayers: mapIds(t.activePlayers),
inactivePlayers: mapIds(t.inactivePlayers),
}));
// 3) Antwort formen (Team ersetzt durch aufgelöste Struktur)
const response = {
name: userRaw.name,
steamId: userRaw.steamId,
avatar: userRaw.avatar,
premierRank: userRaw.premierRank,
isAdmin: userRaw.isAdmin,
status: userRaw.status ?? 'offline',
team: teamResolved,
}
const teamIds = teams.map((t) => t.id);
return NextResponse.json(response, { headers: { 'Cache-Control': 'no-store' } })
// 5) Antwort (ohne Abwärtskompatibilität: KEIN `team`-Feld mehr)
return NextResponse.json(
{
steamId: me.steamId,
name: me.name,
avatar: me.avatar ?? DEFAULT_AVATAR,
location: me.location ?? null,
premierRank: me.premierRank ?? 0,
isAdmin: me.isAdmin,
status: me.status ?? 'offline',
teams, // vollständige Liste
teamIds, // praktische ID-Liste
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}

View File

@ -1,18 +1,42 @@
// /src/i18n/request.ts
import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './routing';
const namespaces = [
'common',
'nav',
'sidebar',
'settings',
'teams',
'matches',
'dashboard'
] as const;
type Namespace = (typeof namespaces)[number];
async function tryImport<T>(p: string): Promise<T | null> {
try { const mod = await import(p as any); return (mod as any).default as T; }
catch { return null; }
}
async function loadMessages(locale: string) {
const entries = await Promise.all(
namespaces.map(async (ns) => {
// ⬇️ neue Struktur: /messages/<namespace>/<locale>.json
const obj = await tryImport<Record<string, unknown>>(
`../messages/${ns}/${locale}.json`
);
return [ns, obj ?? {}] as const;
})
);
const merged = {} as Record<Namespace, Record<string, unknown>>;
for (const [ns, obj] of entries) merged[ns] = obj;
return merged;
}
export default getRequestConfig(async ({requestLocale}) => {
// Typically corresponds to the `[locale]` segment
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
};
});
const locale = hasLocale(routing.locales, requested) ? (requested as string) : routing.defaultLocale;
return { locale, messages: await loadMessages(locale) };
});

View File

@ -3,7 +3,7 @@ import path from 'path';
import { Match, User } from '@/generated/prisma';
import { parseAndStoreDemo } from './parseAndStoreDemo';
import { log } from '../../scripts/cs2-cron-runner.js';
import { prisma } from '../app/lib/prisma.js';
import { prisma } from '@/lib/prisma';
export async function runDownloaderForUser(user: User): Promise<{
newMatches: Match[];

View File

@ -0,0 +1,3 @@
{
"title": "Willkommen!"
}

View File

@ -0,0 +1,3 @@
{
"title": "Welcome!"
}

14
src/messages/nav/de.json Normal file
View File

@ -0,0 +1,14 @@
{
"dashboard": "Dashboard",
"teams": {
"label": "Teams",
"overview": "Übersicht",
"manage": "Teamverwaltung"
},
"players": {
"label": "Spieler",
"overview": "Übersicht",
"stats": "Statistiken"
},
"schedule": "Spielplan"
}

14
src/messages/nav/en.json Normal file
View File

@ -0,0 +1,14 @@
{
"dashboard": "Dashboard",
"teams": {
"label": "Teams",
"overview": "Übersicht",
"manage": "Teamverwaltung"
},
"players": {
"label": "Spieler",
"overview": "Übersicht",
"stats": "Statistiken"
},
"schedule": "Spielplan"
}

View File

@ -0,0 +1,20 @@
{
"account": {
"title": "Kontoeinstellungen",
"appearance": {
"name": "Darstellung",
"description": "Wähle dein bevorzugtes Design. Du kannst einen festen Stil verwenden oder das Systemverhalten übernehmen."
},
"authCode": {
"name": "Authentifizierungscode",
"question": "Was ist der Authentifizierungscode?",
"description": "Drittanbieter-Webseiten und Apps können mit diesem Code auf deine Match-Historie zugreifen..."
},
"shareCode": {
"name": "Match-Share-Code",
"question": "Was ist der Match-Share-Code?",
"findCodeRich": "Du findest deinen Code <link>hier</link>.",
"helpUrl": "https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
}
}
}

View File

@ -0,0 +1,20 @@
{
"account": {
"title": "Account Settings",
"appearance": {
"name": "Appearance",
"description": "Choose your preferred theme. You can use a fixed style or follow your system setting."
},
"authCode": {
"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..."
},
"shareCode": {
"name": "Match Share Code",
"question": "What is the Match Share Code?",
"findCodeRich": "You can find your code <link>here</link>.",
"helpUrl": "https://help.steampowered.com/en/wizard/HelpWithGameIssue/?appid=730&issueid=128"
}
}
}

View File

@ -0,0 +1,7 @@
{
"brand": "Iron:e",
"language": {
"de": "Deutsch",
"en": "Englisch"
}
}

View File

@ -0,0 +1,7 @@
{
"brand": "Iron:e",
"language": {
"de": "German",
"en": "English"
}
}

View File

@ -1,15 +1,13 @@
// /src/middleware.ts
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import createIntlMiddleware from 'next-intl/middleware';
import {getToken} from 'next-auth/jwt';
import {routing} from './i18n/routing';
// 1) i18n-Middleware vorbereiten
const handleI18n = createIntlMiddleware(routing);
// Kleine Helfer
// Helpers
function getCurrentLocaleFromPath(pathname: string, locales: readonly string[], fallback: string) {
const first = pathname.split('/')[1];
return locales.includes(first) ? first : fallback;
@ -30,19 +28,17 @@ function isProtectedPath(pathnameNoLocale: string) {
pathnameNoLocale.startsWith('/dashboard') ||
pathnameNoLocale.startsWith('/settings') ||
pathnameNoLocale.startsWith('/matches') ||
pathnameNoLocale.startsWith('/team') || // ← hinzugefügt
pathnameNoLocale.startsWith('/admin')
);
}
export default async function middleware(req: NextRequest) {
// 2) Erst i18n arbeiten lassen (Locale auflösen, ggf. redirect/rewrite)
// 2) Erst i18n arbeiten lassen
const i18nRes = handleI18n(req);
// Falls i18n gerade einen Redirect/Rewrite veranlasst, das Ergebnis direkt zurückgeben.
// (Erkennbar an Location-Header (redirect) oder x-middleware-rewrite (rewrite))
const isRedirect = i18nRes.headers.get('location') != null;
const isRewrite = i18nRes.headers.get('x-middleware-rewrite') != null;
if (isRedirect || isRewrite) {
// Wenn i18n bereits redirect/rewrite auslöst, direkt zurückgeben
if (i18nRes.headers.get('location') || i18nRes.headers.get('x-middleware-rewrite')) {
return i18nRes;
}
@ -54,7 +50,6 @@ export default async function middleware(req: NextRequest) {
// 3) Nur für geschützte Pfade Auth prüfen
if (!isProtectedPath(pathnameNoLocale)) {
// Nichts weiter zu tun → i18n-Result weiterreichen
return i18nRes;
}
@ -62,7 +57,7 @@ export default async function middleware(req: NextRequest) {
// Adminschutz
if (pathnameNoLocale.startsWith('/admin')) {
if (!token || !token.isAdmin) {
if (!token || !(token as any).isAdmin) {
const redirectUrl = url.clone();
redirectUrl.pathname = `/${currentLocale}/dashboard`;
return NextResponse.redirect(redirectUrl);
@ -72,15 +67,15 @@ export default async function middleware(req: NextRequest) {
// Allgemeiner Auth-Schutz
if (!token) {
const loginUrl = new URL('/api/auth/signin', req.url);
// Callback auf die aktuelle URL (inkl. Locale) setzen
loginUrl.searchParams.set('callbackUrl', url.toString());
loginUrl.searchParams.set('callbackUrl', url.toString()); // komplette Ziel-URL inkl. Locale
return NextResponse.redirect(loginUrl);
}
// Alles gut → weiter mit i18n-Result (entspricht NextResponse.next())
// Alles gut → weiter
return i18nRes;
}
export const config = {
// Standard: alles außer /api, _next, statische Dateien
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
};