From 51ae2c80a13346ebb5c28cb26b078b0b6d440d4f Mon Sep 17 00:00:00 2001 From: Linrador Date: Tue, 23 Sep 2025 23:29:21 +0200 Subject: [PATCH] updated --- messages/de.json | 47 --- messages/en.json | 47 --- src/app/[locale]/admin/[tab]/page.tsx | 6 +- src/app/[locale]/admin/server/page.tsx | 4 +- .../admin/teams/[teamId]/TeamAdminClient.tsx | 6 +- src/app/[locale]/admin/teams/page.tsx | 4 +- src/app/[locale]/components/TeamCard.tsx | 59 ++- .../[locale]/components/TeamCardComponent.tsx | 383 +++++++++++------- .../components/admin/teams/AdminTeamsView.tsx | 2 +- .../[locale]/components/radar/LiveRadar.tsx | 4 +- .../[locale]/components/radar/TeamSidebar.tsx | 2 +- .../components/radar/hooks/useBombBeep.ts | 2 +- .../components/radar/hooks/useOverview.ts | 4 +- .../components/radar/hooks/useRadarState.ts | 8 +- .../components/settings/AccountSettings.tsx | 9 +- .../settings/account/AppearanceSettings.tsx | 6 +- .../settings/account/AuthCodeSettings.tsx | 18 +- .../settings/account/ShareCodeSettings.tsx | 16 +- src/app/[locale]/layout.tsx | 37 +- src/app/[locale]/settings/layout.tsx | 9 +- src/app/api/schedule/route.ts | 2 +- src/app/api/team/available-users/route.ts | 63 ++- src/app/api/team/transfer-leader/route.ts | 100 +++-- src/app/api/teams/route.ts | 73 +++- src/app/api/user/route.ts | 182 +++++---- src/i18n/request.ts | 48 ++- src/jobs/runDownloaderForUser.ts | 2 +- src/messages/dashboard/de.json | 3 + src/messages/dashboard/en.json | 3 + src/messages/nav/de.json | 14 + src/messages/nav/en.json | 14 + src/messages/settings/de.json | 20 + src/messages/settings/en.json | 20 + src/messages/sidebar/de.json | 7 + src/messages/sidebar/en.json | 7 + src/middleware.ts | 23 +- 36 files changed, 723 insertions(+), 531 deletions(-) delete mode 100644 messages/de.json delete mode 100644 messages/en.json create mode 100644 src/messages/dashboard/de.json create mode 100644 src/messages/dashboard/en.json create mode 100644 src/messages/nav/de.json create mode 100644 src/messages/nav/en.json create mode 100644 src/messages/settings/de.json create mode 100644 src/messages/settings/en.json create mode 100644 src/messages/sidebar/de.json create mode 100644 src/messages/sidebar/en.json diff --git a/messages/de.json b/messages/de.json deleted file mode 100644 index 178d073..0000000 --- a/messages/de.json +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json deleted file mode 100644 index cdce50e..0000000 --- a/messages/en.json +++ /dev/null @@ -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" - } -} diff --git a/src/app/[locale]/admin/[tab]/page.tsx b/src/app/[locale]/admin/[tab]/page.tsx index ec64ee0..733ce86 100644 --- a/src/app/[locale]/admin/[tab]/page.tsx +++ b/src/app/[locale]/admin/[tab]/page.tsx @@ -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() diff --git a/src/app/[locale]/admin/server/page.tsx b/src/app/[locale]/admin/server/page.tsx index 8f9a50c..bca346c 100644 --- a/src/app/[locale]/admin/server/page.tsx +++ b/src/app/[locale]/admin/server/page.tsx @@ -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' diff --git a/src/app/[locale]/admin/teams/[teamId]/TeamAdminClient.tsx b/src/app/[locale]/admin/teams/[teamId]/TeamAdminClient.tsx index 2630ba6..ffedfd7 100644 --- a/src/app/[locale]/admin/teams/[teamId]/TeamAdminClient.tsx +++ b/src/app/[locale]/admin/teams/[teamId]/TeamAdminClient.tsx @@ -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 } diff --git a/src/app/[locale]/admin/teams/page.tsx b/src/app/[locale]/admin/teams/page.tsx index 09eb214..a348e93 100644 --- a/src/app/[locale]/admin/teams/page.tsx +++ b/src/app/[locale]/admin/teams/page.tsx @@ -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 ( diff --git a/src/app/[locale]/components/TeamCard.tsx b/src/app/[locale]/components/TeamCard.tsx index c70c771..9340eef 100644 --- a/src/app/[locale]/components/TeamCard.tsx +++ b/src/app/[locale]/components/TeamCard.tsx @@ -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({
- {[...team.activePlayers, ...team.inactivePlayers].map(p => ( - {p.name} - ))} + {(() => { + const seen = new Set(); + 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 => ( + {p.name} + )); + })()}
) diff --git a/src/app/[locale]/components/TeamCardComponent.tsx b/src/app/[locale]/components/TeamCardComponent.tsx index 8b9395b..1876880 100644 --- a/src/app/[locale]/components/TeamCardComponent.tsx +++ b/src/app/[locale]/components/TeamCardComponent.tsx @@ -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 - /** 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(null) + // Alle Teams, in denen ich Mitglied bin (Leader/aktiv/inaktiv) + const [myTeams, setMyTeams] = useState([]) + // Für die Inline-Detailansicht bei mehreren Teams + const [selectedTeam, setSelectedTeam] = useState(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( initialInvites.filter(i => i.type === 'team-invite' && i.team) as any ) - // lokale States (unverändert) + // Drag/Modals für TeamMemberView const [activeDragItem, setActiveDragItem] = useState(null) const [isDragging, setIsDragging] = useState(false) const [showLeaveModal, setShowLeaveModal] = useState(false) const [showInviteModal, setShowInviteModal] = useState(false) - // Refs - const currentTeamIdRef = useRef(null) - const lastReloadAt = useRef(0) const lastInviteCheck = useRef(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 - // 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 ( <> -
- {pendingInvitations.map(inv => ( - {}} - 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() - } - }} - /> - ))} -
- - - ) - } + {pendingInvitations.length > 0 && ( +
+ {pendingInvitations.map(inv => ( + {}} + 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() + } + }} + /> + ))} +
+ )} - // 2) Kein Team & keine Einladung → reine Teamliste (aus Server-Props) - if (!team) { - return ( - <> +
+

+ Teameinstellungen +

+

+ Verwalte dein Team und lade Mitglieder ein +

+
+ +
+ + + + ) + } + + // (C) Mehrere Teams: Liste meiner Teams oder Detailansicht + if (!selectedTeam) { + return ( +
+
+

+ Deine Teams +

+

+ Wähle ein Team, um die Mitglieder zu verwalten. +

+
+ +
+ {myTeams.map(team => ( + + ))} +
+
+ ) + } + + // selectedTeam gesetzt → Detailansicht mit "Zurück" oben links return (
+
+ +
+

- Teameinstellungen + Teamverwaltung

+

Verwalte dein Team und lade Mitglieder ein

@@ -252,7 +345,7 @@ function TeamCardComponent(
(null); diff --git a/src/app/[locale]/components/radar/hooks/useRadarState.ts b/src/app/[locale]/components/radar/hooks/useRadarState.ts index 417b1b5..dbd62cc 100644 --- a/src/app/[locale]/components/radar/hooks/useRadarState.ts +++ b/src/app/[locale]/components/radar/hooks/useRadarState.ts @@ -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 diff --git a/src/app/[locale]/components/settings/AccountSettings.tsx b/src/app/[locale]/components/settings/AccountSettings.tsx index d32aef2..edbf694 100644 --- a/src/app/[locale]/components/settings/AccountSettings.tsx +++ b/src/app/[locale]/components/settings/AccountSettings.tsx @@ -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 */}
{/* Title */}

- Accounteinstellungen + {tSettings("tabs.account.title")}

- Passe das Erscheinungsbild der Webseite an + {tSettings("tabs.account.description")}

{/* End Title */} diff --git a/src/app/[locale]/components/settings/account/AppearanceSettings.tsx b/src/app/[locale]/components/settings/account/AppearanceSettings.tsx index 880a56b..ce103f8 100644 --- a/src/app/[locale]/components/settings/account/AppearanceSettings.tsx +++ b/src/app/[locale]/components/settings/account/AppearanceSettings.tsx @@ -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() {
diff --git a/src/app/[locale]/components/settings/account/AuthCodeSettings.tsx b/src/app/[locale]/components/settings/account/AuthCodeSettings.tsx index 9fb2963..905a2fb 100644 --- a/src/app/[locale]/components/settings/account/AuthCodeSettings.tsx +++ b/src/app/[locale]/components/settings/account/AuthCodeSettings.tsx @@ -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() {
- +
- Websites und Anwendungen von Drittanbietern haben mit diesem Authentifizierungscode Zugriff auf deinen Spielverlauf und können dein Gameplay analysieren. + {tSettings("tabs.account.page.AuthCodeSettings.description")}

- Deinen Code findest du  + {tSettings("tabs.account.find-code")}  - hier + {tSettings("tabs.account.here")} .

@@ -154,7 +158,7 @@ export default function AuthCodeSettings() { ) : ( )}
diff --git a/src/app/[locale]/components/settings/account/ShareCodeSettings.tsx b/src/app/[locale]/components/settings/account/ShareCodeSettings.tsx index a3ee5f2..14ed12a 100644 --- a/src/app/[locale]/components/settings/account/ShareCodeSettings.tsx +++ b/src/app/[locale]/components/settings/account/ShareCodeSettings.tsx @@ -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() {
- +
- Mit dem Austauschcode können Anwendungen dein letztes offizielles Match finden und analysieren. + {tSettings("tabs.account.page.ShareCodeSettings.description")}

- Du findest deinen Code  + {tSettings("tabs.account.find-code")}  - hier + {tSettings("tabs.account.here")} .

diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index f490ecd..8b8fe9c 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -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 ( - + - + - - {/* App-Shell: Sidebar | Main */}
- - {/* rechte Spalte */}
-
- {children} -
+
{children}
- diff --git a/src/app/[locale]/settings/layout.tsx b/src/app/[locale]/settings/layout.tsx index 4ac7e98..21cb18c 100644 --- a/src/app/[locale]/settings/layout.tsx +++ b/src/app/[locale]/settings/layout.tsx @@ -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 (
- - + +
{children} diff --git a/src/app/api/schedule/route.ts b/src/app/api/schedule/route.ts index e2635d7..ab33e63 100644 --- a/src/app/api/schedule/route.ts +++ b/src/app/api/schedule/route.ts @@ -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) => ({ diff --git a/src/app/api/team/available-users/route.ts b/src/app/api/team/available-users/route.ts index c8ebd35..ea398a7 100644 --- a/src/app/api/team/available-users/route.ts +++ b/src/app/api/team/available-users/route.ts @@ -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 }) diff --git a/src/app/api/team/transfer-leader/route.ts b/src/app/api/team/transfer-leader/route.ts index 32a91e7..72fd8f9 100644 --- a/src/app/api/team/transfer-leader/route.ts +++ b/src/app/api/team/transfer-leader/route.ts @@ -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 }) } diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 37b7186..286eba4 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -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() - 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 = {} 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) { diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 94d9051..188d157 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -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(); + 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 = 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' } } + ); } diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 6638e82..ced241a 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -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(p: string): Promise { + 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//.json + const obj = await tryImport>( + `../messages/${ns}/${locale}.json` + ); + return [ns, obj ?? {}] as const; + }) + ); + + const merged = {} as Record>; + 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 - }; -}); \ No newline at end of file + const locale = hasLocale(routing.locales, requested) ? (requested as string) : routing.defaultLocale; + return { locale, messages: await loadMessages(locale) }; +}); diff --git a/src/jobs/runDownloaderForUser.ts b/src/jobs/runDownloaderForUser.ts index 296e361..866c9e9 100644 --- a/src/jobs/runDownloaderForUser.ts +++ b/src/jobs/runDownloaderForUser.ts @@ -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[]; diff --git a/src/messages/dashboard/de.json b/src/messages/dashboard/de.json new file mode 100644 index 0000000..0d28188 --- /dev/null +++ b/src/messages/dashboard/de.json @@ -0,0 +1,3 @@ +{ + "title": "Willkommen!" +} diff --git a/src/messages/dashboard/en.json b/src/messages/dashboard/en.json new file mode 100644 index 0000000..7a8ffd7 --- /dev/null +++ b/src/messages/dashboard/en.json @@ -0,0 +1,3 @@ +{ + "title": "Welcome!" +} diff --git a/src/messages/nav/de.json b/src/messages/nav/de.json new file mode 100644 index 0000000..740598d --- /dev/null +++ b/src/messages/nav/de.json @@ -0,0 +1,14 @@ +{ + "dashboard": "Dashboard", + "teams": { + "label": "Teams", + "overview": "Übersicht", + "manage": "Teamverwaltung" + }, + "players": { + "label": "Spieler", + "overview": "Übersicht", + "stats": "Statistiken" + }, + "schedule": "Spielplan" +} diff --git a/src/messages/nav/en.json b/src/messages/nav/en.json new file mode 100644 index 0000000..740598d --- /dev/null +++ b/src/messages/nav/en.json @@ -0,0 +1,14 @@ +{ + "dashboard": "Dashboard", + "teams": { + "label": "Teams", + "overview": "Übersicht", + "manage": "Teamverwaltung" + }, + "players": { + "label": "Spieler", + "overview": "Übersicht", + "stats": "Statistiken" + }, + "schedule": "Spielplan" +} diff --git a/src/messages/settings/de.json b/src/messages/settings/de.json new file mode 100644 index 0000000..a195aa7 --- /dev/null +++ b/src/messages/settings/de.json @@ -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 hier.", + "helpUrl": "https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128" + } + } +} diff --git a/src/messages/settings/en.json b/src/messages/settings/en.json new file mode 100644 index 0000000..9f71d1d --- /dev/null +++ b/src/messages/settings/en.json @@ -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 here.", + "helpUrl": "https://help.steampowered.com/en/wizard/HelpWithGameIssue/?appid=730&issueid=128" + } + } +} diff --git a/src/messages/sidebar/de.json b/src/messages/sidebar/de.json new file mode 100644 index 0000000..dbfb490 --- /dev/null +++ b/src/messages/sidebar/de.json @@ -0,0 +1,7 @@ +{ + "brand": "Iron:e", + "language": { + "de": "Deutsch", + "en": "Englisch" + } +} diff --git a/src/messages/sidebar/en.json b/src/messages/sidebar/en.json new file mode 100644 index 0000000..b4041be --- /dev/null +++ b/src/messages/sidebar/en.json @@ -0,0 +1,7 @@ +{ + "brand": "Iron:e", + "language": { + "de": "German", + "en": "English" + } +} diff --git a/src/middleware.ts b/src/middleware.ts index f84730b..edd07f1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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|.*\\..*).*)' };