updated
This commit is contained in:
parent
bb7ac51509
commit
51ae2c80a1
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
{tSettings("tabs.account.find-code")}
|
||||
<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>
|
||||
|
||||
@ -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
|
||||
{tSettings("tabs.account.find-code")}
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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' } }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) };
|
||||
});
|
||||
|
||||
@ -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[];
|
||||
|
||||
3
src/messages/dashboard/de.json
Normal file
3
src/messages/dashboard/de.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "Willkommen!"
|
||||
}
|
||||
3
src/messages/dashboard/en.json
Normal file
3
src/messages/dashboard/en.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "Welcome!"
|
||||
}
|
||||
14
src/messages/nav/de.json
Normal file
14
src/messages/nav/de.json
Normal 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
14
src/messages/nav/en.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"dashboard": "Dashboard",
|
||||
"teams": {
|
||||
"label": "Teams",
|
||||
"overview": "Übersicht",
|
||||
"manage": "Teamverwaltung"
|
||||
},
|
||||
"players": {
|
||||
"label": "Spieler",
|
||||
"overview": "Übersicht",
|
||||
"stats": "Statistiken"
|
||||
},
|
||||
"schedule": "Spielplan"
|
||||
}
|
||||
20
src/messages/settings/de.json
Normal file
20
src/messages/settings/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/messages/settings/en.json
Normal file
20
src/messages/settings/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/messages/sidebar/de.json
Normal file
7
src/messages/sidebar/de.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"brand": "Iron:e",
|
||||
"language": {
|
||||
"de": "Deutsch",
|
||||
"en": "Englisch"
|
||||
}
|
||||
}
|
||||
7
src/messages/sidebar/en.json
Normal file
7
src/messages/sidebar/en.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"brand": "Iron:e",
|
||||
"language": {
|
||||
"de": "German",
|
||||
"en": "English"
|
||||
}
|
||||
}
|
||||
@ -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|.*\\..*).*)'
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user