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' 'use client'
import { notFound, usePathname } from 'next/navigation' import { notFound, usePathname } from 'next/navigation'
import Card from '../components/Card' import Card from '../../components/Card'
import MatchesAdminManager from '../components/admin/MatchesAdminManager' import MatchesAdminManager from '../../components/admin/MatchesAdminManager'
import AdminTeamsView from '../components/admin/teams/AdminTeamsView' import AdminTeamsView from '../../components/admin/teams/AdminTeamsView'
export default function AdminPage() { export default function AdminPage() {
const pathname = usePathname() const pathname = usePathname()

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,7 @@
// /src/app/components/TeamCardComponent.tsx // /src/app/components/TeamCardComponent.tsx
'use client' 'use client'
import { forwardRef, useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, forwardRef } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import TeamInvitationBanner from './TeamInvitationBanner' import TeamInvitationBanner from './TeamInvitationBanner'
@ -13,58 +12,44 @@ import CreateTeamButton from './CreateTeamButton'
import type { Player, Team } from '../../../types/team' import type { Player, Team } from '../../../types/team'
import type { Invitation } from '../../../types/invitation' import type { Invitation } from '../../../types/invitation'
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { import { INVITE_EVENTS, TEAM_EVENTS, SELF_EVENTS, isSseEventType } from '@/lib/sseEvents'
INVITE_EVENTS,
TEAM_EVENTS,
SELF_EVENTS,
isSseEventType,
} from '@/lib/sseEvents'
type Props = { type Props = {
refetchKey?: string refetchKey?: string
/** vom Server geliefert (Page) */
initialTeams: Team[] initialTeams: Team[]
initialInvitationMap: Record<string, string> initialInvitationMap: Record<string, string>
/** optional, falls du Banner o.ä. daraus nimmst */
initialInvites?: Invitation[] initialInvites?: Invitation[]
} }
/** flache, stabile Equality-Checks */ /* ---------- kleine Helper ---------- */
function eqPlayers(a: Player[] = [], b: Player[] = []) { 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 if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false for (let i = 0; i < a.length; i++) if (a[i].steamId !== b[i].steamId) return false
return true return true
} }
const eqTeam = (a?: Team | null, b?: Team | null) => {
function sameLeader(a?: {steamId?: string} | null, b?: {steamId?: string} | null) {
if (!a && !b) return true if (!a && !b) return true
if (!a || !b) return false 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))
)
} }
const eqInviteList = (a: Invitation[] = [], b: Invitation[] = []) => {
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[] = []) {
if (a.length !== b.length) return false if (a.length !== b.length) return false
const A = a.map(x => x.id).sort().join(',') const A = a.map((x) => x.id).sort().join(',')
const B = b.map(x => x.id).sort().join(',') const B = b.map((x) => x.id).sort().join(',')
return A === B return A === B
} }
/* ---------- Komponente ---------- */
function TeamCardComponent( function TeamCardComponent(
{ initialTeams, initialInvitationMap, initialInvites = [] }: Props, { initialTeams, initialInvitationMap, initialInvites = [] }: Props,
_ref: any _ref: any
@ -73,89 +58,86 @@ function TeamCardComponent(
const steamId = session?.user?.steamId ?? '' const steamId = session?.user?.steamId ?? ''
const { lastEvent } = useSSEStore() 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(true)
const [initialLoading, setInitialLoading] = useState(false)
// Team des Users (falls vorhanden). Das bekommst du NICHT aus initialTeams raus // Alle Teams, in denen ich Mitglied bin (Leader/aktiv/inaktiv)
// wir laden es on-demand, wenn nötig (einmalig), oder du reichst es auch serverseitig rein. const [myTeams, setMyTeams] = useState<Team[]>([])
const [team, setTeam] = useState<Team | null>(null) // 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[]>( const [pendingInvitations, setPendingInvitations] = useState<Invitation[]>(
initialInvites.filter(i => i.type === 'team-invite' && i.team) as any 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 [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false) const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false) const [showInviteModal, setShowInviteModal] = useState(false)
// Refs
const currentTeamIdRef = useRef<string | null>(null)
const lastReloadAt = useRef<number>(0)
const lastInviteCheck = 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) /* ------- User+Teams laden (einmalig) ------- */
// Wenn du wirklich GAR KEINEN Client-Initial-Call willst, kannst du das auch serverseitig ermitteln und per Prop reichen. const loadUserTeams = async () => {
useEffect(() => { try {
let ignore = false setInitialLoading(true)
const checkOwnTeam = async () => { const res = await fetch('/api/user', { cache: 'no-store' })
try { if (!res.ok) throw new Error('failed /api/user')
setInitialLoading(true) const data = await res.json()
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
}, [])
// SSE-Reaktionen bei Änderungen nachladen (soft) const teams: Team[] = Array.isArray(data?.teams) ? data.teams : []
const fetchData = async () => { 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 { try {
const res = await fetch('/api/user', { cache: 'no-store' }) const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json() const data = await res.json()
const userTeam = data?.team ?? null const teams: Team[] = Array.isArray(data?.teams) ? data.teams : []
if (userTeam) {
setTeam(prev => (eqTeam(prev, userTeam) ? prev : userTeam)) setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
currentTeamIdRef.current = userTeam.id
if (pendingInvitations.length) setPendingInvitations([]) if (teams.length === 1) setSelectedTeam(teams[0])
} else { else if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) setSelectedTeam(null)
currentTeamIdRef.current = null
// (optional) Invites nachladen wie gehabt … if (teams.length > 0 && pendingInvitations.length) setPendingInvitations([])
if (team !== null) setTeam(null) if (teams.length === 0 && Date.now() - lastInviteCheck.current > 1500) {
// Nur sporadisch Invites neu ziehen (wenn man kein Team hat) lastInviteCheck.current = Date.now()
if (Date.now() - lastInviteCheck.current > 1500) { const inv = await fetch('/api/user/invitations', { cache: 'no-store' })
lastInviteCheck.current = Date.now() if (inv.ok) {
const inviteRes = await fetch('/api/user/invitations', { cache: 'no-store' }) const json = await inv.json()
if (inviteRes.ok) { const all: Invitation[] = (json.invitations ?? [])
const inviteData = await inviteRes.json() .filter((i: any) => i.type === 'team-invite' && i.team)
const all: Invitation[] = (inviteData.invitations ?? []) .map((i: any) => ({ id: i.id, team: i.team }))
.filter((i: any) => i.type === 'team-invite' && i.team) setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all))
.map((i: any) => ({ id: i.id, team: i.team }))
setPendingInvitations(prev => (eqInvites(prev, all) ? prev : all))
}
} }
if (team !== null) setTeam(null)
} }
} catch (e) { } catch { /* noop */ }
console.error('[TeamCardComponent] fetchData error:', e)
}
} }
useEffect(() => { useEffect(() => {
@ -164,7 +146,8 @@ function TeamCardComponent(
const { type, payload } = lastEvent const { type, payload } = lastEvent
if (SELF_EVENTS.has(type)) { fetchData(); return } if (SELF_EVENTS.has(type)) { softReload(); return }
if (type === 'team-invite-revoked') { if (type === 'team-invite-revoked') {
const revokedId = payload?.invitationId as string | undefined const revokedId = payload?.invitationId as string | undefined
const revokedTeamId = payload?.teamId as string | undefined const revokedTeamId = payload?.teamId as string | undefined
@ -176,57 +159,50 @@ function TeamCardComponent(
) )
) )
} }
fetchData() softReload()
return 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEvent, pendingInvitations]) }, [lastEvent, myTeams.length, selectedTeam, pendingInvitations])
/* ------- Render-Zweige ------- */
if (initialLoading) return <LoadingSpinner /> if (initialLoading) return <LoadingSpinner />
// 1) Kein Team, aber Pending-Einladungen -> Banner + NoTeamView (mit initialen Teams & Mapping) // (A) Ich habe gar kein Team → ggf. Banner + NoTeamView
if (!team && pendingInvitations.length > 0) { if (myTeams.length === 0) {
return ( return (
<> <>
<div className="space-y-4"> {pendingInvitations.length > 0 && (
{pendingInvitations.map(inv => ( <div className="space-y-4 mb-4">
<TeamInvitationBanner {pendingInvitations.map(inv => (
key={inv.id} <TeamInvitationBanner
invitation={inv} key={inv.id}
notificationId={inv.id} invitation={inv}
onMarkAsRead={async () => {}} notificationId={inv.id}
onAction={async (action) => { onMarkAsRead={async () => {}}
try { onAction={async (action) => {
await fetch(`/api/user/invitations/${action}`, { try {
method: 'POST', await fetch(`/api/user/invitations/${action}`, {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ invitationId: inv.id, teamId: inv.team.id }), headers: { 'Content-Type': 'application/json' },
}) body: JSON.stringify({ invitationId: inv.id, teamId: inv.team.id }),
} catch (e) { })
console.error('Invite respond fehlgeschlagen:', e) } catch (e) {
} finally { console.error('Invite respond fehlgeschlagen:', e)
// lokal entfernen + soft reload } finally {
setPendingInvitations(list => list.filter(x => x.id !== inv.id)) setPendingInvitations(list => list.filter(x => x.id !== inv.id))
await fetchData() await softReload()
} }
}} }}
/> />
))} ))}
</div> </div>
<NoTeamView )}
initialTeams={initialTeams}
initialInvitationMap={initialInvitationMap}
/>
</>
)
}
// 2) Kein Team & keine Einladung → reine Teamliste (aus Server-Props)
if (!team) {
return (
<>
<NoTeamView <NoTeamView
initialTeams={initialTeams} initialTeams={initialTeams}
initialInvitationMap={initialInvitationMap} 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 ( return (
<div> <div>
<div className="mb-4 xl:mb-8"> <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"> <h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
Teameinstellungen Teamverwaltung
</h1> </h1>
<p className="text-sm text-gray-500 dark:text-neutral-500"> <p className="text-sm text-gray-500 dark:text-neutral-500">
Verwalte dein Team und lade Mitglieder ein Verwalte dein Team und lade Mitglieder ein
</p> </p>
@ -252,7 +345,7 @@ function TeamCardComponent(
<form> <form>
<TeamMemberView <TeamMemberView
team={team} team={selectedTeam}
currentUserSteamId={steamId} currentUserSteamId={steamId}
adminMode={false} adminMode={false}
activeDragItem={activeDragItem} activeDragItem={activeDragItem}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,18 +4,23 @@ import DeleteAccountSettings from "./account/DeleteAccountSettings"
import AppearanceSettings from "./account/AppearanceSettings" import AppearanceSettings from "./account/AppearanceSettings"
import AuthCodeSettings from "./account/AuthCodeSettings" import AuthCodeSettings from "./account/AuthCodeSettings"
import LatestKnownCodeSettings from "./account/ShareCodeSettings" import LatestKnownCodeSettings from "./account/ShareCodeSettings"
import { useTranslations, useLocale } from 'next-intl'
export default function AccountSettings() { export default function AccountSettings() {
// Übersetzungen
const tSettings = useTranslations('settings')
return ( return (
<>{/* Account Card */} <>{/* Account Card */}
<div className=""> <div className="">
{/* Title */} {/* Title */}
<div className="mb-4 xl:mb-8"> <div className="mb-4 xl:mb-8">
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200"> <h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
Accounteinstellungen {tSettings("tabs.account.title")}
</h1> </h1>
<p className="text-sm text-gray-500 dark:text-neutral-500"> <p className="text-sm text-gray-500 dark:text-neutral-500">
Passe das Erscheinungsbild der Webseite an {tSettings("tabs.account.description")}
</p> </p>
</div> </div>
{/* End Title */} {/* End Title */}

View File

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

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import Popover from '../../Popover' import Popover from '../../Popover'
import Button from '../../Button' import Button from '../../Button'
import { useTranslations, useLocale } from 'next-intl'
export default function AuthCodeSettings() { export default function AuthCodeSettings() {
const [authCode, setAuthCode] = useState('') const [authCode, setAuthCode] = useState('')
@ -12,6 +13,9 @@ export default function AuthCodeSettings() {
const [manuallySet, setManuallySet] = useState(false) const [manuallySet, setManuallySet] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
// Übersetzungen
const tSettings = useTranslations('settings')
const showInput = !isLoading && (!authCode || manuallySet) const showInput = !isLoading && (!authCode || manuallySet)
const formatAuthCode = (value: string) => { const formatAuthCode = (value: string) => {
@ -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="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"> <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"> <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> </label>
<div className="mt-1"> <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"> <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> <p>
Deinen Code findest du&nbsp; {tSettings("tabs.account.find-code")}&nbsp;
<Link <Link
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128" href={tSettings("tabs.account.url")}
target="_blank" target="_blank"
className="text-blue-600 underline hover:text-blue-800" className="text-blue-600 underline hover:text-blue-800"
> >
hier {tSettings("tabs.account.here")}
</Link>. </Link>.
</p> </p>
</div> </div>
@ -154,7 +158,7 @@ export default function AuthCodeSettings() {
</> </>
) : ( ) : (
<Button color="red" variant="ghost" onClick={handleDisconnect}> <Button color="red" variant="ghost" onClick={handleDisconnect}>
Verbindung trennen {tSettings("tabs.account.page.AuthCodeSettings.button-disconnect")}
</Button> </Button>
)} )}
</div> </div>

View File

@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import Popover from '../../Popover' import Popover from '../../Popover'
import Button from '../../Button' import Button from '../../Button'
import { useTranslations, useLocale } from 'next-intl'
export default function LatestKnownCodeSettings() { export default function LatestKnownCodeSettings() {
const [lastKnownShareCode, setLastKnownShareCode] = useState('') const [lastKnownShareCode, setLastKnownShareCode] = useState('')
@ -15,6 +16,9 @@ export default function LatestKnownCodeSettings() {
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
// Übersetzungen
const tSettings = useTranslations('settings')
const shareCodeExpired = useMemo(() => { const shareCodeExpired = useMemo(() => {
if (!lastKnownShareCodeDate) return false if (!lastKnownShareCodeDate) return false
const daysSince = (Date.now() - new Date(lastKnownShareCodeDate).getTime()) / (1000 * 60 * 60 * 24) const daysSince = (Date.now() - new Date(lastKnownShareCodeDate).getTime()) / (1000 * 60 * 60 * 24)
@ -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="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"> <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"> <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> </label>
<div className="mt-1"> <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"> <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> <p>
Du findest deinen Code&nbsp; {tSettings("tabs.account.find-code")}&nbsp;
<Link <Link
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128" href={tSettings("tabs.account.url")}
target="_blank" target="_blank"
className="text-blue-600 underline hover:text-blue-800" className="text-blue-600 underline hover:text-blue-800"
> >
hier {tSettings("tabs.account.here")}
</Link>. </Link>.
</p> </p>
</div> </div>

View File

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

View File

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

View File

@ -2,7 +2,7 @@
'use server' 'use server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { prisma } from '../../lib/prisma' import { prisma } from '@/lib/prisma'
// Helper: Prisma-User -> Player // Helper: Prisma-User -> Player
const toPlayer = (u: any) => ({ 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 { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const { searchParams } = new URL(req.url) 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({ const pendingInvites = await prisma.teamInvite.findMany({
where: teamId ? { teamId } : undefined, where: { teamId },
include: { select: { steamId: true }
user: {
select: {
steamId : true,
name : true,
avatar : true,
location : true,
premierRank: true,
team : true,
},
},
},
}) })
const invitedByThisTeam = new Set(pendingInvites.map(i => i.steamId))
// 2) Nur die von DIESEM Team bereits eingeladenen Steam-IDs // 3) Alle User (oder mit Suche filtern, wenn du willst)
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
const allUsers = await prisma.user.findMany({ const allUsers = await prisma.user.findMany({
where: { team: null }, // hat noch kein Team
select: { select: {
steamId : true, steamId : true,
name : true, name : true,
avatar : true, avatar : true,
location : 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 => const availableUsers = allUsers.filter(u =>
!teamMemberIds.has(u.steamId) && !membersOfThisTeam.has(u.steamId) && !invitedByThisTeam.has(u.steamId)
!invitedByThisTeam.has(u.steamId)
) )
return NextResponse.json({ users: availableUsers }) return NextResponse.json({ users: availableUsers })

View File

@ -8,7 +8,6 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { teamId, newLeaderSteamId } = await req.json() const { teamId, newLeaderSteamId } = await req.json()
if (!teamId || !newLeaderSteamId) { if (!teamId || !newLeaderSteamId) {
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 }) return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
} }
@ -17,28 +16,36 @@ export async function POST(req: NextRequest) {
where: { id: teamId }, where: { id: teamId },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true }, select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
}) })
if (!team) { if (!team) return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
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([ const allPlayerIds = Array.from(new Set([
...(team.activePlayers ?? []), ...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []), ...(team.inactivePlayers ?? []),
team.leaderId, // alter Leader (kann null sein) team.leaderId,
].filter(Boolean) as string[])) ].filter(Boolean) as string[]))
// Neuer Leader muss Mitglied sein
if (!allPlayerIds.includes(newLeaderSteamId)) { if (!allPlayerIds.includes(newLeaderSteamId)) {
return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 }) return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 })
} }
// Leader setzen
await prisma.team.update({ await prisma.team.update({
where: { id: teamId }, where: { id: teamId },
data : { leaderId: newLeaderSteamId }, data : { leaderId: newLeaderSteamId },
}) })
// Namen neuer Leader // --- Benachrichtigung & SSE unverändert ---
const newLeader = await prisma.user.findUnique({ const newLeader = await prisma.user.findUnique({
where : { steamId: newLeaderSteamId }, where : { steamId: newLeaderSteamId },
select: { name: true }, select: { name: true },
@ -47,7 +54,6 @@ export async function POST(req: NextRequest) {
const textForOthers = const textForOthers =
`${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".` `${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader von "${team.name}".`
// 1) Notification an neuen Leader (sichtbar + live)
const leaderNote = await prisma.notification.create({ const leaderNote = await prisma.notification.create({
data: { data: {
steamId : newLeaderSteamId, steamId : newLeaderSteamId,
@ -58,47 +64,37 @@ export async function POST(req: NextRequest) {
}, },
}) })
await sendServerSSEMessage({ await sendServerSSEMessage({
type : 'notification', type: 'notification',
targetUserIds: [newLeaderSteamId], targetUserIds: [newLeaderSteamId],
message : leaderNote.message, message: leaderNote.message,
id : leaderNote.id, id: leaderNote.id,
actionType : leaderNote.actionType ?? undefined, actionType: leaderNote.actionType ?? undefined,
actionData : leaderNote.actionData ?? undefined, actionData: leaderNote.actionData ?? undefined,
createdAt : leaderNote.createdAt.toISOString(), createdAt: leaderNote.createdAt.toISOString(),
}) })
// 2) Info an alle anderen (sichtbar + live)
const others = allPlayerIds.filter(id => id !== newLeaderSteamId) const others = allPlayerIds.filter(id => id !== newLeaderSteamId)
if (others.length) { if (others.length) {
const notes = await Promise.all( const notes = await Promise.all(others.map(steamId =>
others.map(steamId => prisma.notification.create({
prisma.notification.create({ data: {
data: { steamId,
steamId, title: 'Neuer Teamleader',
title: 'Neuer Teamleader', message: textForOthers,
message: textForOthers, actionType: 'team-leader-changed',
actionType: 'team-leader-changed', actionData: newLeaderSteamId,
actionData: newLeaderSteamId, },
}, })
}) ))
) await Promise.all(notes.map(n => sendServerSSEMessage({
) type: 'notification',
targetUserIds: [n.steamId],
await Promise.all( message: n.message,
notes.map(n => id: n.id,
sendServerSSEMessage({ actionType: n.actionType ?? undefined,
type: 'notification', actionData: n.actionData ?? undefined,
targetUserIds: [n.steamId], createdAt: n.createdAt.toISOString(),
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)
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-leader-changed', type: 'team-leader-changed',
targetUserIds: others, 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])) const reloadTargets = Array.from(new Set([...allPlayerIds, newLeaderSteamId]))
if (reloadTargets.length) { if (reloadTargets.length) {
await sendServerSSEMessage({ await sendServerSSEMessage({ type: 'team-updated', targetUserIds: reloadTargets, teamId })
type: 'team-updated',
targetUserIds: reloadTargets,
teamId,
})
} }
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' }) 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) console.error('Fehler beim Leaderwechsel:', error)
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 }) 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 { prisma } from '@/lib/prisma'
import type { Player } from '../../../types/team' import type { Player } from '../../../types/team'
export const dynamic = 'force-dynamic' // optional: Caching aus export const dynamic = 'force-dynamic'
// export const revalidate = 0
export async function GET() { export async function GET() {
try { try {
const teams = await prisma.team.findMany({ const teams = await prisma.team.findMany({
select: { id: true, name: true, logo: true, leaderId: true, createdAt: true, select: {
activePlayers: true, inactivePlayers: true }, 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>() const uniqueIds = new Set<string>()
teams.forEach(t => { for (const t of teams) {
t.activePlayers.forEach(id => uniqueIds.add(id)) t.activePlayers.forEach(id => uniqueIds.add(id))
t.inactivePlayers.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({ const users = await prisma.user.findMany({
where: { steamId: { in: [...uniqueIds] } }, 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 byId: Record<string, Player> = {}
const DEFAULT_AVATAR = '/assets/img/avatars/default.png' const DEFAULT_AVATAR = '/assets/img/avatars/default.png'
const UNKNOWN_NAME = 'Unbekannt' const UNKNOWN_NAME = 'Unbekannt'
users.forEach(u => {
for (const u of users) {
byId[u.steamId] = { byId[u.steamId] = {
steamId: u.steamId, steamId: u.steamId,
name: u.name ?? UNKNOWN_NAME, name: u.name ?? UNKNOWN_NAME,
avatar: u.avatar ?? DEFAULT_AVATAR, avatar: u.avatar ?? DEFAULT_AVATAR,
location: u.location ?? '', 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( return NextResponse.json(
{ items: result, hasMore: false }, { teams: result, hasMore: false },
{ headers: { 'Cache-Control': 'no-store' } } { headers: { 'Cache-Control': 'no-store' } }
) )
} catch (err) { } catch (err) {

View File

@ -1,95 +1,137 @@
// /src/app/api/user/route.ts // /src/app/api/user/route.ts
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server';
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma' 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) { export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req)) // ⚠️ Typen fixen: getServerSession korrekt casten
const steamId = session?.user?.steamId const session = (await getServerSession(authOptions(req) as any)) as SessionShape;
const steamId = session?.user?.steamId;
if (!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) // 1) Basisdaten des Users
const userRaw = await prisma.user.findUnique({ const me = await prisma.user.findUnique({
where: { steamId }, where: { steamId },
select: { select: {
name: true,
steamId: true, steamId: true,
name: true,
avatar: true, avatar: true,
location: true,
premierRank: true, premierRank: true,
isAdmin: true, isAdmin: true,
status: 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) { if (!me) {
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 }) return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 });
} }
// 2) Falls Team vorhanden: active/inactive IDs in User-Objekte auflösen // 2) Alle Teams laden, in denen der User Leader/aktiv/inaktiv ist
let teamResolved: any = null const teamsRaw = await prisma.team.findMany({
if (userRaw.team) { where: {
const activeIds = userRaw.team.activePlayers ?? [] OR: [
const inactiveIds = userRaw.team.inactivePlayers ?? [] { 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 [] // 3) Einmalig alle benötigten Steam-IDs sammeln und als User ziehen
const [activeUsers, inactiveUsers] = await Promise.all([ const ids = new Set<string>();
prisma.user.findMany({ for (const t of teamsRaw) {
where: { steamId: { in: activeIds } }, t.activePlayers.forEach(ids.add, ids);
select: { steamId: true, name: true, avatar: true, premierRank: true }, t.inactivePlayers.forEach(ids.add, ids);
}), if (t.leaderId) ids.add(t.leaderId);
prisma.user.findMany({ }
where: { steamId: { in: inactiveIds } },
select: { steamId: true, name: true, avatar: true, premierRank: true }, 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 mapIds = (arr: string[]) =>
const byId = (ids: string[], users: any[]) => { arr.map((id) => byId[id]).filter(Boolean) as SlimPlayer[];
const map = new Map(users.map((u) => [u.steamId, u]))
return ids.map((id) => map.get(id)).filter(Boolean)
}
teamResolved = { // 4) Teams auflösen (Leader + aktive/inaktive Spieler)
id: userRaw.team.id, const teams = teamsRaw.map((t) => ({
name: userRaw.team.name, id: t.id,
logo: userRaw.team.logo, name: t.name,
leader: userRaw.team.leader ?? null, logo: t.logo,
activePlayers: byId(activeIds, activeUsers), createdAt: t.createdAt,
inactivePlayers: byId(inactiveIds, inactiveUsers), 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 teamIds = teams.map((t) => t.id);
const response = {
name: userRaw.name,
steamId: userRaw.steamId,
avatar: userRaw.avatar,
premierRank: userRaw.premierRank,
isAdmin: userRaw.isAdmin,
status: userRaw.status ?? 'offline',
team: teamResolved,
}
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 // /src/i18n/request.ts
import {getRequestConfig} from 'next-intl/server'; import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl'; import {hasLocale} from 'next-intl';
import {routing} from './routing'; import {routing} from './routing';
export default getRequestConfig(async ({requestLocale}) => { const namespaces = [
// Typically corresponds to the `[locale]` segment 'common',
const requested = await requestLocale; 'nav',
const locale = hasLocale(routing.locales, requested) 'sidebar',
? requested 'settings',
: routing.defaultLocale; 'teams',
'matches',
'dashboard'
] as const;
type Namespace = (typeof namespaces)[number];
return { async function tryImport<T>(p: string): Promise<T | null> {
locale, try { const mod = await import(p as any); return (mod as any).default as T; }
messages: (await import(`../../messages/${locale}.json`)).default 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}) => {
const requested = await requestLocale;
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 { Match, User } from '@/generated/prisma';
import { parseAndStoreDemo } from './parseAndStoreDemo'; import { parseAndStoreDemo } from './parseAndStoreDemo';
import { log } from '../../scripts/cs2-cron-runner.js'; 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<{ export async function runDownloaderForUser(user: User): Promise<{
newMatches: Match[]; 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 {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server'; import type {NextRequest} from 'next/server';
import createIntlMiddleware from 'next-intl/middleware'; import createIntlMiddleware from 'next-intl/middleware';
import {getToken} from 'next-auth/jwt'; import {getToken} from 'next-auth/jwt';
import {routing} from './i18n/routing'; import {routing} from './i18n/routing';
// 1) i18n-Middleware vorbereiten // 1) i18n-Middleware vorbereiten
const handleI18n = createIntlMiddleware(routing); const handleI18n = createIntlMiddleware(routing);
// Kleine Helfer // Helpers
function getCurrentLocaleFromPath(pathname: string, locales: readonly string[], fallback: string) { function getCurrentLocaleFromPath(pathname: string, locales: readonly string[], fallback: string) {
const first = pathname.split('/')[1]; const first = pathname.split('/')[1];
return locales.includes(first) ? first : fallback; return locales.includes(first) ? first : fallback;
@ -30,19 +28,17 @@ function isProtectedPath(pathnameNoLocale: string) {
pathnameNoLocale.startsWith('/dashboard') || pathnameNoLocale.startsWith('/dashboard') ||
pathnameNoLocale.startsWith('/settings') || pathnameNoLocale.startsWith('/settings') ||
pathnameNoLocale.startsWith('/matches') || pathnameNoLocale.startsWith('/matches') ||
pathnameNoLocale.startsWith('/team') || // ← hinzugefügt
pathnameNoLocale.startsWith('/admin') pathnameNoLocale.startsWith('/admin')
); );
} }
export default async function middleware(req: NextRequest) { 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); const i18nRes = handleI18n(req);
// Falls i18n gerade einen Redirect/Rewrite veranlasst, das Ergebnis direkt zurückgeben. // Wenn i18n bereits redirect/rewrite auslöst, direkt zurückgeben
// (Erkennbar an Location-Header (redirect) oder x-middleware-rewrite (rewrite)) if (i18nRes.headers.get('location') || i18nRes.headers.get('x-middleware-rewrite')) {
const isRedirect = i18nRes.headers.get('location') != null;
const isRewrite = i18nRes.headers.get('x-middleware-rewrite') != null;
if (isRedirect || isRewrite) {
return i18nRes; return i18nRes;
} }
@ -54,7 +50,6 @@ export default async function middleware(req: NextRequest) {
// 3) Nur für geschützte Pfade Auth prüfen // 3) Nur für geschützte Pfade Auth prüfen
if (!isProtectedPath(pathnameNoLocale)) { if (!isProtectedPath(pathnameNoLocale)) {
// Nichts weiter zu tun → i18n-Result weiterreichen
return i18nRes; return i18nRes;
} }
@ -62,7 +57,7 @@ export default async function middleware(req: NextRequest) {
// Adminschutz // Adminschutz
if (pathnameNoLocale.startsWith('/admin')) { if (pathnameNoLocale.startsWith('/admin')) {
if (!token || !token.isAdmin) { if (!token || !(token as any).isAdmin) {
const redirectUrl = url.clone(); const redirectUrl = url.clone();
redirectUrl.pathname = `/${currentLocale}/dashboard`; redirectUrl.pathname = `/${currentLocale}/dashboard`;
return NextResponse.redirect(redirectUrl); return NextResponse.redirect(redirectUrl);
@ -72,15 +67,15 @@ export default async function middleware(req: NextRequest) {
// Allgemeiner Auth-Schutz // Allgemeiner Auth-Schutz
if (!token) { if (!token) {
const loginUrl = new URL('/api/auth/signin', req.url); const loginUrl = new URL('/api/auth/signin', req.url);
// Callback auf die aktuelle URL (inkl. Locale) setzen loginUrl.searchParams.set('callbackUrl', url.toString()); // komplette Ziel-URL inkl. Locale
loginUrl.searchParams.set('callbackUrl', url.toString());
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
// Alles gut → weiter mit i18n-Result (entspricht NextResponse.next()) // Alles gut → weiter
return i18nRes; return i18nRes;
} }
export const config = { export const config = {
// Standard: alles außer /api, _next, statische Dateien
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
}; };