This commit is contained in:
Rother 2025-05-28 00:41:23 +02:00
commit b79c4faa03
152 changed files with 36204 additions and 0 deletions

View File

@ -0,0 +1,41 @@
'use client'
import { use, useState } from 'react'
import { notFound } from 'next/navigation'
import Card from '@/app/components/Card'
import TeamCardComponent from '@/app/components/TeamCardComponent'
import MatchesAdminManager from '@/app/components/MatchesAdminManager'
import MatchList from '@/app/components/MatchList'
export default function Page({ params }: { params: Promise<{ tab: string }> }) {
const { tab } = use(params)
const [refetchKey, setRefetchKey] = useState<string>('') // 🔥 Gemeinsamer Reload-Key
const renderTabContent = () => {
switch (tab) {
case 'matches':
return (
<Card title="Matches" description="">
<MatchesAdminManager />
</Card>
)
case 'privacy':
return (
<Card title="Datenschutz" description="Einstellungen zum Schutz deiner Daten." />
)
case 'team':
return (
<Card title="Team" description="Verwalte dein Team und lade Mitglieder ein.">
<div className="mb-4">
<TeamCardComponent refetchKey={refetchKey} />
</div>
</Card>
)
default:
return notFound()
}
}
return <>{renderTabContent()}</>
}

17
src/app/admin/layout.tsx Normal file
View File

@ -0,0 +1,17 @@
import { Tabs } from '@/app/components/Tabs'
import Tab from '@/app/components/Tab'
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="container mx-auto">
<Tabs>
<Tab name="Spielpläne" href="/admin/matches" />
<Tab name="Privacy" href="/admin/privacy" />
<Tab name="Team" href="/admin/team" />
</Tabs>
<div className="mt-6">
{children}
</div>
</div>
)
}

6
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,6 @@
// src/app/admin/page.tsx
import { redirect } from 'next/navigation'
export default function AdminRedirectPage() {
redirect('/admin/matches')
}

View File

@ -0,0 +1,10 @@
import { prisma } from '@/app/lib/prisma'
import { NextResponse } from 'next/server'
export async function GET() {
const teams = await prisma.team.findMany({
select: { id: true, name: true }
})
return NextResponse.json(teams)
}

View File

@ -0,0 +1,9 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { type NextRequest } from 'next/server'
export const handler = async (req: NextRequest, ctx: any) => {
return NextAuth(req, ctx, authOptions(req)) // mit Request
}
export { handler as GET, handler as POST }

View File

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { decrypt, encrypt } from '@/app/lib/crypto'
import { decodeMatchShareCode, MatchInformation } from 'csgo-sharecode';
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
let steamId = session?.user?.steamId ?? req.headers.get('x-steamid') ?? undefined;
if (!steamId) {
return NextResponse.json({ valid: false, reason: 'Missing steamId' });
}
try {
const user = await prisma.user.findUnique({
where: { steamId },
select: { authCode: true, lastKnownShareCode: true },
});
if (!user?.authCode || !user.lastKnownShareCode) {
return NextResponse.json({ valid: false });
}
const decryptedAuthCode = decrypt(user.authCode);
// Nur EINEN nächsten Code abrufen
const url = `https://api.steampowered.com/ICSGOPlayers_730/GetNextMatchSharingCode/v1?key=${process.env.STEAM_API_KEY}&steamid=${steamId}&steamidkey=${decryptedAuthCode}&knowncode=${user.lastKnownShareCode}`;
const res = await fetch(url);
const data = await res.json();
const nextCode = data?.result?.nextcode ?? 'n/a';
if (nextCode === 'n/a') {
return NextResponse.json({ valid: true, nextCode: null });
}
// MatchInfo extrahieren & speichern
const matchInfo = decodeMatchShareCode(nextCode);
await prisma.cS2MatchRequest.upsert({
where: {
steamId_matchId: {
steamId,
matchId: matchInfo.matchId,
},
},
update: {},
create: {
userId: steamId,
steamId: steamId,
matchId: matchInfo.matchId,
reservationId: matchInfo.reservationId,
tvPort: matchInfo.tvPort,
},
});
return NextResponse.json({
valid: true,
nextCode,
});
} catch (err) {
if (err instanceof Error && err.message === 'INVALID_CODE') {
return NextResponse.json({
valid: false,
error: 'Invalid authCode or knownCode (veraltet oder ungültig)',
});
}
return NextResponse.json({
valid: false,
error: err instanceof Error ? err.message : String(err),
});
}
}

View File

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { encrypt, decrypt } from '@/app/lib/crypto'
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { authCode, lastKnownShareCode } = await req.json()
if (!authCode || typeof authCode !== 'string') {
return NextResponse.json({ error: 'Ungültiger Auth Code' }, { status: 400 })
}
try {
await prisma.user.update({
where: { steamId },
data: {
authCode: encrypt(authCode),
lastKnownShareCode: lastKnownShareCode || undefined,
lastKnownShareCodeDate: lastKnownShareCode ? new Date() : undefined,
},
})
return new NextResponse(null, { status: 204 })
} catch (error) {
console.error('Fehler beim Speichern:', error)
return NextResponse.json({ error: 'Fehler beim Speichern der Codes' }, { status: 500 })
}
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const user = await prisma.user.findUnique({
where: { steamId },
select: {
authCode: true,
lastKnownShareCode: true,
lastKnownShareCodeDate: true,
},
})
return NextResponse.json({
authCode: user?.authCode ? decrypt(user.authCode) : null,
lastKnownShareCode: user?.lastKnownShareCode ?? null,
lastKnownShareCodeDate: user?.lastKnownShareCodeDate ?? null,
})
} catch (error) {
console.error('Fehler beim Abrufen:', error)
return NextResponse.json({ error: 'Fehler beim Abrufen der Codes' }, { status: 500 })
}
}

View File

@ -0,0 +1,168 @@
// /app/api/matches/[id]/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
type Params = { params: { id: string } }
export async function GET(_: Request, { params }: Params) {
const { id } = params
if (!id) {
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
}
try {
const match = await prisma.match.findUnique({
where: { matchId: BigInt(id) },
include: {
teamA: true,
teamB: true,
players: {
include: { user: true, stats: true }
}
}
})
if (!match) {
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
}
// Trenne MatchPlayer-Daten nach Team
const playersA = match.players.filter(p => p.teamId === match.teamAId)
const playersB = match.players.filter(p => p.teamId === match.teamBId)
return NextResponse.json({
...match,
playersA,
playersB,
})
} catch (err) {
console.error(`GET /matches/${id} failed:`, err)
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
}
}
export async function PUT(req: NextRequest, { params }: Params) {
const session = await getServerSession(authOptions(req))
const userId = session?.user?.steamId
const isAdmin = session?.user?.isAdmin
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const { id } = params
const body = await req.json()
const { title, description, matchDate, players } = body
const match = await prisma.match.findUnique({
where: { matchId: BigInt(id) },
include: {
teamA: { include: { leader: true } },
teamB: { include: { leader: true } },
}
})
if (!match) {
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
}
const isTeamLeaderA = match.teamA?.leaderId === userId
const isTeamLeaderB = match.teamB?.leaderId === userId
if (!isAdmin && !isTeamLeaderA && !isTeamLeaderB) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Wenn kein Admin: validiere Spieler gegen aktives Team
if (!isAdmin) {
const ownTeamId = isTeamLeaderA ? match.teamAId : match.teamBId
if (!ownTeamId) {
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 });
}
const ownTeam = await prisma.team.findUnique({ where: { id: ownTeamId } })
const allowed = new Set(ownTeam?.activePlayers || [])
const invalid = players.some((p: any) => p.teamId === ownTeamId && !allowed.has(p.userId))
if (invalid) {
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
}
}
try {
await prisma.matchPlayer.deleteMany({ where: { matchId: BigInt(id) } })
await prisma.matchPlayer.createMany({
data: players.map((p: any) => ({
matchId: id,
userId: p.userId,
teamId: p.teamId,
})),
})
const updated = await prisma.match.update({
where: { matchId: BigInt(id) },
data: {
title,
description,
matchDate: new Date(matchDate),
},
include: {
teamA: true,
teamB: true,
players: {
include: { user: true }
}
}
})
// Spieler wieder trennen nach Team
const playersA = updated.players
.filter(p => p.teamId === updated.teamAId)
.map(p => ({
steamId: p.user.steamId,
name: p.user.name,
avatar: p.user.avatar,
location: p.user.location,
}))
const playersB = updated.players
.filter(p => p.teamId === updated.teamBId)
.map(p => ({
steamId: p.user.steamId,
name: p.user.name,
avatar: p.user.avatar,
location: p.user.location,
}))
return NextResponse.json({
...updated,
playersA,
playersB,
})
} catch (err) {
console.error(`PUT /matches/${id} failed:`, err)
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: Params) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const { id } = params
try {
await prisma.match.delete({ where: { matchId: BigInt(id) } })
return NextResponse.json({ success: true })
} catch (err) {
console.error(`DELETE /matches/${id} failed:`, err)
return NextResponse.json({ error: 'Failed to delete match' }, { status: 500 })
}
}

View File

@ -0,0 +1,73 @@
// /app/api/matches/create/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
export async function POST (req: NextRequest) {
/* ▸ Berechtigung ---------------------------------------------------------------- */
const session = await getServerSession(authOptions(req))
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status : 403 })
}
/* ▸ Eingaben aus dem Body -------------------------------------------------------- */
const { teamAId, teamBId, title, description, matchDate, map } = await req.json()
if (!teamAId || !teamBId || !matchDate) {
return NextResponse.json({ error: 'Missing fields' }, { status : 400 })
}
try {
/* ▸ Aktive Spieler der Teams laden ------------------------------------------- */
const [teamA, teamB] = await Promise.all([
prisma.team.findUnique({ where: { id: teamAId }, select: { activePlayers: true } }),
prisma.team.findUnique({ where: { id: teamBId }, select: { activePlayers: true } })
])
if (!teamA || !teamB) {
return NextResponse.json({ error: 'Team not found' }, { status : 404 })
}
/* ▸ Match & MatchPlayers in einer Transaktion anlegen ----------------------- */
const result = await prisma.$transaction(async (tx) => {
/* 1. Match */
const newMatch = await tx.match.create({
data: {
teamAId,
teamBId,
title : title?.trim() || `${teamAId}-${teamBId}`,
description : description?.trim() || null,
map : map?.trim() || null,
matchDate : new Date(matchDate)
}
})
/* 2. Spieler-Datensätze vorbereiten */
const playersData = [
...teamA.activePlayers.map((steamId: string) => ({
matchId: newMatch.matchId,
steamId,
teamId : teamAId
})),
...teamB.activePlayers.map((steamId: string) => ({
matchId: newMatch.matchId,
steamId,
teamId : teamBId
}))
]
/* 3. Anlegen (nur wenn Spieler vorhanden) */
if (playersData.length) {
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
}
return newMatch
})
return NextResponse.json(result, { status: 201 })
} catch (err) {
console.error('POST /matches/create failed:', err)
return NextResponse.json({ error: 'Failed to create match' }, { status : 500 })
}
}

View File

@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET() {
try {
const matches = await prisma.match.findMany({
orderBy: { matchDate: 'asc' },
include: {
teamA: true,
teamB: true,
players: {
include: {
user: true,
stats: true,
},
},
},
})
return NextResponse.json(matches)
} catch (err) {
console.error('GET /matches failed:', err)
return NextResponse.json({ error: 'Failed to load matches' }, { status: 500 })
}
}

View File

@ -0,0 +1,55 @@
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { NextResponse, type NextRequest } from 'next/server'
// ✅ Neue Notification erstellen
export async function POST(req: NextRequest) {
const { userId, title, message } = await req.json()
if (!userId || !title || !message) {
return NextResponse.json({ message: 'Fehlende Felder' }, { status: 400 })
}
const notification = await prisma.notification.create({
data: {
userId,
title,
message,
}
})
return NextResponse.json({ notification })
}
// ✅ Notifications für aktuellen User laden
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.id) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const notifications = await prisma.notification.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
})
return NextResponse.json({ notifications })
}
// ✅ Alle Notifications auf "gelesen" setzen
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.id) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
await prisma.notification.updateMany({
where: { userId: session.user.id, read: false },
data: { read: true },
})
return NextResponse.json({ message: 'Alle Notifications gelesen' })
}

View File

@ -0,0 +1,20 @@
// /api/notifications/mark-all-read/route.ts
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
await prisma.notification.updateMany({
where: { userId: session.user.steamId, read: false },
data: { read: true },
})
return NextResponse.json({ message: 'Alle Notifications als gelesen markiert' })
}

View File

@ -0,0 +1,39 @@
// app/api/notifications/mark-read/[id]/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
const param = await params
const notificationId = param.id
try {
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
})
if (!notification || notification.userId !== session.user.id) {
return NextResponse.json({ error: 'Nicht gefunden oder nicht erlaubt' }, { status: 403 })
}
await prisma.notification.update({
where: { id: notificationId },
data: { read: true },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('[Notification] Fehler beim Markieren:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,20 @@
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const notifications = await prisma.notification.findMany({
where: { userId: session.user.steamId },
orderBy: { createdAt: 'desc' },
take: 10,
})
return NextResponse.json({ notifications })
}

View File

@ -0,0 +1,31 @@
// /api/notifications/user/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const notifications = await prisma.notification.findMany({
where: {
userId: session.user.steamId,
},
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
message: true,
read: true,
actionType: true,
actionData: true,
createdAt: true,
},
})
return NextResponse.json({ notifications })
}

View File

@ -0,0 +1,37 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: 'Nicht eingeloggt oder Steam ID fehlt' },
{ status: 401 }
)
}
const steamId = session.user.id
const apiKey = process.env.STEAM_API_KEY
if (!apiKey) {
return NextResponse.json(
{ error: 'STEAM_API_KEY nicht gesetzt' },
{ status: 500 }
)
}
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${apiKey}&steamids=${steamId}`
const res = await fetch(url)
if (!res.ok) {
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Steam-Daten' },
{ status: res.status }
)
}
const data = await res.json()
return NextResponse.json(data.response.players[0])
}

View File

@ -0,0 +1,34 @@
// ✅ /api/team/[teamId]/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET(
req: NextRequest,
{ params }: { params: { teamId: string } }
) {
try {
const param = await params
const team = await prisma.team.findUnique({
where: { id: param.teamId },
})
if (!team) {
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
}
const activePlayers = await prisma.user.findMany({
where: { steamId: { in: team.activePlayers } },
select: { steamId: true, name: true, avatar: true, location: true },
})
const inactivePlayers = await prisma.user.findMany({
where: { steamId: { in: team.inactivePlayers } },
select: { steamId: true, name: true, avatar: true, location: true },
})
return NextResponse.json(team)
} catch (error) {
console.error('Fehler beim Laden des Teams:', error)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET() {
try {
const allUsers = await prisma.user.findMany({
where: {
team: null,
},
select: {
steamId: true,
name: true,
avatar: true,
location: true,
},
orderBy: {
name: 'asc',
},
})
return NextResponse.json({ users: allUsers })
} catch (error) {
console.error('Fehler beim Laden der verfügbaren Benutzer:', error)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
}
}

View File

@ -0,0 +1,25 @@
// /pages/api/team/change-logo.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/app/lib/prisma'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { teamId, logoUrl } = req.body
if (!teamId || !logoUrl) {
return res.status(400).json({ error: 'Team-ID oder Logo-URL fehlt' })
}
try {
await prisma.team.update({
where: { id: teamId },
data: { logo: logoUrl },
})
return res.status(200).json({ success: true })
} catch (err) {
console.error(err)
return res.status(500).json({ error: 'Logo konnte nicht geändert werden' })
}
}

View File

@ -0,0 +1,60 @@
// /api/team/create/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma';
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client';
export async function POST(req: NextRequest) {
try {
const { teamname, leader } = await req.json();
if (!teamname || !leader) {
return NextResponse.json({ message: 'Fehlende Eingaben.' }, { status: 400 });
}
const existingTeam = await prisma.team.findFirst({ where: { name: teamname } });
if (existingTeam) {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 });
}
const existingUser = await prisma.user.findUnique({ where: { steamId: leader } });
if (!existingUser) {
return NextResponse.json({ message: 'Benutzer nicht gefunden.' }, { status: 404 });
}
const newTeam = await prisma.team.create({
data: {
name: teamname,
leaderId: leader,
activePlayers: [leader],
inactivePlayers: [],
},
});
await prisma.user.update({
where: { steamId: leader },
data: { teamId: newTeam.id },
});
await prisma.notification.create({
data: {
userId: leader,
title: 'Team erstellt',
message: `Du hast erfolgreich das Team "${teamname}" erstellt.`,
},
});
// 📢 WebSocket Nachricht senden
await sendServerWebSocketMessage({
type: 'team-created',
title: 'Team erstellt',
message: `Das Team "${teamname}" wurde erstellt.`,
teamId: newTeam.id,
});
return NextResponse.json({ message: 'Team erstellt', team: newTeam });
} catch (error: any) {
console.error('Fehler beim Team erstellen:', error.message, error.stack);
return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 });
}
}

View File

@ -0,0 +1,21 @@
// /pages/api/team/delete.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/app/lib/prisma'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { teamId } = req.body
if (!teamId) {
return res.status(400).json({ error: 'Team-ID fehlt' })
}
try {
await prisma.team.delete({ where: { id: teamId } })
return res.status(200).json({ success: true })
} catch (err) {
console.error(err)
return res.status(500).json({ error: 'Team konnte nicht gelöscht werden' })
}
}

View File

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET(req: NextRequest) {
const teamId = req.nextUrl.searchParams.get('id')
if (!teamId) {
return NextResponse.json({ message: 'Team-ID fehlt.' }, { status: 400 })
}
try {
const team = await prisma.team.findUnique({
where: { id: teamId },
include: {
leader: {
select: {
steamId: true,
name: true,
avatar: true,
location: true,
},
},
},
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
}
const steamIds = [...team.activePlayers, ...team.inactivePlayers]
const players = await prisma.user.findMany({
where: { steamId: { in: steamIds } },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
},
})
return NextResponse.json({
team: {
id: team.id,
teamname: team.name,
logo: team.logo,
leader: team.leader,
activePlayers: players.filter(p => team.activePlayers.includes(p.steamId)),
inactivePlayers: players.filter(p => team.inactivePlayers.includes(p.steamId)),
},
})
} catch (err) {
console.error('[GET /api/team/get] Fehler:', err)
return NextResponse.json({ message: 'Interner Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,67 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { teamId, userIds: rawUserIds, invitedBy } = body
if (!teamId || !rawUserIds || !invitedBy) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
}
const userIds = rawUserIds.filter((id: string) => id !== invitedBy)
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { name: true },
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
const teamName = team.name || 'Unbekanntes Team'
const results = await Promise.all(
userIds.map(async (userId: string) => {
const invitation = await prisma.invitation.create({
data: {
userId,
teamId,
type: 'team-invite',
},
})
const notification = await prisma.notification.create({
data: {
userId,
title: 'Teameinladung',
message: `Du wurdest in das Team "${teamName}" eingeladen.`,
actionType: 'team-invite',
actionData: invitation.id,
},
})
await sendServerWebSocketMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [userId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
})
return invitation.id
})
)
return NextResponse.json({ message: 'Einladungen versendet', invitationIds: results })
} catch (error) {
console.error('Fehler beim Versenden der Einladungen:', error)
return NextResponse.json({ message: 'Fehler beim Einladen' }, { status: 500 })
}
}

View File

@ -0,0 +1,96 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const { teamId, steamId } = await req.json()
if (!teamId || !steamId) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
}
const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
const user = await prisma.user.findUnique({
where: { steamId },
select: { name: true }
})
const userName = user?.name ?? 'Ein Mitglied'
const teamName = team.name ?? 'Unbekanntes Team'
const active = team.activePlayers.filter((id) => id !== steamId)
const inactive = team.inactivePlayers.filter((id) => id !== steamId)
await prisma.team.update({
where: { id: teamId },
data: {
activePlayers: { set: active },
inactivePlayers: { set: inactive },
},
})
await prisma.user.update({
where: { steamId },
data: { teamId: null },
})
// 🟥 Gekickter User>
const notification = await prisma.notification.create({
data: {
userId: steamId,
title: 'Teamverlassen',
message: `Du wurdest aus dem Team "${teamName}" geworfen.`,
actionType: 'team-kick',
actionData: null,
},
})
await sendServerWebSocketMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [steamId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
})
// 🟩 Verbleibende Mitglieder
const remainingUserIds = [...active, ...inactive]
await Promise.all(
remainingUserIds.map(async (userId) => {
const notification = await prisma.notification.create({
data: {
userId,
title: 'Teamupdate',
message: `${userName} wurde aus dem Team "${teamName}" geworfen.`,
actionType: 'team-kick-other',
actionData: null,
},
})
await sendServerWebSocketMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [userId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
})
})
)
return NextResponse.json({ message: 'Mitglied entfernt' })
} catch (error) {
console.error('[KICK] Fehler:', error)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,113 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { removePlayerFromTeam } from '@/app/lib/removePlayerFromTeam'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
export async function POST(req: NextRequest) {
try {
const { steamId } = await req.json()
if (!steamId) {
return NextResponse.json({ message: 'Steam ID fehlt' }, { status: 400 })
}
const team = await prisma.team.findFirst({
where: {
OR: [
{ activePlayers: { has: steamId } },
{ inactivePlayers: { has: steamId } },
],
},
})
if (!team) {
return NextResponse.json({ message: 'Kein Team gefunden.' }, { status: 404 })
}
const { activePlayers, inactivePlayers, leader } = removePlayerFromTeam(
{
activePlayers: team.activePlayers,
inactivePlayers: team.inactivePlayers,
leader: team.leaderId,
}, steamId)
if (!leader) {
await prisma.team.delete({ where: { id: team.id } })
} else {
await prisma.team.update({
where: { id: team.id },
data: {
leader: {
connect: { steamId: leader },
},
activePlayers,
inactivePlayers,
},
})
}
await prisma.user.update({
where: { steamId },
data: { teamId: null },
})
const user = await prisma.user.findUnique({
where: { steamId },
select: { name: true },
})
const notification = await prisma.notification.create({
data: {
userId: steamId,
title: 'Teamupdate',
message: `Du hast das Team "${team.name}" verlassen.`,
actionType: 'team-left',
actionData: null,
},
})
await sendServerWebSocketMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [steamId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
})
const allRemainingPlayers = Array.from(new Set([
...activePlayers,
...inactivePlayers,
])).filter(id => id !== steamId)
await Promise.all(
allRemainingPlayers.map(async (userId) => {
const notification = await prisma.notification.create({
data: {
userId,
title: 'Teamupdate',
message: `${user?.name ?? 'Ein Spieler'} hat das Team verlassen.`,
actionType: 'team-member-left',
actionData: null,
},
})
await sendServerWebSocketMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [userId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
})
})
)
return NextResponse.json({ message: 'Erfolgreich aus dem Team entfernt' })
} catch (error) {
console.error('Fehler beim Verlassen des Teams:', error)
return NextResponse.json({ message: 'Fehler beim Verlassen des Teams' }, { status: 500 })
}
}

View File

@ -0,0 +1,53 @@
// src/app/api/team/list/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
/**
* GET /api/team/list
* Liefert alle Teams inklusive aller Mitglieder (UserObjekte)
* Struktur:
* [
* {
* id, teamname, logo, leader, createdAt,
* players: [ { steamId, name, avatar, location } ]
* },
*
* ]
*/
export async function GET() {
try {
/* 1. Alle Teams holen */
const teams = await prisma.team.findMany()
/* 2. Für jedes Team die zugehörigen UserDatensätze besorgen */
const teamsWithPlayers = await Promise.all(
teams.map(async (t) => {
const steamIds = [...t.activePlayers, ...t.inactivePlayers]
// User abrufen, die in active+inactive vorkommen
const players = await prisma.user.findMany({
where: { steamId: { in: steamIds } },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
},
})
return {
...t,
players,
}
})
)
return NextResponse.json({ teams: teamsWithPlayers }, { status: 200 })
} catch (err) {
console.error('GET /api/team/list failed:', err)
return NextResponse.json(
{ message: 'Interner Serverfehler' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,32 @@
// /app/api/team/rename/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
export async function POST(req: NextRequest) {
try {
const { teamId, newName } = await req.json()
if (!teamId || !newName) {
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
}
await prisma.team.update({
where: { id: teamId },
data: { name: newName },
})
// 🔔 WebSocket Nachricht an alle User (global)
await sendServerWebSocketMessage({
type: 'team-renamed',
title: 'Team umbenannt!',
message: `Das Team wurde umbenannt in "${newName}".`,
teamId,
})
return NextResponse.json({ success: true })
} catch (err) {
console.error('Fehler beim Umbenennen:', err)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,82 @@
// src/app/api/team/request-join/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
export async function POST(req: NextRequest) {
try {
/* ---- Session prüfen ------------------------------------------ */
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const requesterSteamId = session.user.steamId
/* ---- Body validieren ----------------------------------------- */
const { teamId } = await req.json()
if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
}
/* ---- Team holen ---------------------------------------------- */
const team = await prisma.team.findUnique({ where: { id: teamId } })
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
/* ---- Bereits Mitglied? --------------------------------------- */
if (
requesterSteamId === team.leaderId ||
team.activePlayers.includes(requesterSteamId) ||
team.inactivePlayers.includes(requesterSteamId)
) {
return NextResponse.json({ message: 'Du bist bereits Mitglied' }, { status: 400 })
}
/* ---- Doppelte Anfrage vermeiden ------------------------------ */
const existingInvite = await prisma.invitation.findFirst({
where: { userId: requesterSteamId, teamId },
})
if (existingInvite) {
return NextResponse.json({ message: 'Anfrage läuft bereits' }, { status: 200 })
}
/* ---- Invitation anlegen -------------------------------------- */
await prisma.invitation.create({
data: {
userId: requesterSteamId, // User.steamId
teamId,
type: 'team-join-request',
},
})
/* ---- Leader benachrichtigen ---------------------------------- */
const notification = await prisma.notification.create({
data: {
userId: team.leaderId!,
title: 'Beitrittsanfrage',
message: `${session.user.name ?? 'Ein Spieler'} möchte deinem Team beitreten.`,
actionType: 'team-join-request',
actionData: teamId,
},
})
/* ---- WebSocket Event (optional) ------------------------------ */
await sendServerWebSocketMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [team.leaderId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
})
return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 })
} catch (err) {
console.error('POST /api/team/request-join', err)
return NextResponse.json({ message: 'Interner Serverfehler' }, { status: 500 })
}
}

76
src/app/api/team/route.ts Normal file
View File

@ -0,0 +1,76 @@
import { getServerSession } from 'next-auth'
import { baseAuthOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET() {
const session = await getServerSession(baseAuthOptions)
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
try {
const team = await prisma.team.findFirst({
where: {
OR: [
{ leader: { steamId: session.user.steamId} },
{ activePlayers: { has: session.user.steamId } },
{ inactivePlayers: { has: session.user.steamId } },
],
},
include: {
matchesAsTeamA: {
include: {
teamA: true,
teamB: true,
}
},
matchesAsTeamB: {
include: {
teamA: true,
teamB: true,
}
}
}
})
if (!team) {
return NextResponse.json({ team: null }, { status: 200 })
}
const steamIds = [...team.activePlayers, ...team.inactivePlayers]
const playerData = await prisma.user.findMany({
where: { steamId: { in: steamIds } },
select: { steamId: true, name: true, avatar: true, location: true },
})
const activePlayers = team.activePlayers
.map((id) => playerData.find((m) => m.steamId === id))
.filter(Boolean)
const inactivePlayers = team.inactivePlayers
.map((id) => playerData.find((m) => m.steamId === id))
.filter(Boolean)
const matches = [...team.matchesAsTeamA, ...team.matchesAsTeamB]
.filter(m => m.teamA && m.teamB)
.sort((a, b) => new Date(a.matchDate).getTime() - new Date(b.matchDate).getTime())
return NextResponse.json({
team: {
id: team.id,
teamname: team.name,
logo: team.logo,
leader: team.leaderId,
activePlayers,
inactivePlayers,
matches,
},
})
} catch (error) {
console.error('Fehler in /api/team:', error)
return NextResponse.json({ error: 'Interner Fehler beim Laden des Teams' }, { status: 500 })
}
}

View File

@ -0,0 +1,55 @@
// /app/api/team/transfer-leader/route.ts
import { prisma } from '@/app/lib/prisma'
import { NextResponse, type NextRequest } from 'next/server'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
export async function POST(req: NextRequest) {
try {
const { teamId, newLeaderSteamId } = await req.json()
if (!teamId || !newLeaderSteamId) {
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
}
const team = await prisma.team.findUnique({
where: { id: teamId },
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
}
const allPlayerIds = Array.from(new Set([
...(team.activePlayers || []),
...(team.inactivePlayers || []),
]))
if (!allPlayerIds.includes(newLeaderSteamId)) {
return NextResponse.json({ message: 'Neuer Leader ist kein Teammitglied.' }, { status: 400 })
}
await prisma.team.update({
where: { id: teamId },
data: { leader: newLeaderSteamId },
})
const newLeader = await prisma.user.findUnique({
where: { steamId: newLeaderSteamId },
select: { name: true },
})
await sendServerWebSocketMessage({
type: 'team-leader-changed',
title: 'Neuer Teamleader',
message: `${newLeader?.name ?? 'Ein Spieler'} ist jetzt Teamleader.`,
teamId,
targetUserIds: allPlayerIds,
})
return NextResponse.json({ message: 'Leader erfolgreich übertragen.' })
} catch (error) {
console.error('Fehler beim Leaderwechsel:', error)
return NextResponse.json({ message: 'Serverfehler beim Leaderwechsel.' }, { status: 500 })
}
}

View File

@ -0,0 +1,32 @@
// ✅ /api/team/update-players/route.ts
import { prisma } from '@/app/lib/prisma'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
try {
const { teamId, activePlayers, inactivePlayers } = await req.json()
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
}
await prisma.team.update({
where: { id: teamId },
data: { activePlayers, inactivePlayers },
})
const allSteamIds = [...activePlayers, ...inactivePlayers]
await sendServerWebSocketMessage({
type: 'team-updated',
teamId,
targetUserIds: allSteamIds,
})
return NextResponse.json({ message: 'Team-Mitglieder erfolgreich aktualisiert' })
} catch (error) {
console.error('Fehler beim Aktualisieren der Team-Mitglieder:', error)
return NextResponse.json({ error: 'Serverfehler beim Aktualisieren' }, { status: 500 })
}
}

View File

@ -0,0 +1,58 @@
// /api/team/upload-logo/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { writeFile, mkdir, unlink } from 'fs/promises'
import { join, dirname } from 'path'
import { randomUUID } from 'crypto'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
export async function POST(req: NextRequest) {
const formData = await req.formData()
const file = formData.get('logo') as File
const teamId = formData.get('teamId') as string
if (!file || !teamId) {
return NextResponse.json({ message: 'Ungültige Daten' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop()
const filename = `${teamId}-${randomUUID()}.${ext}`
const filepath = join(process.cwd(), 'public/assets/img/logos', filename)
await mkdir(dirname(filepath), { recursive: true })
// Prisma laden und altes Logo abfragen
const { prisma } = await import('@/app/lib/prisma')
const existingTeam = await prisma.team.findUnique({
where: { id: teamId },
select: { logo: true },
})
// Altes Logo löschen (falls vorhanden)
if (existingTeam?.logo) {
const oldPath = join(process.cwd(), 'public/assets/img/logos', existingTeam.logo)
try {
await unlink(oldPath)
} catch (err) {
console.warn('Altes Logo konnte nicht gelöscht werden (evtl. nicht vorhanden):', err)
}
}
// Neues Logo speichern
await writeFile(filepath, buffer)
// Team in DB aktualisieren
await prisma.team.update({
where: { id: teamId },
data: { logo: filename },
})
await sendServerWebSocketMessage({
type: 'team-logo-updated',
title: 'Team-Logo hochgeladen!',
message: `Das Teamlogo wurde aktualisiert.`,
teamId
});
return NextResponse.json({ success: true, filename })
}

View File

@ -0,0 +1,135 @@
// /api/user/invitations/[action]/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { sendServerWebSocketMessage } from '@/app/lib/websocket-server-client'
export const dynamic = 'force-dynamic'
export async function POST(
req: NextRequest,
{ params }: { params: { action: string } }
) {
try {
const param = await params
const action = param.action
const { invitationId } = await req.json()
if (!invitationId) {
return NextResponse.json({ message: 'Invitation ID fehlt' }, { status: 400 })
}
const invitation = await prisma.invitation.findUnique({ where: { id: invitationId } })
if (!invitation) {
return NextResponse.json({ message: 'Einladung existiert nicht mehr' }, { status: 404 })
}
const { userId, teamId, type } = invitation
if (action === 'accept') {
await prisma.user.update({ where: { steamId: userId }, data: { teamId } })
await prisma.team.update({
where: { id: teamId },
data: { inactivePlayers: { push: userId } },
})
await prisma.invitation.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({
where: { userId, actionType: 'team-invite', actionData: invitationId },
data: { read: true, actionType: null, actionData: null },
})
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { name: true, activePlayers: true, inactivePlayers: true },
})
const allSteamIds = Array.from(new Set([
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
]))
const notification = await prisma.notification.create({
data: {
userId,
title: 'Teambeitritt',
message: `Du bist dem Team "${team?.name ?? 'Unbekannt'}" beigetreten.`,
actionType: 'team-joined',
actionData: teamId,
},
})
await sendServerWebSocketMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [userId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
})
const joiningUser = await prisma.user.findUnique({
where: { steamId: userId },
select: { name: true },
})
const otherUserIds = allSteamIds.filter(id => id !== userId)
await Promise.all(
otherUserIds.map(async (otherUserId) => {
const notification = await prisma.notification.create({
data: {
userId: otherUserId,
title: 'Neues Mitglied',
message: `${joiningUser?.name ?? 'Ein Spieler'} ist deinem Team beigetreten.`,
actionType: 'team-member-joined',
actionData: userId,
},
})
await sendServerWebSocketMessage({
type: notification.actionType ?? 'notification',
targetUserIds: [otherUserId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
})
})
)
return NextResponse.json({ message: 'Einladung angenommen' })
}
if (action === 'reject') {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { name: true },
})
await prisma.invitation.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({
where: { userId, actionData: invitationId },
data: { read: true, actionType: null, actionData: null },
})
const eventType = type === 'team-join-request'
? 'team-join-request-reject'
: 'team-invite-reject'
await sendServerWebSocketMessage({
type: eventType,
targetUserIds: [userId],
message: `Einladung zu Team "${team?.name}" wurde abgelehnt.`,
})
return NextResponse.json({ message: 'Einladung abgelehnt' })
}
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
} catch (error) {
console.error('Fehler bei Einladung:', error)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,37 @@
// src/app/api/user/invitations/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const invitations = await prisma.invitation.findMany({
where: {
userId: session.user.steamId,
},
select: {
id: true,
teamId: true,
createdAt: true,
type: true,
team: {
select: {
name: true,
}
}
},
orderBy: { createdAt: 'desc' }
})
return NextResponse.json({ invitations })
} catch (err) {
console.error('Fehler beim Laden der Einladungen:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
}

30
src/app/api/user/route.ts Normal file
View File

@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
const user = await prisma.user.findUnique({
where: { steamId },
select: {
name: true,
steamId: true,
avatar: true,
team: true,
isAdmin: true,
},
})
if (!user) {
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 })
}
return NextResponse.json(user)
}

View File

@ -0,0 +1,153 @@
'use client'
import { ReactNode, forwardRef, useState, useRef, useEffect } from 'react'
type ButtonProps = {
title?: string
children?: ReactNode
onClick?: () => void
onToggle?: (open: boolean) => void
modalId?: string
color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'transparent'
variant?: 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link'
size?: 'sm' | 'md' | 'lg'
className?: string
dropDirection?: "up" | "down" | "auto"
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
title,
children,
onClick,
onToggle,
modalId,
color = 'blue',
variant = 'solid',
size = 'md',
className,
dropDirection = "down"
},
ref
) {
const [open, setOpen] = useState(false)
const [direction, setDirection] = useState<'up' | 'down'>('down')
const localRef = useRef<HTMLButtonElement>(null)
const buttonRef = (ref as React.RefObject<HTMLButtonElement>) || localRef
const modalAttributes: { [key: string]: string } = modalId
? {
'aria-haspopup': 'dialog',
'aria-expanded': 'false',
'aria-controls': modalId,
'data-hs-overlay': `#${modalId}`,
}
: {}
const sizeClasses: Record<string, string> = {
sm: 'py-2 px-3',
md: 'py-3 px-4',
lg: 'p-4 sm:p-5',
}
const base = `
${sizeClasses[size] || sizeClasses['md']}
inline-flex items-center gap-x-2 text-sm font-medium rounded-lg
focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none
`
const variants: Record<string, Record<string, string>> = {
solid: {
blue: 'bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700',
red: 'bg-red-600 text-white hover:bg-red-700 focus:bg-red-700',
gray: 'bg-gray-600 text-white hover:bg-gray-700 focus:bg-gray-700',
teal: 'bg-teal-600 text-white hover:bg-teal-700 focus:bg-teal-700',
green: 'bg-green-600 text-white hover:bg-green-700 focus:bg-green-700',
transparent: 'bg-transparent-600 text-white hover:bg-transparent-700 focus:bg-transparent-700',
},
outline: {
blue: 'border border-gray-200 text-gray-500 hover:border-blue-600 hover:text-blue-600 focus:border-blue-600 focus:text-blue-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-blue-500 dark:hover:border-blue-600 dark:focus:text-blue-500 dark:focus:border-blue-600',
red: 'border border-gray-200 text-gray-500 hover:border-red-600 hover:text-red-600 focus:border-red-600 focus:text-red-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-red-500 dark:hover:border-red-600 dark:focus:text-red-500 dark:focus:border-red-600',
gray: 'border border-gray-200 text-gray-500 hover:border-gray-600 hover:text-gray-600 focus:border-gray-600 focus:text-gray-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-white dark:hover:border-neutral-600 dark:focus:text-white dark:focus:border-neutral-600',
teal: 'border border-teal-200 text-teal-500 hover:border-teal-600 hover:text-teal-600 focus:border-teal-600 focus:text-teal-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-white dark:hover:border-neutral-600 dark:focus:text-white dark:focus:border-neutral-600',
green: 'border border-green-200 text-green-500 hover:border-green-600 hover:text-green-600 focus:border-green-600 focus:text-green-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-white dark:hover:border-neutral-600 dark:focus:text-white dark:focus:border-neutral-600',
transparent: 'border border-transparent-200 text-transparent-500 hover:border-transparent-600 hover:text-transparent-600 focus:border-transparent-600 focus:text-transparent-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-white dark:hover:border-neutral-600 dark:focus:text-white dark:focus:border-neutral-600',
},
ghost: {
blue: 'border border-transparent text-blue-600 hover:bg-blue-100 hover:text-blue-800 focus:bg-blue-100 focus:text-blue-800 dark:text-blue-500 dark:hover:bg-blue-800/30 dark:hover:text-blue-400 dark:focus:bg-blue-800/30 dark:focus:text-blue-400',
red: 'border border-transparent text-red-600 hover:bg-red-100 hover:text-red-800 focus:bg-red-100 focus:text-red-800 dark:text-red-500 dark:hover:bg-red-800/30 dark:hover:text-red-400 dark:focus:bg-red-800/30 dark:focus:text-red-400',
gray: 'border border-transparent text-gray-600 hover:bg-gray-100 hover:text-gray-800 focus:bg-gray-100 focus:text-gray-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white',
teal: 'border border-transparent text-teal-600 hover:bg-teal-100 hover:text-teal-800 focus:bg-teal-100 focus:text-teal-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white',
green: 'border border-transparent text-green-600 hover:bg-green-100 hover:text-green-800 focus:bg-green-100 focus:text-green-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white',
transparent: 'border border-transparent text-transparent-600 hover:bg-transparent-100 hover:text-transparent-800 focus:bg-transparent-100 focus:text-transparent-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white',
},
soft: {
blue: 'bg-blue-100 text-blue-800 hover:bg-blue-200 focus:bg-blue-200 dark:text-blue-400 dark:hover:bg-blue-900 dark:focus:bg-blue-900',
red: 'bg-red-100 text-red-800 hover:bg-red-200 focus:bg-red-200 dark:text-red-400 dark:hover:bg-red-900 dark:focus:bg-red-900',
gray: 'bg-gray-100 text-gray-800 hover:bg-gray-200 focus:bg-gray-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
teal: 'bg-teal-100 text-teal-800 hover:bg-teal-200 focus:bg-teal-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
green: 'bg-green-100 text-green-800 hover:bg-green-200 focus:bg-green-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
transparent: 'bg-transparent-100 text-transparent-800 hover:bg-transparent-200 focus:bg-transparent-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
},
white: {
blue: 'border border-teal-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
red: 'border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
gray: 'border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
teal: 'border border-teal-200 bg-white text-teal-800 shadow-2xs hover:bg-teal-50 focus:bg-teal-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
green: 'border border-green-200 bg-white text-green-800 shadow-2xs hover:bg-green-50 focus:bg-green-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
transparent: 'border border-transparent-200 bg-white text-transparent-800 shadow-2xs hover:bg-transparent-50 focus:bg-transparent-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
},
link: {
blue: 'border border-transparent text-blue-600 hover:text-blue-800 focus:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400 dark:focus:text-blue-400',
red: 'border border-transparent text-red-600 hover:text-red-800 focus:text-red-800 dark:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400',
gray: 'border border-transparent text-gray-600 hover:text-gray-800 focus:text-gray-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white',
teal: 'border border-transparent text-teal-600 hover:text-teal-800 focus:text-teal-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white',
green: 'border border-transparent text-green-600 hover:text-green-800 focus:text-green-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white',
transparent: 'border border-transparent text-transparent-600 hover:text-transparent-800 focus:text-transparent-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white'
},
}
const classes = `
${base}
${variants[variant]?.[color] || variants.solid.blue}
${className || ''}
`
useEffect(() => {
if (open && dropDirection === "auto" && buttonRef.current) {
requestAnimationFrame(() => {
const rect = buttonRef.current!.getBoundingClientRect();
const dropdownHeight = 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
setDirection("up");
} else {
setDirection("down");
}
});
}
}, [open, dropDirection]);
const toggle = () => {
const next = !open
setOpen(next)
onToggle?.(next)
onClick?.()
}
return (
<button
ref={buttonRef}
type="button"
className={classes}
onClick={toggle}
{...modalAttributes}
>
{children ?? title}
</button>
)
})
export default Button

View File

@ -0,0 +1,59 @@
'use client'
type CardWidth =
| 'sm' // 24rem (maxwsm)
| 'md' // 28rem (maxwmd)
| 'lg' // 32rem (maxwlg)
| 'xl' // 36rem (maxwxl)
| '2xl' // 42rem (maxw2xl)
| 'full' // 100% (wfull)
| 'auto' // keine Begrenzung
type CardProps = {
title?: string
description?: string
children?: React.ReactNode
/** links, rechts oder (Default) zentriert */
align?: 'left' | 'right' | 'center'
/** gewünschte MaxBreite (Default: lg) */
maxWidth?: CardWidth
}
export default function Card({
children,
align = 'center',
maxWidth = 'lg'
}: CardProps) {
/* Ausrichtung bestimmen */
const alignClasses =
align === 'left'
? 'mr-auto'
: align === 'right'
? 'ml-auto'
: 'mx-auto' // center
/* Breite in TailwindKlasse übersetzen */
const widthClasses: Record<CardWidth, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
full: 'w-full',
auto: '' // keine Begrenzung
}
return (
<div
className={`
flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
dark:bg-neutral-900 dark:border-neutral-700 dark:shadow-neutral-700/70
${alignClasses} ${widthClasses[maxWidth]}
`}
>
<div className="p-3">
{children && <div>{children}</div>}
</div>
</div>
)
}

View File

@ -0,0 +1,80 @@
'use client'
type ComboBoxProps = {
value: string
items?: string[]
onSelect: (value: string) => void
}
export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
return (
<div id="hs-combobox-basic-usage" className="relative" data-hs-combo-box="">
<div className="relative">
<input
className="py-2.5 sm:py-3 ps-4 pe-9 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"
type="text"
role="combobox"
aria-expanded="false"
value={value}
data-hs-combo-box-input=""
readOnly
/>
<div
className="absolute top-1/2 end-3 -translate-y-1/2"
aria-expanded="false"
role="button"
data-hs-combo-box-toggle=""
>
<svg
className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</div>
<div
className="absolute z-50 w-full max-h-72 p-1 mt-1 bg-white border border-gray-200 rounded-lg overflow-y-auto dark:bg-neutral-900 dark:border-neutral-700"
style={{ display: 'none' }}
role="listbox"
data-hs-combo-box-output=""
>
{items?.map((item, idx) => (
<div
key={idx}
className="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg dark:text-neutral-200 dark:hover:bg-neutral-800"
role="option"
tabIndex={0}
onClick={() => onSelect(item)}
data-hs-combo-box-output-item=""
data-hs-combo-box-item-stored-data={JSON.stringify({ id: idx, name: item })}
>
<div className="flex justify-between items-center w-full">
<span data-hs-combo-box-search-text={item} data-hs-combo-box-value="">{item}</span>
{item === value && (
<span className="hs-combo-box-selected:block">
<svg
className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,154 @@
'use client'
import { useEffect, useState, useImperativeHandle, forwardRef } from 'react'
import { useSession } from 'next-auth/react'
import Modal from './Modal'
import Button from './Button'
import { Player, Team } from '../types/team'
type CreateTeamButtonProps = {
setRefetchKey: (key: string) => void
}
const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ setRefetchKey }, ref) => {
const { data: session } = useSession()
const [teamname, setTeamname] = useState('')
const [showModal, setShowModal] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [message, setMessage] = useState('')
const handleSubmit = async () => {
setStatus('idle')
setMessage('')
if (!teamname.trim()) {
setStatus('error')
setMessage('Bitte gib einen Teamnamen ein.')
return
}
try {
const res = await fetch('/api/team/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamname, leader: session?.user?.steamId }),
})
const result = await res.json()
if (!res.ok) {
throw new Error(result.message || 'Fehler beim Erstellen')
}
setStatus('success')
setMessage(`Team "${result.team.teamname}" wurde erfolgreich erstellt!`)
setTeamname('')
setTimeout(() => {
const modalEl = document.getElementById('modal-create-team')
if (modalEl && window.HSOverlay?.close) {
window.HSOverlay.close(modalEl)
}
setShowModal(false)
setRefetchKey(Date.now().toString()) // 🔥 Neuer Key zum Reload
}, 1500)
} catch (err: any) {
setStatus('error')
setMessage(err.message || 'Fehler beim Erstellen des Teams')
}
}
return (
<div>
<Button
onClick={() => setShowModal(true)}
color="blue"
variant="solid"
size="sm"
>
Neues Team erstellen
</Button>
<Modal
id="modal-create-team"
title="Neues Team erstellen"
show={showModal}
onClose={() => setShowModal(false)}
onSave={handleSubmit}
closeButtonTitle="Team erstellen"
>
<div className="max-w-sm space-y-2">
<label htmlFor="teamname" className="block text-sm font-medium mb-1 dark:text-white">
Teamname
</label>
<div className="relative">
<input
id="teamname"
type="text"
value={teamname}
onChange={(e) => {
setTeamname(e.target.value)
setStatus('idle')
setMessage('')
}}
className={`py-2.5 px-4 block w-full rounded-lg sm:text-sm focus:ring-1
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700
${
status === 'error'
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: status === 'success'
? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}
`}
required
name="teamname"
aria-describedby="teamname-feedback"
/>
{status !== 'idle' && (
<div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none">
<svg
className={`shrink-0 size-4 ${status === 'error' ? 'text-red-500' : 'text-teal-500'}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
{status === 'error' ? (
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</>
) : (
<polyline points="20 6 9 17 4 12" />
)}
</svg>
</div>
)}
</div>
{message && (
<p
id="teamname-feedback"
className={`text-sm mt-1 ${
status === 'error' ? 'text-red-600' : 'text-teal-600'
}`}
>
{message}
</p>
)}
</div>
</Modal>
</div>
)
})
CreateTeamButton.displayName = 'CreateTeamButton'
export default CreateTeamButton

View File

@ -0,0 +1,209 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Select from "./Select";
import Button from "./Button";
const months = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember"
];
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() + i);
type DatePickerWithTimeProps = {
value: Date;
onChange: (date: Date) => void;
};
export default function DatePickerWithTime({ value, onChange }: DatePickerWithTimeProps) {
const [showPicker, setShowPicker] = useState(false);
const [month, setMonth] = useState(value.getMonth());
const [year, setYear] = useState(value.getFullYear());
const [hour, setHour] = useState(value.getHours());
const [minute, setMinute] = useState(value.getMinutes());
const [direction, setDirection] = useState<"up" | "down">("down");
const buttonRef = useRef<HTMLButtonElement>(null);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDay = new Date(year, month, 1).getDay();
const offset = (firstDay + 6) % 7; // Mo=0
const formattedDate = `${value.toLocaleDateString("de-DE")} ${value.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})}`;
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
!buttonRef.current?.contains(event.target as Node) &&
!document.getElementById("datepicker-popover")?.contains(event.target as Node)
) {
setShowPicker(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Aktualisiere value bei Uhrzeit-/Datumsauswahl
useEffect(() => {
const newDate = new Date(year, month, value.getDate(), hour, minute);
onChange(newDate);
}, [hour, minute, year, month]);
useEffect(() => {
if (showPicker && buttonRef.current) {
requestAnimationFrame(() => {
const rect = buttonRef.current!.getBoundingClientRect();
const dropdownHeight = 350;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
setDirection("up");
} else {
setDirection("down");
}
});
}
}, [showPicker]);
const handleDayClick = (day: number) => {
const newDate = new Date(year, month, day, hour, minute);
onChange(newDate);
};
return (
<div className="relative w-full">
<Button
ref={buttonRef}
color="transparent"
variant="soft"
onClick={() => setShowPicker((prev) => !prev)}
dropDirection="auto"
className="w-full text-left border border-gray-300 shadow-sm text-sm text-gray-700 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-200"
>
{formattedDate}
</Button>
{showPicker && (
<div
id="datepicker-popover"
className={`absolute z-[9999] w-80 bg-white border border-gray-200 shadow-lg rounded-xl overflow-visible dark:bg-neutral-900 dark:border-neutral-700
${direction === "up" ? "bottom-full mb-2" : "top-full mt-2"}
`}
>
<div className="p-3 space-y-0.5 w-full">
{/* Header: Monat / Jahr / Prev / Next */}
<div className="grid grid-cols-5 items-center gap-x-3 mx-1.5 pb-3">
<div className="col-span-1">
<button
type="button"
onClick={() => setMonth((prev) => (prev === 0 ? 11 : prev - 1))}
className="size-8 flex justify-center items-center text-gray-800 hover:bg-gray-100 rounded-full dark:text-neutral-400 dark:hover:bg-neutral-800"
aria-label="Previous"
>
<svg className="shrink-0 size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="m15 18-6-6 6-6" /></svg>
</button>
</div>
<div className="col-span-3 flex justify-center items-center gap-x-1">
<Select
dropDirection="auto"
options={months.map((m, i) => ({ value: i.toString(), label: m }))}
value={month.toString()}
onChange={(val) => setMonth(Number(val))}
className="min-w-[7rem]"
/>
<span className="text-gray-800 dark:text-neutral-200">/</span>
<Select
dropDirection="auto"
options={years.map((y) => ({ value: y.toString(), label: y.toString() }))}
value={year.toString()}
onChange={(val) => setYear(Number(val))}
className="min-w-[5rem]"
/>
</div>
<div className="col-span-1 flex justify-end">
<button
type="button"
onClick={() => setMonth((prev) => (prev === 11 ? 0 : prev + 1))}
className="size-8 flex justify-center items-center text-gray-800 hover:bg-gray-100 rounded-full dark:text-neutral-400 dark:hover:bg-neutral-800"
aria-label="Next"
>
<svg className="shrink-0 size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="m9 18 6-6-6-6" /></svg>
</button>
</div>
</div>
{/* Wochentage */}
<div className="flex pb-1.5">
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map((d) => (
<span key={d} className="m-px w-10 block text-center text-sm text-gray-500 dark:text-neutral-500">
{d}
</span>
))}
</div>
{/* Kalendertage */}
<div className="flex flex-wrap">
{Array.from({ length: offset }).map((_, i) => (
<div key={`empty-${i}`} className="m-px w-10 h-10" />
))}
{Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1;
const isSelected =
value.getDate() === day &&
value.getMonth() === month &&
value.getFullYear() === year;
return (
<button
key={day}
type="button"
onClick={() => handleDayClick(day)}
className={`m-px size-10 flex justify-center items-center text-sm rounded-full
${isSelected
? "bg-blue-600 text-white font-medium"
: "text-gray-800 hover:border-blue-600 hover:text-blue-600 dark:text-neutral-200 dark:hover:text-blue-500"
}`}
>
{day}
</button>
);
})}
</div>
{/* Uhrzeit-Auswahl */}
<div className="pt-3 flex justify-center items-center gap-x-2">
<Select
dropDirection="auto"
options={Array.from({ length: 24 }, (_, h) => ({
value: h.toString(),
label: h.toString().padStart(2, "0")
}))}
value={hour.toString()}
onChange={(val) => setHour(Number(val))}
/>
<span className="text-gray-800 dark:text-neutral-200">:</span>
<Select
dropDirection="auto"
options={[0, 15, 30, 45].map((m) => ({
value: m.toString(),
label: m.toString().padStart(2, "0")
}))}
value={minute.toString()}
onChange={(val) => setMinute(Number(val))}
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,78 @@
import { useEffect, useRef, useState } from 'react'
export type DropdownItem = {
label: string
icon?: React.ReactNode
onClick?: () => void
disabled?: boolean
}
type DropdownProps = {
items: DropdownItem[]
}
export default function Dropdown({ items }: DropdownProps) {
const [open, setOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
} else {
document.removeEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [open])
return (
<div ref={dropdownRef} className="relative inline-flex">
<button
type="button"
className="hs-dropdown-toggle py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-sm hover:bg-gray-50 focus:outline-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(prev => !prev)}
>
<svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</svg>
</button>
{open && (
<div
className="absolute right-0 mt-2 min-w-60 bg-white shadow-md rounded-lg divide-y divide-gray-200 dark:bg-neutral-800 dark:border dark:border-neutral-700 dark:divide-neutral-700 z-50"
role="menu"
aria-orientation="vertical"
>
<div className="p-1 space-y-0.5">
{items.map((item, index) => (
<button
key={index}
onClick={() => {
item.onClick?.()
setOpen(false)
}}
disabled={item.disabled}
className="w-full text-left flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-none dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 disabled:opacity-50"
>
{item.icon && <span className="shrink-0 w-4 h-4">{item.icon}</span>}
{item.label}
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,29 @@
'use client'
import { useDroppable } from '@dnd-kit/core'
import { Player } from '../types/team'
type DroppableZoneProps = {
id: string
label: string
children: React.ReactNode
activeDragItem: Player | null
}
export function DroppableZone({ id, label, children, activeDragItem }: DroppableZoneProps) {
const { isOver, setNodeRef } = useDroppable({ id })
const baseClasses = `
p-4 rounded-lg border-2 min-h-[200px] transition-all
${isOver ? 'border-blue-400 border-dashed bg-gray-200 dark:bg-neutral-800' : 'border-gray-300 dark:border-neutral-700'}
`
return (
<div ref={setNodeRef} className={baseClasses}>
<h3 className="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">{label}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-4">
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,26 @@
'use client'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
export default function EditButton({ match }: { match: any }) {
const { data: session } = useSession()
const isLeader =
session?.user?.steamId &&
(session.user.steamId === match.teamA.leader ||
session.user.steamId === match.teamB.leader)
if (!isLeader) return null
return (
<div className="mt-6 text-center">
<Link
href={`/matches/${match.id}/edit`}
className="inline-block px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 transition"
>
Match bearbeiten
</Link>
</div>
)
}

View File

@ -0,0 +1,179 @@
'use client'
import { useEffect, useState } from 'react'
import Modal from '@/app/components/Modal'
import MiniCard from '@/app/components/MiniCard'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import { useSession } from 'next-auth/react'
import { Player } from '@/app/types/team'
import { Team } from '@/app/types/team'
type Props = {
show: boolean
onClose: () => void
matchId: string
teamA: Team
teamB: Team
initialPlayersA: string[]
initialPlayersB: string[]
onSaved?: () => void
}
export default function EditMatchPlayersModal({
show,
onClose,
matchId,
teamA,
teamB,
initialPlayersA,
initialPlayersB,
onSaved,
}: Props) {
const { data: session } = useSession()
const [playersA, setPlayersA] = useState<Player[]>([])
const [playersB, setPlayersB] = useState<Player[]>([])
const [selectedA, setSelectedA] = useState<string[]>([])
const [selectedB, setSelectedB] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [saved, setSaved] = useState(false)
const steamId = session?.user?.steamId
const isLeaderA = steamId && teamA?.leader && steamId === teamA.leader
const isLeaderB = steamId && teamB?.leader && steamId === teamB.leader
const isAdmin = session?.user?.isAdmin
const canEdit = isAdmin || isLeaderA || isLeaderB
if (!teamA || !teamB) return <LoadingSpinner />
useEffect(() => {
if (show) {
fetchTeamPlayers()
setSelectedA(initialPlayersA)
setSelectedB(initialPlayersB)
setSaved(false)
}
}, [show])
const fetchTeamPlayers = async () => {
try {
const [resA, resB] = await Promise.all([
fetch(`/api/team/${teamA.id}`).then(res => res.json()),
fetch(`/api/team/${teamB.id}`).then(res => res.json()),
])
setPlayersA(resA.activePlayers || [])
setPlayersB(resB.activePlayers || [])
} catch (err) {
console.error('Fehler beim Laden der Spieler:', err)
}
}
const toggleSelect = (team: 'A' | 'B', steamId: string) => {
if (team === 'A') {
setSelectedA(prev =>
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
)
} else {
setSelectedB(prev =>
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
)
}
}
const handleSave = async () => {
setLoading(true)
try {
const players = [
...selectedA.map(userId => ({ userId, teamId: teamA.id })),
...selectedB.map(userId => ({ userId, teamId: teamB.id })),
]
const res = await fetch(`/api/matches/${matchId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ players }),
})
if (!res.ok) throw new Error('Fehler beim Speichern')
setSaved(true)
onSaved?.()
} catch (err) {
console.error('Speichern fehlgeschlagen:', err)
} finally {
setLoading(false)
}
}
return (
<Modal
id="edit-match-players-modal"
title="Spieler bearbeiten"
show={show}
onClose={onClose}
onSave={handleSave}
closeButtonTitle={saved ? '✓ gespeichert' : 'Speichern'}
closeButtonColor={saved ? 'green' : 'blue'}
>
{!canEdit ? (
<p className="text-sm text-gray-700 dark:text-neutral-300">
Du bist kein Teamleiter dieses Matches.
</p>
) : (
<>
{saved && (
<div className="mb-4 text-green-700 bg-green-100 border border-green-200 rounded px-4 py-2 text-sm">
Änderungen gespeichert
</div>
)}
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="font-semibold mb-2">{teamA.teamname}</h3>
{playersA.length === 0 ? (
<LoadingSpinner />
) : (
<div className="space-y-2">
{playersA.map((p) => (
<MiniCard
key={p.steamId}
title={p.name}
avatar={p.avatar}
steamId={p.steamId}
location={p.location}
selected={selectedA.includes(p.steamId)}
onSelect={() => toggleSelect('A', p.steamId)}
currentUserSteamId={steamId!}
teamLeaderSteamId={teamA.leader}
hideActions
/>
))}
</div>
)}
</div>
<div>
<h3 className="font-semibold mb-2">{teamB.teamname}</h3>
{playersB.length === 0 ? (
<LoadingSpinner />
) : (
<div className="space-y-2">
{playersB.map((p) => (
<MiniCard
key={p.steamId}
title={p.name}
avatar={p.avatar}
steamId={p.steamId}
location={p.location}
selected={selectedB.includes(p.steamId)}
onSelect={() => toggleSelect('B', p.steamId)}
currentUserSteamId={steamId!}
teamLeaderSteamId={teamB.leader}
hideActions
/>
))}
</div>
)}
</div>
</div>
</>
)}
</Modal>
)
}

View File

@ -0,0 +1,39 @@
type InputProps = {
id?: string
label: string
type?: string
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder?: string
disabled?: boolean
}
export default function Input({
id,
label,
type = 'text',
value,
onChange,
placeholder,
disabled = false,
}: InputProps) {
const inputId = id || label.toLowerCase().replace(/\s+/g, '-')
return (
<div className="">
<label htmlFor={inputId} className="block text-sm font-medium mb-2 dark:text-white">
{label}
</label>
<input
id={inputId}
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
className="py-2.5 sm:py-3 px-4 block w-full border border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"
/>
</div>
)
}

View File

@ -0,0 +1,140 @@
'use client'
import { useState, useEffect } from 'react'
import Modal from './Modal'
import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react'
import LoadingSpinner from './LoadingSpinner'
import { Player, Team } from '../types/team'
type Props = {
show: boolean
onClose: () => void
onSuccess: () => void
team: Team
}
export default function InvitePlayersModal({ show, onClose, onSuccess, team }: Props) {
const { data: session } = useSession()
const steamId = session?.user?.steamId
const [allUsers, setAllUsers] = useState<Player[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false)
const [sentCount, setSentCount] = useState(0)
useEffect(() => {
if (show) {
fetchUsersNotInTeam()
setIsSuccess(false) // Status zurücksetzen beim Öffnen
}
}, [show])
const fetchUsersNotInTeam = async () => {
try {
setIsLoading(true);
const res = await fetch('/api/team/available-users')
const data = await res.json()
setAllUsers(data.users || [])
} catch (err) {
console.error('Fehler beim Laden der Benutzer:', err)
}
finally {
setIsLoading(false);
}
}
const handleSelect = (steamId: string) => {
setSelectedIds((prev) =>
prev.includes(steamId) ? prev.filter((id) => id !== steamId) : [...prev, steamId]
)
}
// Entferne das setTimeout aus handleInvite komplett!
const handleInvite = async () => {
if (selectedIds.length === 0 || !steamId) return
try {
const res = await fetch('/api/team/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teamId: team.id,
userIds: selectedIds,
invitedBy: steamId,
}),
})
if (!res.ok) {
const error = await res.json()
console.error('Fehler beim Einladen:', error.message)
} else {
setSentCount(selectedIds.length) // 👈 speichere Anzahl
setIsSuccess(true) // ✅ Erfolg markieren
setSelectedIds([]) // Optional: Selektion leeren
onSuccess() // ⚡ Nur Success-Callback, kein Schließen hier!
}
} catch (err) {
console.error('Fehler beim Einladen:', err)
}
}
useEffect(() => {
if (isSuccess) {
const timeout = setTimeout(() => {
const modalEl = document.getElementById('invite-members-modal')
if (modalEl && window.HSOverlay?.close) {
window.HSOverlay.close(modalEl)
}
onClose()
}, 1500)
return () => clearTimeout(timeout)
}
}, [isSuccess, onClose])
return (
<Modal
id="invite-members-modal"
title="Mitglieder einladen"
show={show}
onClose={onClose}
onSave={handleInvite}
closeButtonColor={isSuccess ? "teal" : "blue"}
closeButtonTitle={isSuccess ? "Einladungen versendet" : "Einladungen senden"}
>
<p className="text-sm text-gray-700 dark:text-neutral-300">
Wähle Benutzer aus, die du in dein Team einladen möchtest:
</p>
{isSuccess && (
<div className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg">
{sentCount} Einladung{sentCount !== 1 ? 'en' : ''} erfolgreich versendet!
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{isLoading ? (
<LoadingSpinner />
) : (
allUsers.map((user) => (
<MiniCard
key={user.steamId}
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected={selectedIds.includes(user.steamId)}
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
hideActions={true}
/>
))
)}
</div>
</Modal>
)
}

View File

@ -0,0 +1,88 @@
'use client'
import { useState, useEffect } from 'react'
import Modal from './Modal'
import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react'
import { Player, Team } from '../types/team'
import { useTeamManager } from '../hooks/useTeamManager'
type Props = {
show: boolean
onClose: () => void
onSuccess: () => void
team: Team
}
export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props) {
const { data: session } = useSession()
const steamId = session?.user?.steamId
const [newLeaderId, setNewLeaderId] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { leaveTeam } = useTeamManager({}, null)
useEffect(() => {
if (show && team.leader) {
setNewLeaderId(team.leader)
}
}, [show, team.leader])
const handleLeave = async () => {
if (!steamId) return
setIsSubmitting(true)
try {
const payload = team.leader === steamId
? { steamId, newLeaderId }
: { steamId }
const success = await leaveTeam(steamId, team.leader === steamId ? newLeaderId : undefined)
if (success) {
onSuccess()
}
} catch (err) {
console.error('Fehler beim Verlassen:', err)
} finally {
setIsSubmitting(false)
onClose()
}
}
return (
<Modal
id="leave-team-modal"
title="Team verlassen"
show={show}
onClose={onClose}
onSave={handleLeave}
closeButtonColor="red"
closeButtonTitle="Team verlassen"
>
<p className="text-sm text-gray-700 dark:text-neutral-300">
Du bist der Teamleader. Bitte wähle ein anderes Mitglied aus, das die Rolle des Leaders übernehmen soll:
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{(team.players ?? [])
.filter((player) => player.steamId !== steamId)
.map((player: Player) => (
<MiniCard
key={player.steamId}
steamId={player.steamId}
title={player.name}
avatar={player.avatar}
location={player.location}
selected={newLeaderId === player.steamId}
onSelect={setNewLeaderId}
isLeader={player.steamId === team.leader}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
hideActions={true}
/>
))}
</div>
</Modal>
)
}

View File

@ -0,0 +1,17 @@
'use client'
export default function LoadingSpinner() {
return (
<div className="flex flex-auto flex-col justify-center items-center p-4">
<div className="flex justify-center">
<div
className="animate-spin inline-block size-6 border-3 border-current border-t-transparent text-blue-600 rounded-full dark:text-blue-500"
role="status"
aria-label="loading"
>
<span className="sr-only">Loading...</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,175 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import { useSession } from 'next-auth/react'
import Button from './Button'
import EditMatchPlayersModal from './EditMatchPlayersModal'
import PlayerCard from './PlayerCard'
import { Match } from '../types/match'
import { Player } from '../types/team'
import MatchTeamCard from './MatchTeamCard'
function getTeamLogo(logo: string | null) {
return logo ? `/assets/img/logos/${logo}` : '/default-logo.png'
}
export default function MatchDetails({ matchId }: { matchId: string }) {
const { data: session } = useSession()
const [match, setMatch] = useState<Match | null>(null)
const [showModal, setShowModal] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
fetch(`/api/matches/${matchId}`)
.then((res) => res.ok ? res.json() : null)
.then((data) => {
// Ensure teamA and teamB match the expected Team type
if (data) {
// Convert undefined to null for logo and leader
const processedData = {
...data,
teamA: {
...data.teamA,
logo: data.teamA.logo || null,
leader: data.teamA.leader || null,
},
teamB: {
...data.teamB,
logo: data.teamB.logo || null,
leader: data.teamB.leader || null,
}
};
setMatch(processedData);
}
})
}, [matchId])
const isTeamLeader =
session?.user?.steamId &&
(session.user.steamId === match?.teamA?.leader || session.user.steamId === match?.teamB?.leader)
const isAdmin = session?.user?.isAdmin
if (!match) return <p className="text-center text-gray-500 mt-8">Match wird geladen </p>
return (
<div className="p-5 md:p-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700">
<div className="max-w-4xl mx-auto">
<div className="rounded">
<div className="bg-black text-white rounded-xl py-6 px-4 flex items-center justify-between text-center">
{/* Team A */}
<div className="flex flex-col items-center w-1/4">
<Image
src={getTeamLogo(match.teamA.logo)}
alt={match.teamA.teamname}
width={64}
height={64}
className="rounded-full border object-cover"
/>
<div className="mt-2 text-sm">{match.teamA.teamname}</div>
</div>
{/* Score + Datum */}
<div className="flex flex-col items-center w-1/2">
<div className="flex items-center justify-center gap-4 text-4xl font-bold mb-2">
<span>{match.scoreA ?? '-'}</span>
<span></span>
<span>{match.scoreB ?? '-'}</span>
</div>
<div className="text-sm text-gray-300">
{new Date(match.matchDate).toLocaleDateString('de-DE')}
<br />
{new Date(match.matchDate).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})} Uhr
</div>
</div>
{/* Team B */}
<div className="flex flex-col items-center w-1/4">
<Image
src={getTeamLogo(match.teamB.logo)}
alt={match.teamB.teamname}
width={64}
height={64}
className="rounded-full border object-cover"
/>
<div className="mt-2 text-sm">{match.teamB.teamname}</div>
</div>
</div>
{/* Beschreibung */}
{match.description && (
<div className="mt-6 text-gray-700 text-center">
<p>{match.description}</p>
</div>
)}
</div>
{/* SpielerListen */}
<div className="flex flex-col gap-6 mt-8 w-full">
<MatchTeamCard
team={match.teamA}
players={match.playersA}
editable={
session?.user?.steamId === match.teamA.leader || session?.user?.isAdmin
}
onEditClick={() => setShowModal(true)}
/>
<MatchTeamCard
team={match.teamB}
players={match.playersB}
editable={
session?.user?.steamId === match.teamA.leader || session?.user?.isAdmin
}
onEditClick={() => setShowModal(true)}
/>
</div>
{/* Modal */}
{match?.teamA && match?.teamB && (
<EditMatchPlayersModal
show={showModal}
onClose={() => setShowModal(false)}
matchId={match.id}
teamA={match.teamA}
teamB={match.teamB}
initialPlayersA={match.playersA.map(p => p.user.steamId)}
initialPlayersB={match.playersB.map(p => p.user.steamId)}
onSaved={() => {
setSaved(true);
// Refresh match data after saving
fetch(`/api/matches/${matchId}`)
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data) {
// Apply the same processing
const processedData = {
...data,
teamA: {
...data.teamA,
logo: data.teamA.logo || null,
leader: data.teamA.leader || null,
},
teamB: {
...data.teamB,
logo: data.teamB.logo || null,
leader: data.teamB.leader || null,
}
};
setMatch(processedData);
}
});
}}
/>
)}
{saved && (
<div className="text-green-600 text-sm text-center mt-4"> Änderungen gespeichert</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,121 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Switch from '@/app/components/Switch'
type Match = {
id: string
title: string
description?: string
matchDate: string
teamA: { id: string; teamname: string; logo?: string | null }
teamB: { id: string; teamname: string; logo?: string | null }
}
function getTeamLogo(logo?: string | null) {
return logo ? `/assets/img/logos/${logo}` : '/default-logo.png'
}
export default function MatchList() {
const { data: session } = useSession()
const [matches, setMatches] = useState<Match[]>([])
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
useEffect(() => {
fetch('/api/matches')
.then((res) => res.ok ? res.json() : [])
.then(setMatches)
.catch((err) => console.error('Fehler beim Laden der Matches:', err))
}, [])
const filteredMatches = onlyOwnTeam && session?.user?.team
? matches.filter(m =>
m.teamA.id === session.user.team || m.teamB.id === session.user.team
)
: matches
return (
<div className="max-w-4xl mx-auto py-8 px-4 space-y-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">Geplante Matches</h1>
{session?.user?.team && (
<Switch
id="only-own-team"
checked={onlyOwnTeam}
onChange={setOnlyOwnTeam}
labelRight="Nur mein Team"
/>
)}
</div>
{filteredMatches.length === 0 ? (
<p className="text-gray-500">Keine Matches geplant.</p>
) : (
<ul className="space-y-4">
{filteredMatches.map((match) => (
<li key={match.id}>
<Link
href={`/matches/${match.id}`}
className="block border rounded p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition"
>
<div className="flex items-center justify-between text-center">
{/* Team A */}
<div className="flex flex-col items-center w-1/4">
<Image
src={getTeamLogo(match.teamA.logo)}
alt={match.teamA.teamname}
width={64}
height={64}
className="rounded-full border object-cover bg-white"
/>
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{match.teamA.teamname}
</span>
</div>
{/* Datum / Zeit */}
<div className="text-sm text-gray-600 dark:text-gray-300 w-1/2">
<div>{new Date(match.matchDate).toLocaleDateString('de-DE')}</div>
<div>{new Date(match.matchDate).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
})} Uhr</div>
</div>
{/* Team B */}
<div className="flex flex-col items-center w-1/4">
<Image
src={getTeamLogo(match.teamB.logo)}
alt={match.teamB.teamname}
width={64}
height={64}
className="rounded-full border object-cover bg-white"
/>
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{match.teamB.teamname}
</span>
</div>
</div>
{/* Match-Titel */}
<div className="mt-3 text-sm font-medium text-center text-gray-700 dark:text-gray-300">
{match.title}
</div>
{/* Match-Beschreibung (optional) */}
{match.description && (
<div className="text-sm text-center text-gray-500 dark:text-gray-400 mt-1">
{match.description}
</div>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -0,0 +1,43 @@
import Table from './Table'
import Image from 'next/image'
import { MatchPlayer } from '@/app/types/match'
type Props = {
player: MatchPlayer
}
export default function MatchPlayerCard({ player }: Props) {
return (
<Table.Row hoverable>
<td className="w-[48px] p-1 text-center align-middle whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">
<Image className="rounded-full shrink-0 border object-cover" alt={`Avatar von ${player.user.name}`} src={player.user.avatar} width={40} height={40}></Image>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-neutral-200">
{player.user.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">
{player.stats?.kills ?? '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">
{player.stats?.deaths ?? '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">
{player.stats?.assists ?? '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">
{player.stats?.adr ?? '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">
{player.stats?.adr ?? '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-end text-sm font-medium">
<button
type="button"
className="text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
Details
</button>
</td>
</Table.Row>
)
}

View File

@ -0,0 +1,95 @@
'use client'
import { Team } from '@/app/types/team'
import { MatchPlayer } from '../types/match'
import MatchPlayerCard from './MatchPlayerCard'
import Image from 'next/image'
import Button from './Button'
import Table from './Table'
type Props = {
team: Team
players: MatchPlayer[]
onEditClick?: () => void
editable?: boolean
}
function getTeamLogo(logo: string | null) {
return logo ? `/assets/img/logos/${logo}` : '/default-logo.png'
}
const StatIcons = {
kills: <span title="Kills">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="20" width="20" viewBox="0 0 512 512">
<path d="M256 0c17.7 0 32 14.3 32 32l0 10.4c93.7 13.9 167.7 88 181.6 181.6l10.4 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-10.4 0c-13.9 93.7-88 167.7-181.6 181.6l0 10.4c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-10.4C130.3 455.7 56.3 381.7 42.4 288L32 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l10.4 0C56.3 130.3 130.3 56.3 224 42.4L224 32c0-17.7 14.3-32 32-32zM107.4 288c12.5 58.3 58.4 104.1 116.6 116.6l0-20.6c0-17.7 14.3-32 32-32s32 14.3 32 32l0 20.6c58.3-12.5 104.1-58.4 116.6-116.6L384 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l20.6 0C392.1 165.7 346.3 119.9 288 107.4l0 20.6c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-20.6C165.7 119.9 119.9 165.7 107.4 224l20.6 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-20.6 0zM256 224a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/>
</svg>
</span>,
deaths: <span title="Deaths">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="20" width="20" viewBox="0 0 512 512">
<path d="M416 398.9c58.5-41.1 96-104.1 96-174.9C512 100.3 397.4 0 256 0S0 100.3 0 224c0 70.7 37.5 133.8 96 174.9c0 .4 0 .7 0 1.1l0 64c0 26.5 21.5 48 48 48l48 0 0-48c0-8.8 7.2-16 16-16s16 7.2 16 16l0 48 64 0 0-48c0-8.8 7.2-16 16-16s16 7.2 16 16l0 48 48 0c26.5 0 48-21.5 48-48l0-64c0-.4 0-.7 0-1.1zM96 256a64 64 0 1 1 128 0A64 64 0 1 1 96 256zm256-64a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>
</svg>
</span>,
assist: <span title="Assists">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="20" width="25" viewBox="0 0 640 512">
<path d="M323.4 85.2l-96.8 78.4c-16.1 13-19.2 36.4-7 53.1c12.9 17.8 38 21.3 55.3 7.8l99.3-77.2c7-5.4 17-4.2 22.5 2.8s4.2 17-2.8 22.5l-20.9 16.2L550.2 352l41.8 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-76 0-4 0-.7 0-3.9-2.5L434.8 79c-15.3-9.8-33.2-15-51.4-15c-21.8 0-43 7.5-60 21.2zm22.8 124.4l-51.7 40.2C263 274.4 217.3 268 193.7 235.6c-22.2-30.5-16.6-73.1 12.7-96.8l83.2-67.3c-11.6-4.9-24.1-7.4-36.8-7.4C234 64 215.7 69.6 200 80l-72 48-80 0c-26.5 0-48 21.5-48 48L0 304c0 26.5 21.5 48 48 48l108.2 0 91.4 83.4c19.6 17.9 49.9 16.5 67.8-3.1c5.5-6.1 9.2-13.2 11.1-20.6l17 15.6c19.5 17.9 49.9 16.6 67.8-2.9c4.5-4.9 7.8-10.6 9.9-16.5c19.4 13 45.8 10.3 62.1-7.5c17.9-19.5 16.6-49.9-2.9-67.8l-134.2-123z"/>
</svg>
</span>,
adr: <span title="ADR">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="20" width="25" viewBox="0 0 576 512">
<path d="M528 56c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 8L32 64C14.3 64 0 78.3 0 96L0 208c0 17.7 14.3 32 32 32l10 0c20.8 0 36.1 19.6 31 39.8L33 440.2c-2.4 9.6-.2 19.7 5.8 27.5S54.1 480 64 480l96 0c14.7 0 27.5-10 31-24.2L217 352l104.5 0c23.7 0 44.8-14.9 52.7-37.2L400.9 240l31.1 0c8.5 0 16.6-3.4 22.6-9.4L477.3 208l66.7 0c17.7 0 32-14.3 32-32l0-80c0-17.7-14.3-32-32-32l-16 0 0-8zM321.4 304L229 304l16-64 105 0-21 58.7c-1.1 3.2-4.2 5.3-7.5 5.3zM80 128l384 0c8.8 0 16 7.2 16 16s-7.2 16-16 16L80 160c-8.8 0-16-7.2-16-16s7.2-16 16-16z"/>
</svg>
</span>,
utildmg: <span title="Utility Damage">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="20" width="20" viewBox="0 0 512 512">
<path d="M459.1 52.4L442.6 6.5C440.7 2.6 436.5 0 432.1 0s-8.5 2.6-10.4 6.5L405.2 52.4l-46 16.8c-4.3 1.6-7.3 5.9-7.2 10.4c0 4.5 3 8.7 7.2 10.2l45.7 16.8 16.8 45.8c1.5 4.4 5.8 7.5 10.4 7.5s8.9-3.1 10.4-7.5l16.5-45.8 45.7-16.8c4.2-1.5 7.2-5.7 7.2-10.2c0-4.6-3-8.9-7.2-10.4L459.1 52.4zm-132.4 53c-12.5-12.5-32.8-12.5-45.3 0l-2.9 2.9C256.5 100.3 232.7 96 208 96C93.1 96 0 189.1 0 304S93.1 512 208 512s208-93.1 208-208c0-24.7-4.3-48.5-12.2-70.5l2.9-2.9c12.5-12.5 12.5-32.8 0-45.3l-80-80zM200 192c-57.4 0-104 46.6-104 104l0 8c0 8.8-7.2 16-16 16s-16-7.2-16-16l0-8c0-75.1 60.9-136 136-136l8 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-8 0z"/>
</svg>
</span>,
}
export default function MatchTeamCard({ team, players, onEditClick, editable }: Props) {
return (
<div className="flex flex-col w-full">
{/* Gemeinsamer Rahmen mit Abrundung */}
<div className="border border-gray-200 dark:border-neutral-700 rounded-lg overflow-hidden">
{/* Teamkopf */}
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 dark:bg-neutral-700">
<div className="flex items-center gap-4">
<Image
src={getTeamLogo(team.logo)}
alt={team.teamname}
width={48}
height={48}
className="rounded-full border object-cover"
/>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
{team.teamname}
</h3>
</div>
{editable && <Button title="Bearbeiten" onClick={onEditClick} />}
</div>
{/* Tabelle ohne extra Rahmen */}
<Table>
<Table.Head>
<Table.Row>
<Table.Cell as="th"></Table.Cell>
<Table.Cell as="th">Name</Table.Cell>
<Table.Cell as="th">{StatIcons.kills}</Table.Cell>
<Table.Cell as="th">{StatIcons.deaths}</Table.Cell>
<Table.Cell as="th">{StatIcons.assist}</Table.Cell>
<Table.Cell as="th">{StatIcons.adr}</Table.Cell>
<Table.Cell as="th">{StatIcons.utildmg}</Table.Cell>
<Table.Cell as="th"></Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{players.map(player => (
<MatchPlayerCard key={player.user.steamId} player={player} />
))}
</Table.Body>
</Table>
</div>
</div>
)
}

View File

@ -0,0 +1,209 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Modal from '@/app/components/Modal'
import Select from '@/app/components/Select'
import Input from './Input'
import Button from './Button'
import DatePickerWithTime from './DatePickerWithTime'
import Link from 'next/link'
import Image from 'next/image'
import Switch from './Switch'
function getRoundedDate() {
const now = new Date()
const minutes = now.getMinutes()
const roundedMinutes = Math.ceil(minutes / 15) * 15
now.setMinutes(roundedMinutes === 60 ? 0 : roundedMinutes)
if (roundedMinutes === 60) now.setHours(now.getHours() + 1)
now.setSeconds(0)
now.setMilliseconds(0)
return now
}
function getTeamLogo(logo?: string | null) {
return logo ? `/assets/img/logos/${logo}` : '/default-logo.png'
}
export default function MatchesAdminManager() {
const { data: session } = useSession()
const [teams, setTeams] = useState<any[]>([])
const [matches, setMatches] = useState<any[]>([])
const [teamAId, setTeamAId] = useState('')
const [teamBId, setTeamBId] = useState('')
const [title, setTitle] = useState('')
const [titleManuallySet, setTitleManuallySet] = useState(false)
const [description, setDescription] = useState('')
const [matchDate, setMatchDate] = useState(getRoundedDate())
const [showModal, setShowModal] = useState(false)
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
useEffect(() => {
fetch('/api/admin/teams').then(res => res.json()).then(setTeams)
fetchMatches()
}, [])
useEffect(() => {
if (!titleManuallySet && teamAId && teamBId && teamAId !== teamBId) {
const teamA = teams.find(t => t.id === teamAId)
const teamB = teams.find(t => t.id === teamBId)
if (teamA && teamB) {
setTitle(`${teamA.teamname} vs ${teamB.teamname}`)
}
}
}, [teamAId, teamBId, teams, titleManuallySet])
const fetchMatches = async () => {
const res = await fetch('/api/matches')
if (res.ok) {
const data = await res.json()
setMatches(data)
}
}
const filteredMatches = onlyOwnTeam && session?.user?.team
? matches.filter((m: any) =>
m.teamA.id === session.user.team || m.teamB.id === session.user.team)
: matches
const resetFields = () => {
setTitle('')
setTitleManuallySet(false)
setDescription('')
setMatchDate(getRoundedDate())
setTeamAId('')
setTeamBId('')
}
const createMatch = async () => {
if (!teamAId || !teamBId || !title || !matchDate || teamAId === teamBId) {
alert('Bitte alle Felder korrekt ausfüllen.')
return
}
const res = await fetch('/api/matches/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamAId, teamBId, title, description, matchDate })
})
if (res.ok) {
resetFields()
setShowModal(false)
fetchMatches()
} else {
alert('Fehler beim Erstellen')
}
}
return (
<>
<div className="max-w-4xl mx-auto py-8 px-4 space-y-4">
{filteredMatches.length === 0 ? (
<p className="text-gray-500">Keine Matches geplant.</p>
) : (
<ul className="space-y-4">
{filteredMatches.map((match: any) => (
<li key={match.id}>
<Link href={`/matches/${match.id}`} className="block border rounded p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition">
<div className="flex items-center justify-between text-center">
<div className="flex flex-col items-center w-1/4">
<Image
src={getTeamLogo(match.teamA.logo)}
alt={match.teamA.teamname}
width={64}
height={64}
className="rounded-full border object-cover bg-white"
/>
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{match.teamA.teamname}
</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-300 w-1/2">
<div>{new Date(match.matchDate).toLocaleDateString('de-DE')}</div>
<div>{new Date(match.matchDate).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
})} Uhr</div>
</div>
<div className="flex flex-col items-center w-1/4">
<Image
src={getTeamLogo(match.teamB.logo)}
alt={match.teamB.teamname}
width={64}
height={64}
className="rounded-full border object-cover bg-white"
/>
<span className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{match.teamB.teamname}
</span>
</div>
</div>
{match.description && (
<div className="text-sm text-center text-gray-500 dark:text-gray-400 mt-1">
{match.description}
</div>
)}
</Link>
</li>
))}
</ul>
)}
</div>
{/* Modal zum Erstellen */}
<Modal
id="create-match-modal"
title="Match erstellen"
show={showModal}
onClose={() => setShowModal(false)}
onSave={createMatch}
closeButtonTitle="Erstellen"
closeButtonColor="blue"
>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block mb-1">Team A</label>
<Select
value={teamAId}
onChange={setTeamAId}
options={teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.teamname }))}
placeholder="Wählen"
/>
</div>
<div>
<label className="block mb-1">Team B</label>
<Select
value={teamBId}
onChange={setTeamBId}
options={teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.teamname }))}
placeholder="Wählen"
/>
</div>
<div className="col-span-2">
<Input
label="Titel"
value={title}
onChange={(e) => {
setTitle(e.target.value)
setTitleManuallySet(true)
}}
/>
</div>
<div className="col-span-2">
<Input
label="Beschreibung"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="col-span-2">
<DatePickerWithTime value={matchDate} onChange={setMatchDate} />
</div>
</div>
</Modal>
</>
)
}

View File

@ -0,0 +1,125 @@
'use client'
import Button from './Button'
import Image from 'next/image'
type MiniCardProps = {
title: string
avatar: string
steamId: string
selected?: boolean
onSelect?: (steamId: string) => void
onKick?: (steamId: string) => void
isLeader?: boolean
draggable?: boolean
currentUserSteamId: string
teamLeaderSteamId: string
location?: string
dragListeners?: any
hoverEffect?: boolean
onPromote?: (steamId: string) => void
hideActions?: boolean
hideOverlay?: boolean
}
export default function MiniCard({
title,
avatar,
steamId,
selected,
onSelect,
onKick,
isLeader = false,
draggable,
currentUserSteamId,
teamLeaderSteamId,
location,
dragListeners,
hoverEffect = false,
onPromote,
hideActions = false,
hideOverlay = false,
}: MiniCardProps) {
const isSelectable = typeof onSelect === 'function'
const canKick = currentUserSteamId === teamLeaderSteamId && steamId !== teamLeaderSteamId
const cardClasses = `
relative flex flex-col items-center p-4 border rounded-lg transition
max-h-[154px] w-full overflow-hidden
bg-white dark:bg-neutral-800 border shadow-2xs rounded-xl
${selected ? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400' : 'border-gray-200 dark:border-neutral-700'}
${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''}
${isSelectable ? 'hover:border-blue-400 dark:hover:border-blue-400 cursor-pointer' : ''}
`
const avatarWrapper = 'relative w-16 h-16 mb-2'
const handleCardClick = () => {
if (isSelectable) onSelect?.(steamId)
}
const handleKickClick = (e: React.MouseEvent) => {
onKick?.(steamId)
}
const handlePromoteClick = (e: React.MouseEvent) => {
onPromote?.(steamId)
}
const stopDrag = (e: React.PointerEvent | React.MouseEvent) => {
e.stopPropagation()
}
return (
<div className={`${cardClasses} group`} onClick={handleCardClick} {...dragListeners}>
{canKick && !hideActions && !hideOverlay && (
<div className={`absolute inset-0 bg-white dark:bg-black bg-opacity-50 flex flex-col items-center justify-center gap-2 transition-opacity z-10 ${
hideOverlay ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'
}`}>
<span className="text-gray-800 dark:text-neutral-200 font-semibold text-sm mb-1 truncate px-2 max-w-[90%] text-center">{title}</span>
<div className="pointer-events-auto" onPointerDown={stopDrag}>
<Button title="Kicken" color="red" variant="solid" size="sm" onClick={handleKickClick} />
</div>
{typeof onPromote === 'function' && (
<div className="pointer-events-auto" onPointerDown={stopDrag}>
<Button title="Leader" color="blue" variant="solid" size="sm" onClick={handlePromoteClick} />
</div>
)}
</div>
)}
<div className="flex flex-col items-center z-0">
<div className={avatarWrapper}>
<div className="relative w-16 h-16 mb-2">
<Image
src={avatar}
alt={title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
quality={75}
className="rounded-full object-cover"
draggable={false}
/>
{isLeader && (
<div className="absolute -top-1 -right-1 bg-yellow-400 rounded-full p-0.5 shadow ring-2 ring-white dark:ring-neutral-800" draggable={false}>
<svg className="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.965a1 1 0 00.95.69h4.172c.969 0 1.371 1.24.588 1.81l-3.375 2.455a1 1 0 00-.364 1.118l1.287 3.966c.3.92-.755 1.688-1.54 1.118l-3.375-2.455a1 1 0 00-1.175 0l-3.375 2.455c-.784.57-1.838-.197-1.539-1.118l1.286-3.966a1 1 0 00-.364-1.118L2.05 9.392c-.783-.57-.38-1.81.588-1.81h4.172a1 1 0 00.95-.69l1.286-3.965z" />
</svg>
</div>
)}
</div>
</div>
<span className="text-sm text-gray-800 dark:text-neutral-200 text-center mt-1 truncate max-w-[100px] w-full block">
{title}
</span>
{location ? (
<span className={`fi fi-${location.toLowerCase()} text-xl mt-1`} title={location} />
) : (
<span className="text-xl mt-1" title="Weltweit">🌐</span>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,39 @@
type MiniCardDummyProps = {
title: string
onClick?: () => void
children?: React.ReactNode
}
export default function MiniCardDummy({ title, onClick, children }: MiniCardDummyProps) {
return (
<div
onClick={onClick}
className={`
relative flex flex-col items-center p-4 border border-dashed rounded-lg transition
hover:border-blue-400 dark:hover:border-blue-400 hover:cursor-pointer
border-gray-300 dark:border-neutral-700
`}
>
<div className="flex flex-col items-center">
<div className="relative w-16 h-16 mb-2 flex items-center justify-center overflow-hidden rounded-full bg-gray-100 dark:bg-neutral-800">
{children ? (
children
) : (
<img
src="https://via.placeholder.com/64x64.png?text=+"
alt="Dummy Avatar"
className="w-16 h-16 object-cover"
/>
)}
</div>
<span className="text-sm text-blue-600 dark:text-blue-400 underline text-center mt-1 truncate max-w-[100px] w-full block">
{title}
</span>
</div>
{/* reserviere Platz für Flagge oder Button, auch wenn leer */}
<div className="mt-2 w-full flex justify-center min-h-[16px] relative" />
</div>
)
}

View File

@ -0,0 +1,153 @@
'use client'
import { useEffect } from 'react'
type ModalProps = {
id: string
title: string
children?: React.ReactNode
show: boolean
onClose?: () => void
onSave?: () => void
hideCloseButton?: boolean
closeButtonColor?: string
closeButtonTitle?: string
}
export default function Modal({
id,
title,
children,
show,
onClose,
onSave,
hideCloseButton = false,
closeButtonColor = "blue",
closeButtonTitle = "Speichern"
}: ModalProps) {
useEffect(() => {
const modalEl = document.getElementById(id);
const hs = (window as any).HSOverlay;
const handleClose = () => {
if (typeof onClose === 'function') {
onClose();
}
};
modalEl?.addEventListener('hsOverlay:close', handleClose);
const tryOpen = () => {
try {
if (typeof hs?.autoInit === 'function') {
hs.autoInit();
}
if (modalEl && typeof hs?.open === 'function') {
hs.open(modalEl);
}
} catch (err) {
console.error('[Modal] Fehler beim Öffnen des Modals:', err);
}
};
const tryClose = () => {
try {
if (modalEl && typeof hs?.close === 'function') {
const isInCollection = hs?.collection?.find?.((item: any) => item.element === modalEl);
if (isInCollection) {
hs.close(modalEl);
}
}
} catch (err) {
console.error('[Modal] Fehler beim Schließen des Modals:', err);
}
};
if (show) {
tryOpen();
} else {
tryClose();
}
return () => {
modalEl?.removeEventListener('hsOverlay:close', handleClose);
};
}, [show, id]);
return (
<div
id={id}
data-hs-overlay="true"
className="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none"
role="dialog"
tabIndex={-1}
aria-labelledby={`${id}-label`}
>
<div className="fixed inset-0 z-[-1] bg-black bg-opacity-50 dark:bg-neutral-900/70 hs-overlay-backdrop">
<div className="hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500 mt-0 opacity-0 ease-out transition-all sm:max-w-lg sm:w-full m-3 sm:mx-auto min-h-[calc(100%-56px)] flex items-center">
<div className="w-full flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl pointer-events-auto dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70">
<div className="flex justify-between items-center py-3 px-4 border-b border-gray-200 dark:border-neutral-700">
<h3 id={`${id}-label`} className="font-bold text-gray-800 dark:text-white">
{title}
</h3>
{!hideCloseButton && (
<button
type="button"
onClick={onClose}
className="size-8 inline-flex justify-center items-center gap-x-2 rounded-full border border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 focus:outline-hidden focus:bg-gray-200 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-400 dark:focus:bg-neutral-600"
aria-label="Close"
data-hs-overlay={`#${id}`}
>
<span className="sr-only">Schließen</span>
<svg
className="shrink-0 size-4"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
<div className="p-4 overflow-visible">
{children}
</div>
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
{!hideCloseButton && (
<button
type="button"
onClick={onClose}
data-hs-overlay={`#${id}`}
className="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700"
>
Schließen
</button>
)}
{onSave && (
<button
type="button"
onClick={onSave}
className={`py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 text-white hover:bg-${closeButtonColor}-700 focus:outline-hidden focus:bg-${closeButtonColor}-700 disabled:opacity-50 disabled:pointer-events-none`}
>
{closeButtonTitle}
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
'use client'
import Link from "next/link"
import { usePathname } from 'next/navigation'
export default function Navbar({ children }: { children?: React.ReactNode }) {
const pathname = usePathname()
return (
<>
<header className="flex flex-wrap sm:justify-start sm:flex-nowrap z-50 w-full bg-white text-sm py-3 sm:py-0 dark:bg-neutral-900">
<nav className="max-w-[85rem] w-full mx-auto px-4 md:px-6 lg:px-8">
<div className="relative sm:flex sm:items-center">
<div className="flex items-center justify-between">
<a className="flex-none font-semibold text-xl text-black focus:outline-hidden focus:opacity-80 dark:text-white" href="#" aria-label="Brand">Brand</a>
<div className="sm:hidden">
<button type="button" className="hs-collapse-toggle p-2 inline-flex justify-center items-center gap-x-2 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-transparent dark:border-neutral-700 dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10" id="hs-navbar-to-overlay-collapse" aria-haspopup="dialog" aria-expanded="false" aria-controls="hs-navbar-to-overlay" aria-label="Toggle navigation" data-hs-overlay="#hs-navbar-to-overlay" data-hs-overlay-options='{"moveOverlayToBody": 640}'>
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" x2="21" y1="6" y2="6"/><line x1="3" x2="21" y1="12" y2="12"/><line x1="3" x2="21" y1="18" y2="18"/></svg>
</button>
</div>
</div>
<div id="hs-navbar-to-overlay" className="hs-overlay hs-overlay-open:translate-x-0 [--auto-close:sm] -translate-x-full fixed top-0 start-0 transition-all duration-300 transform h-full w-full sm:w-96 z-60 bg-white border-e sm:static sm:block sm:h-auto sm:w-full sm:border-e-transparent sm:transition-none sm:transform-none sm:translate-x-0 sm:z-40 dark:bg-neutral-800 sm:dark:bg-neutral-900 dark:border-e-neutral-700 sm:dark:border-e-transparent hidden" role="dialog" tabindex="-1" aria-label="Sidebar" data-hs-overlay-close-on-resize>
<div className="overflow-hidden overflow-y-auto h-full [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500">
<div className="flex flex-col gap-y-3 sm:gap-y-0 sm:flex-row sm:items-center sm:justify-end p-2 sm:p-0">
<div className="py-3 sm:hidden flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
<h3 className="font-medium text-gray-800 dark:text-neutral-200">
Menu
</h3>
<button type="button" className="py-1.5 px-2 inline-flex justify-center items-center gap-x-1 rounded-full border border-gray-200 text-xs text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:text-neutral-200 dark:focus:bg-neutral-700" aria-label="Close" data-hs-overlay="#hs-navbar-to-overlay" aria-expanded="true">
Close
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
</div>
<a className="sm:p-2 font-medium text-sm text-blue-500 focus:outline-hidden" href="#" aria-current="page">Active</a>
<div className="hs-dropdown [--strategy:static] sm:[--strategy:absolute] [--adaptive:none] sm:[--trigger:hover] [--is-collapse:true] sm:[--is-collapse:false] ">
<button id="hs-mega-menu" type="button" className="hs-dropdown-toggle sm:p-2 flex items-center w-full text-gray-600 font-medium text-sm hover:text-gray-400 focus:outline-hidden focus:text-gray-400 dark:text-neutral-400 dark:hover:text-neutral-500 dark:focus:text-neutral-500" aria-haspopup="menu" aria-expanded="false" aria-label="Mega Menu">
Mega Menu
<svg className="hs-dropdown-open:-rotate-180 sm:hs-dropdown-open:rotate-0 duration-300 ms-2 shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div className="hs-dropdown-menu sm:transition-[opacity,margin] sm:ease-in-out sm:duration-[150ms] hs-dropdown-open:opacity-100 opacity-0 w-full hidden z-10 sm:mt-3 top-full start-0 min-w-60 bg-white sm:shadow-md rounded-lg py-2 sm:px-2 dark:bg-neutral-800 sm:dark:border dark:border-neutral-700 dark:divide-neutral-700 before:absolute" role="menu" aria-orientation="vertical" aria-labelledby="hs-mega-menu">
<div className="sm:grid sm:grid-cols-3">
<div className="flex flex-col">
<a className="flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700 dark:focus:text-neutral-300" href="#">
About
</a>
<a className="flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700 dark:focus:text-neutral-300" href="#">
Services
</a>
<a className="flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700 dark:focus:text-neutral-300" href="#">
Customer Story
</a>
</div>
<div className="flex flex-col">
<a className="flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700 dark:focus:text-neutral-300" href="#">
Careers
</a>
<a className="flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700 dark:focus:text-neutral-300" href="#">
Careers: Overview
</a>
<a className="flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700 dark:focus:text-neutral-300" href="#">
Careers: Apply
</a>
</div>
<div className="flex flex-col">
<a className="flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700 dark:focus:text-neutral-300" href="#">
Log In
</a>
<a className="flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700 dark:focus:text-neutral-300" href="#">
Sign Up
</a>
</div>
</div>
</div>
</div>
<a className="sm:p-2 font-medium text-sm text-gray-600 hover:text-gray-400 focus:outline-hidden focus:text-gray-400 dark:text-neutral-400 dark:hover:text-neutral-500 dark:focus:text-neutral-500" href="#">Project</a>
</div>
</div>
</div>
</div>
</nav>
</header>
</>
)
}

View File

@ -0,0 +1,70 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import TeamCard from './TeamCard'
import { Team } from '../types/team'
export default function NoTeamView() {
const { data: session } = useSession()
const [teams, setTeams] = useState<Team[]>([])
const [teamToInvitationId, setTeamToInvitationId] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const fetchData = async () => {
setLoading(true)
const [teamRes, invitesRes] = await Promise.all([
fetch('/api/team/list'),
fetch('/api/user/invitations'),
])
const teamData = await teamRes.json()
const inviteData = await invitesRes.json()
setTeams(teamData.teams || [])
const mapping: Record<string, string> = {}
for (const invite of inviteData?.invitations || []) {
if (invite.type === 'team-join-request') {
mapping[invite.teamId] = invite.id
}
}
setTeamToInvitationId(mapping)
setLoading(false)
}
useEffect(() => {
fetchData()
}, [])
const updateInvitationMap = (teamId: string, newId: string | null) => {
setTeamToInvitationId((prev) => {
const updated = { ...prev }
if (newId) updated[teamId] = newId
else delete updated[teamId]
return updated
})
}
if (loading) return <p>Lade Teams </p>
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
Du bist noch in keinem Team.
<br />
<span className="text-blue-600">Tritt jetzt einem Team bei!</span>
</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => (
<TeamCard
key={team.id}
team={team}
currentUserSteamId={session?.user?.steamId || ''}
invitationId={teamToInvitationId[team.id]}
onUpdateInvitation={updateInvitationMap}
/>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,182 @@
'use client'
import { useEffect, useState } from 'react'
import NotificationDropdown from './NotificationDropdown'
import { useWS } from '@/app/lib/wsStore'
import { useSession } from 'next-auth/react'
import { useTeamManager } from '../hooks/useTeamManager'
import { useRouter } from 'next/navigation'
type Notification = {
id: string
text: string
read: boolean
actionType?: string
actionData?: string
createdAt?: string
}
export default function NotificationCenter() {
const { data: session } = useSession()
const [notifications, setNotifications] = useState<Notification[]>([])
const [open, setOpen] = useState(false)
const { socket, connect } = useWS()
const { markAllAsRead, markOneAsRead, handleInviteAction } = useTeamManager({}, null)
const router = useRouter()
const [previewText, setPreviewText] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [animateBell, setAnimateBell] = useState(false)
useEffect(() => {
const steamId = session?.user?.steamId
if (!steamId) return
const loadNotifications = async () => {
try {
const res = await fetch('/api/notifications/user')
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
const loaded = data.notifications.map((n: any) => ({
id: n.id,
text: n.message,
read: n.read,
actionType: n.actionType,
actionData: n.actionData,
createdAt: n.createdAt,
}))
setNotifications(loaded)
} catch (err) {
console.error('[NotificationCenter] Fehler beim Laden:', err)
}
}
loadNotifications()
connect(steamId)
}, [session?.user?.steamId, connect])
useEffect(() => {
if (!socket) return
const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data)
if (data.type === 'heartbeat') return
const isNotificationType = [
'notification',
'invitation',
'team-invite',
'team-joined',
'team-member-joined',
'team-kick',
'team-kick-other',
'team-left',
'team-member-left',
'team-leader-changed',
'team-join-request'
].includes(data.type)
if (!isNotificationType) return
const newNotification: Notification = {
id: data.id,
text: data.message || 'Neue Benachrichtigung',
read: false,
actionType: data.actionType,
actionData: data.actionData,
createdAt: data.createdAt,
}
setNotifications(prev => [newNotification, ...prev])
setPreviewText(newNotification.text)
setShowPreview(true)
setAnimateBell(true)
setTimeout(() => {
setShowPreview(false)
setTimeout(() => {
setPreviewText(null)
}, 300)
setAnimateBell(false)
}, 3000)
}
socket.addEventListener('message', handleMessage)
return () => socket.removeEventListener('message', handleMessage)
}, [socket])
return (
<div className="fixed bottom-6 right-6 z-50">
<button
type="button"
onClick={() => setOpen(prev => !prev)}
className={`relative flex items-center transition-all duration-300 ease-in-out
${showPreview ? 'w-[320px] pl-4 pr-11' : 'w-[44px] justify-center'}
h-11 rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs
dark:bg-neutral-900 dark:border-neutral-700 dark:text-white`}
>
{/* Vorschautext */}
{previewText && (
<span className="truncate text-sm text-gray-800 dark:text-white">
{previewText}
</span>
)}
{/* Notification Bell (absolut rechts innerhalb des Buttons) */}
<div className="absolute right-0 top-0 bottom-0 w-11 flex items-center justify-center">
<svg
className={`shrink-0 size-6 transition-transform ${animateBell ? 'animate-shake' : ''}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14V11a6.002 6.002 0 00-4-5.659V4a2 2 0 00-4 0v1.341C7.67 6.165 6 8.388 6 11v3c0 .828-.672 1.5-1.5 1.5H4v1h5m6 0v1a2 2 0 11-4 0v-1h4z"
/>
</svg>
{notifications.some(n => !n.read) && (
<span className="flex absolute top-0 end-0 -mt-1 -me-1">
<span className="animate-ping absolute inline-flex size-5 rounded-full bg-red-400 opacity-75 dark:bg-red-600"></span>
<span className="relative inline-flex items-center justify-center size-5 rounded-full text-xs font-bold bg-red-500 text-white">
{notifications.filter(n => !n.read).length}
</span>
</span>
)}
</div>
</button>
{/* Dropdown */}
{open && (
<NotificationDropdown
notifications={notifications}
markAllAsRead={async () => {
await markAllAsRead()
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
}}
onSingleRead={async (id) => {
await markOneAsRead(id)
setNotifications(prev => prev.map(n => (n.id === id ? { ...n, read: true } : n)))
}}
onClose={() => setOpen(false)}
onAction={async (action, id) => {
await handleInviteAction(action, id)
setNotifications(prev =>
prev.map(n =>
n.actionData === id
? { ...n, read: true, actionType: undefined, actionData: undefined }
: n
)
)
if (action === 'accept') router.refresh()
}}
/>
)}
</div>
)
}

View File

@ -0,0 +1,173 @@
'use client'
import { useEffect, useRef } from 'react'
import Button from './Button'
import { formatDistanceToNow } from 'date-fns'
import { de } from 'date-fns/locale'
type Notification = {
id: string
text: string
read: boolean
actionType?: string // z.B. "team-invite" | "team-join-request"
actionData?: string // invitationId oder teamId
createdAt?: string
}
type Props = {
notifications: Notification[]
markAllAsRead: () => void
onSingleRead: (id: string) => void
onClose: () => void
onAction: (action: 'accept' | 'reject', invitationId: string) => void
}
export default function NotificationDropdown({
notifications,
markAllAsRead,
onSingleRead,
onClose,
onAction,
}: Props) {
const dropdownRef = useRef<HTMLDivElement>(null)
/* --- Klick außerhalb schließt Dropdown ------------------------- */
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
/* --- Render ----------------------------------------------------- */
return (
<div
ref={dropdownRef}
className="absolute bottom-20 right-0 w-80 bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-xl overflow-hidden z-50"
>
{/* Kopfzeile */}
<div className="p-2 flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
<span className="font-semibold text-gray-800 dark:text-white">
Benachrichtigungen
</span>
<Button
title="Alle als gelesen markieren"
onClick={markAllAsRead}
variant="solid"
color="blue"
size="sm"
className="p-2"
>
{/* Icon */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
<path d="M255.4 48.2c.2-.1.4-.2.6-.2s.4.1.6.2L460.6 194c2.1 1.5 3.4 3.9 3.4 6.5v13.6L291.5 355.7c-20.7 17-50.4 17-71.1 0L48 214.1v-13.6c0-2.6 1.2-5 3.4-6.5L255.4 48.2zM48 276.2L190 392.8c38.4 31.5 93.7 31.5 132 0L464 276.2V456c0 4.4-3.6 8-8 8H56c-4.4 0-8-3.6-8-8V276.2zM256 0c-10.2 0-20.2 3.2-28.5 9.1L23.5 154.9C8.7 165.4 0 182.4 0 200.5V456c0 30.9 25.1 56 56 56h400c30.9 0 56-25.1 56-56V200.5c0-18.1-8.7-35.1-23.4-45.6L284.5 9.1C276.2 3.2 266.2 0 256 0z" />
</svg>
</Button>
</div>
{/* Liste */}
<div className="max-h-60 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-neutral-400">
Keine Benachrichtigungen
</div>
) : (
notifications.map((n) => {
const needsAction =
!n.read &&
(n.actionType === 'team-invite' ||
n.actionType === 'team-join-request')
return (
<div
key={n.id}
className="grid grid-cols-[auto_1fr_auto] items-center gap-2 py-3 px-2 border-b border-gray-200 dark:border-neutral-700 text-sm hover:bg-gray-50 dark:hover:bg-neutral-700"
>
{/* roter Punkt */}
<div className="flex items-center justify-center h-full">
<span
className={`inline-block w-2 h-2 rounded-full ${
n.read ? 'bg-transparent' : 'bg-red-500'
}`}
/>
</div>
{/* Text + Timestamp */}
<div className="flex flex-col gap-1">
<span
className={
n.read
? 'text-gray-600 dark:text-neutral-400'
: 'font-semibold text-gray-900 dark:text-white'
}
>
{n.text}
</span>
<span className="text-xs text-gray-400 dark:text-neutral-500">
{n.createdAt &&
formatDistanceToNow(new Date(n.createdAt), {
addSuffix: true,
locale: de,
})}
</span>
</div>
{/* Aktionen */}
<div className="flex items-center gap-1">
{needsAction ? (
<>
<Button
onClick={() => {
onAction('accept', n.actionData!)
onSingleRead(n.id)
}}
className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700"
color="green"
size="sm"
>
</Button>
<Button
onClick={() => {
onAction('reject', n.actionData!)
onSingleRead(n.id)
}}
className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700"
color="red"
size="sm"
>
</Button>
</>
) : (
!n.read && (
<Button
onClick={() => onSingleRead(n.id)}
title="Als gelesen markieren"
className="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-white"
color="gray"
size="sm"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
<path d="M255.4 48.2c.2-.1.4-.2.6-.2s.4.1.6.2L460.6 194c2.1 1.5 3.4 3.9 3.4 6.5v13.6L291.5 355.7c-20.7 17-50.4 17-71.1 0L48 214.1v-13.6c0-2.6 1.2-5 3.4-6.5L255.4 48.2zM48 276.2L190 392.8c38.4 31.5 93.7 31.5 132 0L464 276.2V456c0 4.4-3.6 8-8 8H56c-4.4 0-8-3.6-8-8V276.2zM256 0c-10.2 0-20.2 3.2-28.5 9.1L23.5 154.9C8.7 165.4 0 182.4 0 200.5V456c0 30.9 25.1 56 56 56h400c30.9 0 56-25.1 56-56V200.5c0-18.1-8.7-35.1-23.4-45.6L284.5 9.1C276.2 3.2 266.2 0 256 0z" />
</svg>
</Button>
)
)}
</div>
</div>
)
})
)}
</div>
</div>
)
}

View File

@ -0,0 +1,81 @@
'use client'
import Image from 'next/image'
import { Player, Team } from '@/app/types/team'
export type CardWidth =
| 'sm' // max-w-sm (24rem)
| 'md' // max-w-md (28rem)
| 'lg' // max-w-lg (32rem)
| 'xl' // max-w-xl (36rem)
| '2xl' // max-w-2xl (42rem)
| 'full' // w-full
| 'auto' // keine Begrenzung
type Props = {
player: Player
team?: Team // aktuell nicht genutzt, aber falls du später z.B. TeamFarbe brauchst
align?: 'left' | 'right'
maxWidth?: CardWidth
}
export default function PlayerCard({
player,
team,
align = 'left',
maxWidth = 'sm',
}: Props) {
/* --- HilfsKlassen ----------------------------------------------------- */
const widthClasses: Record<CardWidth, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl':'max-w-2xl',
full: 'w-full',
auto: '', // keine Begrenzung
}
// Linksbündig = Karte nach links schieben, Rechtsbündig = nach rechts
const alignClasses =
align === 'right'
? 'ml-auto'
: align === 'left'
? 'mr-auto'
: 'mx-auto'
// Für den FlexContainer innen:
// * links: Avatar Name (row)
// * rechts: Name Avatar (rowreverse)
const rowDir = align === 'right' ? 'flex-row-reverse text-right' : ''
const avatarSrc = player.avatar || '/default-avatar.png'
/* --- Rendering --------------------------------------------------------- */
return (
<div
className={`
flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
dark:bg-neutral-900 dark:border-neutral-700 dark:shadow-neutral-700/70
${alignClasses} ${widthClasses[maxWidth]}
`}
>
<div className="p-2 md:p-4">
<div className={`flex items-center gap-3 ${rowDir}`}>
<Image
src={avatarSrc}
alt={player.name}
width={48}
height={48}
className="rounded-full shrink-0 border object-cover"
/>
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
{player.name}
</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,58 @@
'use client'
import React, { useState, useRef, useEffect } from 'react'
interface PopoverProps {
text: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
}
export default function Popover({ text, children, size = 'sm' }: PopoverProps) {
const [open, setOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!buttonRef.current?.contains(event.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const sizeClass = {
sm: 'max-w-xs',
md: 'max-w-sm',
lg: 'max-w-md',
xl: 'max-w-lg',
}[size]
return (
<div className="relative inline-block">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((prev) => !prev)}
className="mt-1 text-xs text-gray-400 dark:text-neutral-500"
>
{text}
</button>
{open && (
<div
ref={popoverRef}
className={`fixed z-10 mt-2 ${sizeClass} rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 shadow-md dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300`}
>
{children}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,34 @@
'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
// Preline UI
async function loadPreline() {
return import('preline/dist/index.js');
}
export default function PrelineScript() {
const path = usePathname();
useEffect(() => {
const initPreline = async () => {
await loadPreline();
};
initPreline();
}, []);
useEffect(() => {
setTimeout(() => {
if (
window.HSStaticMethods &&
typeof window.HSStaticMethods.autoInit === 'function'
) {
window.HSStaticMethods.autoInit();
}
}, 100);
}, [path]);
return null;
}

View File

@ -0,0 +1,11 @@
'use client';
import dynamic from 'next/dynamic';
const PrelineScript = dynamic(() => import('./PrelineScript'), {
ssr: false,
});
export default function PrelineScriptWrapper() {
return <PrelineScript />;
}

View File

@ -0,0 +1,383 @@
'use client'
export default function Profile() {
return (
<>
<div className="p-3 md:p-5 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700">
<figure>
<svg className="w-full" preserveAspectRatio="none" width="1113" height="161" viewBox="0 0 1113 161" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_697_201879)"><rect x="1" width="1112" height="348" fill="#B2E7FE"/><rect width="185.209" height="704.432" transform="matrix(0.50392 0.86375 -0.860909 0.508759 435.452 -177.87)" fill="#FF8F5D"/><rect width="184.653" height="378.667" transform="matrix(0.849839 -0.527043 0.522157 0.852849 -10.4556 -16.4521)" fill="#3ECEED"/><rect width="184.653" height="189.175" transform="matrix(0.849839 -0.527043 0.522157 0.852849 35.4456 58.5195)" fill="#4C48FF"/></g><defs><clipPath id="clip0_697_201879"><rect x="0.5" width="1112" height="161" rx="12" fill="white"/></clipPath></defs></svg>
</figure>
<div className="-mt-24">
<div className="relative flex size-30 mx-auto border-4 border-white rounded-full dark:border-neutral-800">
<img className="object-cover size-full rounded-full" src="https://images.unsplash.com/photo-1659482633369-9fe69af50bfb?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=facearea&facepad=3&w=320&h=320&q=80" alt="Hero Image" />
<div className="absolute bottom-0 -end-2">
<button type="button" className="group p-2 max-w-[125px] inline-flex justify-center items-center gap-x-2 text-start bg-red-600 border border-red-600 text-white text-xs font-medium rounded-full shadow-2xs align-middle focus:outline-hidden focus:bg-red-500" data-hs-overlay="#hs-pro-dsm">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>
<span className="group-hover:block hidden">Offline</span>
</button>
</div>
</div>
<div className="mt-3 text-center">
<h1 className="text-xl font-semibold text-gray-800 dark:text-neutral-200">
James Collins
</h1>
<p className="text-gray-500 dark:text-neutral-500">
its_james
</p>
</div>
</div>
<div className="mt-4 md:mt-7 -mb-0.5 flex flex-col md:flex-row md:justify-between md:items-center gap-3">
<div className="md:order-2 flex justify-center md:justify-end">
<label htmlFor="hs-pro-dupfub" className="relative py-1.5 px-3 inline-flex items-center justify-center sm:justify-start border border-gray-200 cursor-pointer font-medium text-sm rounded-lg peer-checked:bg-gray-100 hover:border-gray-300 focus:outline-none focus:border-gray-300 dark:border-neutral-700 dark:peer-checked:bg-neutral-800 dark:hover:border-neutral-600 dark:focus:border-neutral-600">
<input type="checkbox" id="hs-pro-dupfub" className="peer hidden" checked />
<span className="relative z-10 text-gray-800 dark:text-neutral-200 peer-checked:hidden">
Follow
</span>
<span className="relative z-10 hidden peer-checked:flex text-gray-800 dark:text-neutral-200">
Unfollow
</span>
</label>
</div>
<div className="relative flex justify-center md:justify-start" data-hs-scroll-nav='{
"autoCentering": true
}'>
<nav className="hs-scroll-nav-body flex flex-nowrap gap-x-1 overflow-x-auto [&::-webkit-scrollbar]:h-0 snap-x snap-mandatory pb-1.5">
<a className="snap-start relative inline-flex flex-nowrap items-center gap-x-2 px-2.5 py-1.5 hover:bg-gray-100 text-gray-500 hover:text-gray-800 text-sm whitespace-nowrap rounded-lg disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 after:absolute after:-bottom-0.5 after:inset-x-0 after:z-10 after:w-1/4 after:h-0.5 after:rounded-full after:mx-auto after:pointer-events-none dark:text-neutral-500 dark:hover:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 after:bg-gray-600 text-gray-800 font-medium dark:bg-neutral-800 dark:text-white dark:after:bg-neutral-200 active" href="../../pro/dashboard/user-profile-my-profile.html">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="15" r="3"/><circle cx="9" cy="7" r="4"/><path d="M10 15H6a4 4 0 0 0-4 4v2"/><path d="m21.7 16.4-.9-.3"/><path d="m15.2 13.9-.9-.3"/><path d="m16.6 18.7.3-.9"/><path d="m19.1 12.2.3-.9"/><path d="m19.6 18.7-.4-1"/><path d="m16.8 12.3-.4-1"/><path d="m14.3 16.6 1-.4"/><path d="m20.7 13.8 1-.4"/></svg>
My Profile
</a>
<a className="snap-start relative inline-flex flex-nowrap items-center gap-x-2 px-2.5 py-1.5 hover:bg-gray-100 text-gray-500 hover:text-gray-800 text-sm whitespace-nowrap rounded-lg disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 after:absolute after:-bottom-0.5 after:inset-x-0 after:z-10 after:w-1/4 after:h-0.5 after:rounded-full after:mx-auto after:pointer-events-none dark:text-neutral-500 dark:hover:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 " href="../../pro/dashboard/user-profile-teams.html">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Teams
</a>
<a className="snap-start relative inline-flex flex-nowrap items-center gap-x-2 px-2.5 py-1.5 hover:bg-gray-100 text-gray-500 hover:text-gray-800 text-sm whitespace-nowrap rounded-lg disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 after:absolute after:-bottom-0.5 after:inset-x-0 after:z-10 after:w-1/4 after:h-0.5 after:rounded-full after:mx-auto after:pointer-events-none dark:text-neutral-500 dark:hover:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 " href="../../pro/dashboard/user-profile-files.html">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 2H8.6c-.4 0-.8.2-1.1.5-.3.3-.5.7-.5 1.1v12.8c0 .4.2.8.5 1.1.3.3.7.5 1.1.5h9.8c.4 0 .8-.2 1.1-.5.3-.3.5-.7.5-1.1V6.5L15.5 2z"/><path d="M3 7.6v12.8c0 .4.2.8.5 1.1.3.3.7.5 1.1.5h9.8"/><path d="M15 2v5h5"/></svg>
Files
</a>
<a className="snap-start relative inline-flex flex-nowrap items-center gap-x-2 px-2.5 py-1.5 hover:bg-gray-100 text-gray-500 hover:text-gray-800 text-sm whitespace-nowrap rounded-lg disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 after:absolute after:-bottom-0.5 after:inset-x-0 after:z-10 after:w-1/4 after:h-0.5 after:rounded-full after:mx-auto after:pointer-events-none dark:text-neutral-500 dark:hover:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 " href="../../pro/dashboard/user-profile-connections.html">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>
Connections
</a>
</nav>
</div>
</div>
</div>
<div id="hs-pro-dsm" className="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto [--close-when-click-inside:true] pointer-events-none" role="dialog" tabIndex={-1} aria-labelledby="hs-pro-dsm-label">
<div className="hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500 mt-0 opacity-0 ease-out transition-all sm:max-w-xl sm:w-full m-3 sm:mx-auto h-[calc(100%-56px)] min-h-[calc(100%-56px)] flex items-center">
<div className="w-full flex flex-col bg-white rounded-xl pointer-events-auto shadow-xl dark:bg-neutral-800">
<div className="py-2.5 px-4 flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
<h3 id="hs-pro-dsm-label" className="font-medium text-gray-800 dark:text-neutral-200">
Set status
</h3>
<button type="button" className="size-8 shrink-0 flex justify-center items-center gap-x-2 rounded-full border border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-400 dark:focus:bg-neutral-600" aria-label="Close" data-hs-overlay="#hs-pro-dsm">
<span className="sr-only">Close</span>
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div id="hs-modal-status-body" className="p-4 space-y-6 max-h-[75dvh] overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500">
<div className="flex items-center border border-gray-200 rounded-lg dark:border-neutral-700">
<div className="p-3 border-e border-gray-200 dark:border-neutral-700">
<svg className="shrink-0 size-4 text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>
</div>
<input type="text" className="py-1.5 sm:py-2 px-3 block w-full border-transparent rounded-e-md sm:text-sm placeholder:text-gray-400 focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-transparent dark:border-transparent dark:text-neutral-300 dark:placeholder:text-white/60 dark:focus:ring-neutral-600" placeholder="Whats your status?" />
</div>
<div>
<h4 className="text-sm text-gray-500 dark:text-neutral-500">
Suggestions
</h4>
<div className="mt-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="flex flex-col gap-y-2">
<label htmlFor="hs-pro-dupsms1" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms1" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🗓 <span className="ms-2">In a meeting</span>
</span>
</label>
<label htmlFor="hs-pro-dupsms3" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms3" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🚎 <span className="ms-2">Commuting</span>
</span>
</label>
<label htmlFor="hs-pro-dupsms5" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms5" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🎯 <span className="ms-2">Focusing</span>
</span>
</label>
</div>
<div className="flex flex-col gap-y-2">
<label htmlFor="hs-pro-dupsms2" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms2" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🤒 <span className="ms-2">Out sick</span>
</span>
</label>
<label htmlFor="hs-pro-dupsms7" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms7" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🌴 <span className="ms-2">On vacation</span>
</span>
</label>
<label htmlFor="hs-pro-dupsms6" className="relative py-2 px-3 flex cursor-pointer bg-white text-sm rounded-lg focus:outline-hidden dark:bg-neutral-800">
<input type="radio" id="hs-pro-dupsms6" name="hs-pro-dupsms" className="peer absolute top-0 start-0 size-full bg-transparent border border-gray-200 text-transparent rounded-lg cursor-pointer focus:ring-0 focus:ring-offset-0 after:relative after:-z-1 after:block after:size-full after:rounded-lg checked:after:bg-blue-50 checked:text-transparent checked:border-blue-600 checked:hover:border-blue-600 checked:focus:border-blue-600 checked:bg-none disabled:opacity-50 disabled:pointer-events-none focus:border-blue-600 dark:border-neutral-700 dark:checked:after:bg-blue-500/10 dark:checked:border-blue-500 dark:focus:border-neutral-600" />
<span className="peer-checked:text-blue-600 dark:text-white dark:peer-checked:text-blue-500">
🏡 <span className="ms-2">Working remotely</span>
</span>
</label>
</div>
</div>
</div>
</div>
<ul className="flex flex-col bg-white border border-gray-200 rounded-xl -space-y-px dark:bg-neutral-800 dark:border-neutral-700">
<li className="p-3 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="flex gap-x-3">
<span className="mt-0.5 shrink-0 flex flex-col justify-center items-center size-6.5 bg-red-500 text-white rounded-full">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.164 14H15c-1.5-1-2-5.902-2-7 0-.264-.02-.523-.06-.776L5.164 14zm6.288-10.617A4.988 4.988 0 0 0 8.995 2.1a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 7c0 .898-.335 4.342-1.278 6.113l9.73-9.73zM10 15a2 2 0 1 1-4 0h4zm-9.375.625a.53.53 0 0 0 .75.75l14.75-14.75a.53.53 0 0 0-.75-.75L.625 15.625z"/>
</svg>
</span>
<div className="grow">
<div className="flex justify-between items-center mb-1">
<h4 className="text-sm font-semibold text-gray-800 dark:text-neutral-200">
Offline
</h4>
<label htmlFor="hs-pro-dsmofs" className="relative inline-block w-11 h-6 cursor-pointer">
<input type="checkbox" id="hs-pro-dsmofs" className="peer sr-only" />
<span className="absolute inset-0 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out peer-checked:bg-blue-600 dark:bg-neutral-700 dark:peer-checked:bg-blue-500 peer-disabled:opacity-50 peer-disabled:pointer-events-none"></span>
<span className="absolute top-1/2 start-0.5 -translate-y-1/2 size-5 bg-white rounded-full shadow-sm transition-transform duration-200 ease-in-out peer-checked:translate-x-full dark:bg-neutral-400 dark:peer-checked:bg-white"></span>
</label>
</div>
<p className="text-xs text-gray-500 dark:text-neutral-500">
Mute notifications and unassign new messages
</p>
</div>
</div>
</li>
<li className="p-3 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="flex gap-x-3">
<span className="mt-0.5 shrink-0 flex flex-col justify-center items-center size-6.5 bg-yellow-500 text-white rounded-full">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
</svg>
</span>
<div className="grow">
<div className="flex justify-between items-center mb-1">
<h4 className="text-sm font-semibold text-gray-800 dark:text-neutral-200">
Do not disturb
</h4>
<label htmlFor="hs-pro-dsmdnds" className="relative inline-block w-11 h-6 cursor-pointer">
<input type="checkbox" id="hs-pro-dsmdnds" className="peer sr-only" />
<span className="absolute inset-0 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out peer-checked:bg-blue-600 dark:bg-neutral-700 dark:peer-checked:bg-blue-500 peer-disabled:opacity-50 peer-disabled:pointer-events-none"></span>
<span className="absolute top-1/2 start-0.5 -translate-y-1/2 size-5 bg-white rounded-full shadow-sm transition-transform duration-200 ease-in-out peer-checked:translate-x-full dark:bg-neutral-400 dark:peer-checked:bg-white"></span>
</label>
</div>
<p className="text-xs text-gray-500 dark:text-neutral-500">
Mute notifications
</p>
</div>
</div>
</li>
<li className="p-3 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="flex gap-x-3">
<span className="mt-0.5 shrink-0 flex flex-col justify-center items-center size-6.5 bg-yellow-500 text-white rounded-full">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
</svg>
</span>
<div className="grow">
<div className="flex justify-between items-center mb-1">
<h4 className="text-sm font-semibold text-gray-800 dark:text-neutral-200">
Matches
</h4>
<label htmlFor="hs-pro-dsmschs" className="relative inline-block w-11 h-6 cursor-pointer">
<input type="checkbox" id="hs-pro-dsmschs" className="peer sr-only" />
<span className="absolute inset-0 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out peer-checked:bg-blue-600 dark:bg-neutral-700 dark:peer-checked:bg-blue-500 peer-disabled:opacity-50 peer-disabled:pointer-events-none"></span>
<span className="absolute top-1/2 start-0.5 -translate-y-1/2 size-5 bg-white rounded-full shadow-sm transition-transform duration-200 ease-in-out peer-checked:translate-x-full dark:bg-neutral-400 dark:peer-checked:bg-white"></span>
</label>
</div>
<div className="mt-3 sm:mt-1 flex flex-wrap items-center gap-2">
<div className="relative">
<select id="hs-pro-select-time1" data-hs-select='{
"placeholder": "Select option...",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 px-4 pe-7 flex text-nowrap w-full cursor-pointer bg-gray-100 rounded-lg text-start text-sm text-gray-800 focus:outline-hidden focus:bg-gray-200 before:absolute before:inset-0 before:z-1 dark:bg-neutral-700 dark:text-neutral-200 dark:focus:bg-neutral-700",
"dropdownClasses": "mt-2 z-50 w-full min-w-36 max-h-72 p-1 space-y-0.5 overflow-hidden overflow-y-auto bg-white rounded-xl shadow-xl [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500 dark:bg-neutral-900 dark:bg-neutral-900",
"optionClasses": "hs-selected:bg-gray-100 dark:hs-selected:bg-neutral-800 py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800",
"optionTemplate": "<div className=\"flex justify-between items-center w-full\"><span data-title></span><span className=\"hidden hs-selected:block\"><svg className=\"shrink-0 size-3.5 text-gray-800 dark:text-neutral-200\" xmlns=\"http:.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg></span></div>",
"viewport": "#hs-modal-status-body"
}' className="hidden">
<option value="">Choose</option>
<option>12:01 AM</option>
<option>1:00 AM</option>
<option>2:00 AM</option>
<option>3:00 AM</option>
<option>4:00 AM</option>
<option>5:00 AM</option>
<option>6:00 AM</option>
<option>7:00 AM</option>
<option>8:00 AM</option>
<option selected>9:00 AM</option>
<option >10:00 AM</option>
<option>11:00 AM</option>
<option>12:01 PM</option>
<option>1:00 PM</option>
<option>2:00 PM</option>
<option>3:00 PM</option>
<option>4:00 PM</option>
<option>5:00 PM</option>
<option>6:00 PM</option>
<option>7:00 PM</option>
<option>8:00 PM</option>
<option>9:00 PM</option>
<option>10:00 PM</option>
<option>11:00 PM</option>
</select>
<div className="absolute top-1/2 end-2.5 -translate-y-1/2">
<svg className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</div>
</div>
<span className="text-sm text-gray-500 dark:text-neutral-500">to:</span>
<div className="relative">
<select id="hs-pro-select-time2" data-hs-select='{
"placeholder": "Select option...",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 px-4 pe-7 flex text-nowrap w-full cursor-pointer bg-gray-100 rounded-lg text-start text-sm text-gray-800 focus:outline-hidden focus:bg-gray-200 before:absolute before:inset-0 before:z-1 dark:bg-neutral-700 dark:text-neutral-200 dark:focus:bg-neutral-700",
"dropdownClasses": "mt-2 z-50 w-full min-w-36 max-h-72 p-1 space-y-0.5 overflow-hidden overflow-y-auto bg-white rounded-xl shadow-xl [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500 dark:bg-neutral-900 dark:bg-neutral-900",
"optionClasses": "hs-selected:bg-gray-100 dark:hs-selected:bg-neutral-800 py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800",
"optionTemplate": "<div className=\"flex justify-between items-center w-full\"><span data-title></span><span className=\"hidden hs-selected:block\"><svg className=\"shrink-0 size-3.5 text-gray-800 dark:text-neutral-200\" xmlns=\"http:.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg></span></div>",
"viewport": "#hs-modal-status-body"
}' className="hidden">
<option value="">Choose</option>
<option>12:01 AM</option>
<option>1:00 AM</option>
<option>2:00 AM</option>
<option>3:00 AM</option>
<option>4:00 AM</option>
<option>5:00 AM</option>
<option>6:00 AM</option>
<option>7:00 AM</option>
<option>8:00 AM</option>
<option >9:00 AM</option>
<option selected>10:00 AM</option>
<option>11:00 AM</option>
<option>12:01 PM</option>
<option>1:00 PM</option>
<option>2:00 PM</option>
<option>3:00 PM</option>
<option>4:00 PM</option>
<option>5:00 PM</option>
<option>6:00 PM</option>
<option>7:00 PM</option>
<option>8:00 PM</option>
<option>9:00 PM</option>
<option>10:00 PM</option>
<option>11:00 PM</option>
</select>
<div className="absolute top-1/2 end-2.5 -translate-y-1/2">
<svg className="shrink-0 size-3.5 text-gray-500 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</div>
</div>
</div>
</div>
</div>
</li>
</ul>
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
<div className="flex items-center gap-x-3">
<label className="text-sm text-gray-500 dark:text-neutral-500">
Clear status
</label>
<div className="relative">
<select data-hs-select='{
"placeholder": "Status",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 ps-3 pe-7 inline-flex justify-center items-center text-start bg-white border border-gray-200 text-gray-800 text-sm rounded-lg shadow-2xs align-middle focus:outline-hidden focus:ring-2 focus:ring-blue-500 before:absolute before:inset-0 before:z-1 hover:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700",
"dropdownClasses": "mt-2 z-50 w-48 p-1 space-y-0.5 bg-white rounded-xl shadow-xl dark:bg-neutral-900",
"optionClasses": "hs-selected:bg-gray-100 dark:hs-selected:bg-neutral-800 py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800",
"optionTemplate": "<div className=\"flex justify-between items-center w-full\"><span data-title></span><span className=\"hidden hs-selected:block\"><svg className=\"shrink-0 size-3.5 text-gray-800 dark:text-neutral-200\" xmlns=\"http:.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg></span></div>",
"viewport": "#hs-modal-status-body"
}' className="hidden">
<option value="">Choose</option>
<option selected>Never</option>
<option>In 30 minutes</option>
<option>In 1 hour</option>
<option>Today</option>
<option>This week</option>
</select>
<div className="absolute top-1/2 end-2.5 -translate-y-1/2">
<svg className="shrink-0 size-3.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</div>
</div>
</div>
<div className="flex items-center gap-x-3">
<label className="text-sm text-gray-500 dark:text-neutral-500">
Visible to
</label>
<div className="relative inline-block">
<select id="hs-pro-select-visibility" data-hs-select='{
"placeholder": "Visibile to",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"><span className=\"me-2\" data-icon></span><span className=\"text-gray-800 dark:text-neutral-200\" data-title></span></button>",
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 ps-3 pe-7 inline-flex justify-center items-center text-start bg-white border border-gray-200 text-gray-500 text-sm rounded-lg shadow-2xs align-middle focus:outline-hidden focus:ring-2 focus:ring-blue-500 before:absolute before:inset-0 before:z-1 hover:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-500 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700",
"dropdownClasses": "mt-2 z-50 w-48 p-1 space-y-0.5 bg-white rounded-xl shadow-xl dark:bg-neutral-900",
"optionClasses": "hs-selected:bg-gray-100 dark:hs-selected:bg-neutral-800 py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800",
"optionTemplate": "<div><div className=\"flex items-center\"><div className=\"me-2\" data-icon></div><div className=\"font-semibold text-gray-800 dark:text-neutral-200\" data-title></div></div><div className=\"text-sm text-gray-500 dark:text-neutral-500\" data-description></div></div>",
"viewport": "#hs-modal-status-body"
}' className="hidden">
<option value="">Choose</option>
<option value="1" selected data-hs-select-option='{
"description": "Your status will be visible to everyone",
"icon": "<svg className=\"shrink-0 size-4\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" className=\"lucide lucide-globe-2\"><path d=\"M21.54 15H17a2 2 0 0 0-2 2v4.54\"/><path d=\"M7 3.34V5a3 3 0 0 0 3 3v0a2 2 0 0 1 2 2v0c0 1.1.9 2 2 2v0a2 2 0 0 0 2-2v0c0-1.1.9-2 2-2h3.17\"/><path d=\"M11 21.95V18a2 2 0 0 0-2-2v0a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05\"/><circle cx=\"12\" cy=\"12\" r=\"10\"/></svg>"
}'>Anyone</option>
<option value="2" data-hs-select-option='{
"icon": "<svg className=\"inline-block size-4\" width=\"32\" height=\"32\" viewBox=\"0 0 32 32\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M16.0355 1.75926C10.6408 1.75926 5.30597 1.49951 0.0111241 1C-0.288584 7.23393 5.50578 13.1282 12.7987 14.5668L13.9975 14.7266C14.3372 12.4289 15.9956 3.7773 16.595 1.73928C16.4152 1.75926 16.2353 1.75926 16.0355 1.75926Z\" fill=\"#A49DFF\"/><path d=\"M16.615 1.75926C16.615 1.75926 25.2266 7.9932 28.5234 16.3451C30.0419 11.3499 31.1608 6.15498 32 1C26.8051 1.49951 21.71 1.75926 16.615 1.75926Z\" fill=\"#28289A\"/><path d=\"M13.9975 14.7466L13.8177 15.9455C13.8177 15.9455 12.2592 28.4133 23.1886 31.9699C25.2266 26.8748 27.0049 21.6599 28.5234 16.3251C21.9698 15.8456 13.9975 14.7466 13.9975 14.7466Z\" fill=\"#5ADCEE\"/><path d=\"M16.6149 1.75927C16.0155 3.79729 14.3571 12.4089 14.0175 14.7466C14.0175 14.7466 21.9897 15.8456 28.5233 16.3251C25.1866 7.9932 16.6149 1.75927 16.6149 1.75927Z\" fill=\"#7878FF\"/></svg>"
}'>Guideline</option>
</select>
<div className="absolute top-1/2 end-2.5 -translate-y-1/2">
<svg className="shrink-0 size-3.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</div>
</div>
</div>
</div>
</div>
<div className="p-4 flex justify-between gap-x-2">
<div className="flex-1 flex justify-end items-center gap-2">
<button type="button" className="py-2 px-3 text-nowrap inline-flex justify-center items-center text-start whitespace-nowrap bg-white border border-gray-200 text-gray-800 text-sm font-medium rounded-lg shadow-2xs align-middle hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700" data-hs-overlay="#hs-pro-dsm">
Cancel
</button>
<button type="button" className="py-2 px-3 text-nowrap inline-flex justify-center items-center gap-x-2 text-start whitespace-nowrap bg-blue-600 border border-blue-600 text-white text-sm font-medium rounded-lg shadow-2xs align-middle hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:ring-1 focus:ring-blue-300 dark:focus:ring-blue-500" data-hs-overlay="#hs-pro-dsm">
Save status
</button>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,13 @@
// components/Providers.tsx
'use client'
import { SessionProvider } from 'next-auth/react'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider refetchInterval={0} refetchOnWindowFocus={false}>
{children}
</SessionProvider>
)
}

View File

@ -0,0 +1,93 @@
import { useState, useRef, useEffect } from "react";
type Option = {
value: string;
label: string;
};
type SelectProps = {
options: Option[];
placeholder?: string;
value: string;
onChange: (value: string) => void;
dropDirection?: "up" | "down" | "auto";
className?: string;
};
export default function Select({ options, placeholder = "Select option...", value, onChange, dropDirection = "down", className }: SelectProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [direction, setDirection] = useState<"up" | "down">("down");
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open && dropDirection === "auto" && buttonRef.current) {
requestAnimationFrame(() => {
const rect = buttonRef.current!.getBoundingClientRect();
const dropdownHeight = 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
setDirection("up");
} else {
setDirection("down");
}
});
}
}, [open, dropDirection]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const selectedOption = options.find(o => o.value === value);
return (
<div ref={ref} className="relative">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(prev => !prev)}
className={`relative py-2 px-4 pe-10 w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 ${className}`}
>
{selectedOption ? selectedOption.label : placeholder}
<span className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none">
<svg className="w-4 h-4 text-gray-500 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5" />
</svg>
</span>
</button>
{open && (
<ul
className={`absolute z-50 ${
(dropDirection === "auto" ? direction : dropDirection) === "up"
? "bottom-full mb-2"
: "top-full mt-2"
} w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto text-sm dark:bg-neutral-900 dark:border-neutral-700`}
>
{options.map((option) => (
<li
key={option.value}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
className={`py-2 px-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-neutral-800 dark:text-neutral-200 ${
option.value === value ? "bg-gray-100 dark:bg-neutral-800 font-medium" : ""
}`}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,209 @@
'use client'
import { useState } from "react"
import Link from "next/link"
import SidebarFooter from "./SidebarFooter"
import { usePathname } from 'next/navigation'
import Button from "./Button"
export default function Sidebar({ children }: { children?: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
const toggleSidebar = () => {
setIsOpen(prev => !prev)
}
return (
<>
<Button
onClick={toggleSidebar}
color="gray"
variant="solid"
className="absolute items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg sm:hidden focus:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:hover:text-neutral-200 dark:focus:text-neutral-200"
>
<span className="sr-only">Open sidebar</span>
<svg
className="w-6 h-6"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path clipRule="evenodd" fillRule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"></path>
</svg>
</Button>
<div className="flex">
<aside
className={`
fixed top-0 left-0 z-40 h-screen w-64 bg-white dark:bg-neutral-800
border-e border-gray-200 dark:border-neutral-700
transition-transform sm:translate-x-0
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}
aria-label="Sidebar"
>
<div className="relative flex flex-col h-full max-h-full ">
<header className="p-4 flex justify-between items-center gap-x-2">
<a className="flex-none font-semibold text-xl text-black focus:outline-hidden focus:opacity-80 dark:text-white" href="#" aria-label="Iron:e">Iron:e</a>
<div className="lg:hidden -me-2">
<button type="button" onClick={toggleSidebar} className="flex justify-center items-center gap-x-3 size-6 bg-white border border-gray-200 text-sm text-gray-600 hover:bg-gray-100 rounded-full disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:hover:text-neutral-200 dark:focus:text-neutral-200" data-hs-overlay="#hs-sidebar-footer">
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<span className="sr-only">Close</span>
</button>
</div>
</header>
<nav className="h-full overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500">
<div className="hs-accordion-group pb-0 px-2 w-full flex flex-col flex-wrap" data-hs-accordion-always-open>
<ul className="space-y-1">
<li>
<Link
href="/dashboard"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${pathname === '/dashboard'
? 'bg-gray-100 dark:bg-neutral-700 text-gray-900 dark:text-white'
: 'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700'
}`}
>
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Dashboard
</Link>
</li>
<li className="hs-accordion" id="users-accordion">
<button type="button" className="hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" aria-expanded="true" aria-controls="users-accordion-collapse-1">
<svg className="size-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 6a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Zm-1.5 8a4 4 0 0 0-4 4 2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 4 4 0 0 0-4-4h-3Zm6.82-3.096a5.51 5.51 0 0 0-2.797-6.293 3.5 3.5 0 1 1 2.796 6.292ZM19.5 18h.5a2 2 0 0 0 2-2 4 4 0 0 0-4-4h-1.1a5.503 5.503 0 0 1-.471.762A5.998 5.998 0 0 1 19.5 18ZM4 7.5a3.5 3.5 0 0 1 5.477-2.889 5.5 5.5 0 0 0-2.796 6.293A3.501 3.501 0 0 1 4 7.5ZM7.1 12H6a4 4 0 0 0-4 4 2 2 0 0 0 2 2h.5a5.998 5.998 0 0 1 3.071-5.238A5.505 5.505 0 0 1 7.1 12Z" clipRule="evenodd"/>
</svg>
Teams
<svg className="hs-accordion-active:block ms-auto hidden size-4 text-gray-600 group-hover:text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6"/></svg>
<svg className="hs-accordion-active:hidden ms-auto block size-4 text-gray-600 group-hover:text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div id="users-accordion-collapse-1" className="hs-accordion-content w-full overflow-hidden transition-[height] duration-300 hidden" role="region" aria-labelledby="users-accordion">
<ul className="hs-accordion-group pt-1 ps-7 space-y-1" data-hs-accordion-always-open>
<li className="hs-accordion" id="users-accordion-sub-1">
<button type="button" className="hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" aria-expanded="true" aria-controls="users-accordion-sub-1-collapse-1">
Sub Menu 1
<svg className="hs-accordion-active:block ms-auto hidden size-4 text-gray-600 group-hover:text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6"/></svg>
<svg className="hs-accordion-active:hidden ms-auto block size-4 text-gray-600 group-hover:text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div id="users-accordion-sub-1-collapse-1" className="hs-accordion-content w-full overflow-hidden transition-[height] duration-300 hidden" role="region" aria-labelledby="users-accordion-sub-1">
<ul className="pt-1 ps-2 space-y-1">
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 1
</a>
</li>
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 2
</a>
</li>
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 3
</a>
</li>
</ul>
</div>
</li>
<li className="hs-accordion" id="users-accordion-sub-2">
<button type="button" className="hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" aria-expanded="true" aria-controls="users-accordion-sub-2-collapse-1">
Sub Menu 2
<svg className="hs-accordion-active:block ms-auto hidden size-4 text-gray-600 group-hover:text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6"/></svg>
<svg className="hs-accordion-active:hidden ms-auto block size-4 text-gray-600 group-hover:text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div id="users-accordion-sub-2-collapse-1" className="hs-accordion-content w-full overflow-hidden transition-[height] duration-300 hidden" role="region" aria-labelledby="users-accordion-sub-2">
<ul className="pt-1 ps-2 space-y-1">
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 1
</a>
</li>
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 2
</a>
</li>
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 3
</a>
</li>
</ul>
</div>
</li>
</ul>
</div>
</li>
<li className="hs-accordion" id="account-accordion">
<button type="button" className="hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" aria-expanded="true" aria-controls="account-accordion-sub-1-collapse-1">
<svg className="size-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fillRule="evenodd" d="M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4h-4Z" clipRule="evenodd"/></svg>
Spieler
<svg className="hs-accordion-active:block ms-auto hidden size-4 text-gray-600 group-hover:text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6"/></svg>
<svg className="hs-accordion-active:hidden ms-auto block size-4 text-gray-600 group-hover:text-gray-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div id="account-accordion-sub-1-collapse-1" className="hs-accordion-content w-full overflow-hidden transition-[height] duration-300 hidden" role="region" aria-labelledby="account-accordion">
<ul className="pt-1 ps-7 space-y-1">
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 1
</a>
</li>
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 2
</a>
</li>
<li>
<a className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200" href="#">
Link 3
</a>
</li>
</ul>
</div>
</li>
<li>
<Link href="/matches" className="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 dark:text-neutral-200">
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg>
Spielplan {/* <span className="ms-auto py-0.5 px-1.5 inline-flex items-center gap-x-1.5 text-xs bg-gray-200 text-gray-800 rounded-full dark:bg-neutral-600 dark:text-neutral-200">New</span> */}
</Link>
</li>
</ul>
</div>
</nav>
<footer className="mt-auto p-0 border-t border-gray-200 dark:border-neutral-700">
<SidebarFooter></SidebarFooter>
</footer>
</div>
</aside>
<div className="sm:ml-64 flex-1 h-screen overflow-y-auto p-6 bg-white dark:bg-black">
{children}
</div>
</div>
</>
)
}

View File

@ -0,0 +1,172 @@
'use client'
import { signIn, signOut } from 'next-auth/react'
import { useSteamProfile } from '@/app/hooks/useSteamProfile'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { usePathname } from 'next/navigation'
import Script from "next/script";
import LoadingSpinner from '@/app/components/LoadingSpinner'
import Image from 'next/image'
export default function SidebarFooter() {
const { session, steamProfile, status } = useSteamProfile()
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
const [teamName, setTeamName] = useState<string | null>(null)
useEffect(() => {
const loadTeamName = async () => {
const teamId = session?.user?.team
if (!teamId) {
setTeamName(null)
return
}
try {
const res = await fetch(`/api/team/${teamId}`)
const data = await res.json()
setTeamName(data?.teamname ?? null)
} catch (err) {
console.error('[SidebarFooter] TeamName konnte nicht geladen werden:', err)
setTeamName(null)
}
}
loadTeamName()
}, [session?.user?.team])
if (status === 'loading') return <LoadingSpinner />
if (status === 'unauthenticated') {
return (
<>
<button
onClick={() => signIn('steam')}
className="w-full py-4 px-6 bg-green-700 text-white text-sm font-medium hover:bg-green-800 transition"
>
Mit Steam anmelden
</button>
</>
)
}
const user = session!.user
const subline =
teamName // 1. Teamname, wenn vorhanden
?? steamProfile?.steamId // 2. SteamID (wenn bereits vom Hook gemappt)
?? user.id // 3. Fallback auf JWTid
return (
<>
<div className="relative w-full">
{/* Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-full inline-flex items-center gap-x-2 px-4 py-3 text-sm text-left text-gray-800 transition-all duration-300
${isOpen ? 'bg-gray-100 dark:bg-neutral-700' : 'hover:bg-gray-100 dark:hover:bg-neutral-700'}
`}
>
<div className="shrink-0 group block">
<div className="flex items-center">
<Image
src={steamProfile?.avatarfull || user?.image || '/default-avatar.png'}
quality={75}
width={40}
height={40}
className="inline-block shrink-0 size-10 rounded-full"
draggable={false}
alt="Avatar"
/>
<div className="ms-3">
<h3 className="font-semibold text-gray-800 dark:text-white">{steamProfile?.personaname || user?.name}</h3>
<p className="text-xs font-medium text-gray-400 dark:text-neutral-500">{subline}</p>
</div>
</div>
</div>
<svg
className={`ms-auto size-4 group-hover:text-gray-500 ${
isOpen ? 'rotate-180' : ''
} text-gray-600 dark:text-neutral-400`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeWidth={2} d="m5 15 7-7 7 7" />
</svg>
</button>
{/* Menü */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden w-full bg-white shadow-lg dark:bg-neutral-800 dark:border-neutral-600 z-20"
>
<div className="p-2 flex flex-col gap-1">
<Link
href="/profile"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${pathname === '/profile'
? 'bg-gray-100 dark:bg-neutral-700 text-gray-900 dark:text-white'
: 'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700'
}`}
>
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg>
Mein Profil
</Link>
<Link
href="/settings"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${pathname.startsWith('/settings')
? 'bg-gray-100 dark:bg-neutral-700 text-gray-900 dark:text-white'
: 'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700'
}`}
>
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
<path d="M10 19H5a1 1 0 0 1-1-1v-1a3 3 0 0 1 3-3h2m10 1a3 3 0 0 1-3 3m3-3a3 3 0 0 0-3-3m3 3h1m-4 3a3 3 0 0 1-3-3m3 3v1m-3-4a3 3 0 0 1 3-3m-3 3h-1m4-3v-1m-2.121 1.879-.707-.707m5.656 5.656-.707-.707m-4.242 0-.707.707m5.656-5.656-.707.707M12 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
Einstellungen
</Link>
{user?.isAdmin && (
<Link
href="/admin"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${pathname.startsWith('/settings/admin')
? 'bg-gray-100 dark:bg-neutral-700 text-gray-900 dark:text-white'
: 'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700'
}`}
>
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
<path transform="scale(0.046875)" d="M78.6 5C69.1-2.4 55.6-1.5 47 7L7 47c-8.5 8.5-9.4 22-2.1 31.6l80 104c4.5 5.9 11.6 9.4 19 9.4l54.1 0 109 109c-14.7 29-10 65.4 14.3 89.6l112 112c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-112-112c-24.2-24.2-60.6-29-89.6-14.3l-109-109 0-54.1c0-7.5-3.5-14.5-9.4-19L78.6 5zM19.9 396.1C7.2 408.8 0 426.1 0 444.1C0 481.6 30.4 512 67.9 512c18 0 35.3-7.2 48-19.9L233.7 374.3c-7.8-20.9-9-43.6-3.6-65.1l-61.7-61.7L19.9 396.1zM512 144c0-10.5-1.1-20.7-3.2-30.5c-2.4-11.2-16.1-14.1-24.2-6l-63.9 63.9c-3 3-7.1 4.7-11.3 4.7L352 176c-8.8 0-16-7.2-16-16l0-57.4c0-4.2 1.7-8.3 4.7-11.3l63.9-63.9c8.1-8.1 5.2-21.8-6-24.2C388.7 1.1 378.5 0 368 0C288.5 0 224 64.5 224 144l0 .8 85.3 85.3c36-9.1 75.8 .5 104 28.7L429 274.5c49-23 83-72.8 83-130.5zM56 432a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z"/>
</svg>
Administration
</Link>
)}
<Link
href="#"
onClick={() => signOut({ callbackUrl: '/' })}
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors text-gray-800 hover:bg-red-100 dark:text-neutral-300 dark:hover:bg-red-700`} >
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
<path d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/>
</svg>
Abmelden
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</>
)
}

View File

@ -0,0 +1,71 @@
'use client'
import { useSortable, defaultAnimateLayoutChanges } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import MiniCard from './MiniCard'
import { Player } from '../types/team'
type Props = {
player: Player
currentUserSteamId: string
teamLeaderSteamId: string
onKick?: (player: Player) => void
onPromote?: (steamId: string) => void
hideOverlay?: boolean
isDraggingGlobal?: boolean
matchParentBg?: boolean
}
export default function SortableMiniCard({
player,
onKick,
onPromote,
currentUserSteamId,
teamLeaderSteamId,
hideOverlay = false
}: Props) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: player.steamId,
animateLayoutChanges: defaultAnimateLayoutChanges,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const isDraggable = currentUserSteamId === teamLeaderSteamId
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className="transition-transform duration-300 ease-in-out"
>
<MiniCard
steamId={player.steamId}
title={player.name}
avatar={player.avatar}
location={player.location}
isLeader={player.steamId === teamLeaderSteamId}
draggable={isDraggable}
onKick={() => onKick?.(player)}
onPromote={onPromote}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={teamLeaderSteamId}
dragListeners={isDraggable ? listeners : undefined}
hoverEffect={isDraggable}
hideOverlay={hideOverlay}
/>
</div>
)
}

View File

@ -0,0 +1,50 @@
// components/Switch.tsx
'use client'
import React from 'react'
type SwitchProps = {
id: string
checked: boolean
onChange: (checked: boolean) => void
labelLeft?: string
labelRight?: string
className?: string
}
export default function Switch({
id,
checked,
onChange,
labelLeft,
labelRight,
className = '',
}: SwitchProps) {
return (
<div className={`flex items-center gap-x-3 ${className}`}>
{labelLeft && (
<label htmlFor={id} className="text-sm text-gray-500 dark:text-neutral-400">
{labelLeft}
</label>
)}
<label htmlFor={id} className="relative inline-block w-11 h-6 cursor-pointer">
<input
type="checkbox"
id={id}
className="peer sr-only"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="absolute inset-0 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out peer-checked:bg-blue-600 dark:bg-neutral-700 dark:peer-checked:bg-blue-500 peer-disabled:opacity-50 peer-disabled:pointer-events-none"></span>
<span className="absolute top-1/2 start-0.5 -translate-y-1/2 size-5 bg-white rounded-full shadow-xs transition-transform duration-200 ease-in-out peer-checked:translate-x-full dark:bg-neutral-400 dark:peer-checked:bg-white"></span>
</label>
{labelRight && (
<label htmlFor={id} className="text-sm text-gray-500 dark:text-neutral-400">
{labelRight}
</label>
)}
</div>
)
}

View File

@ -0,0 +1,16 @@
'use client'
import Link from 'next/link'
type TabProps = {
name: string
href: string
}
export default function Tab({ name, href }: TabProps) {
return (
<a href={href} className="px-4 py-2 text-sm hover:underline">
{name}
</a>
)
}

View File

@ -0,0 +1,70 @@
// components/ui/Table.tsx
'use client'
import React, { ReactNode } from 'react'
type TableProps = {
children: ReactNode
}
function TableWrapper({ children }: TableProps) {
return (
<div className="flex flex-col">
<div className="-m-1.5 overflow-x-auto">
<div className="p-1.5 min-w-full inline-block align-middle">
<div className="border border-t-0 border-gray-200 overflow-hidden dark:border-neutral-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
{children}
</table>
</div>
</div>
</div>
</div>
)
}
function Head({ children }: { children: ReactNode }) {
return <thead className="bg-gray-50 dark:bg-neutral-700">{children}</thead>
}
function Body({ children }: { children: ReactNode }) {
return <tbody className="divide-y divide-gray-200 dark:divide-neutral-700">{children}</tbody>
}
function Row({
children,
hoverable = false,
}: {
children: ReactNode
hoverable?: boolean
}) {
const className = hoverable ? 'hover:bg-gray-100 dark:hover:bg-neutral-700' : ''
return <tr className={className}>{children}</tr>
}
function Cell({
children,
as: Component = 'td',
className = '',
}: {
children?: ReactNode
as?: 'td' | 'th'
className?: string
}) {
const baseClass =
Component === 'th'
? 'px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-neutral-400'
: 'px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200'
return <Component className={`${baseClass} ${className}`}>{children}</Component>
}
// 📦 Zusammensetzen:
const Table = Object.assign(TableWrapper, {
Head,
Body,
Row,
Cell,
})
export default Table

View File

@ -0,0 +1,46 @@
'use client'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import type { ReactNode, ReactElement } from 'react'
type TabProps = {
name: string
href: string
}
export function Tabs({ children }: { children: ReactNode }) {
const pathname = usePathname()
const tabs = Array.isArray(children) ? children : [children]
return (
<nav className="flex gap-x-1" aria-label="Tabs" role="tablist" aria-orientation="horizontal">
{tabs
.filter(
(tab): tab is ReactElement<TabProps> =>
tab !== null &&
typeof tab === 'object' &&
'props' in tab &&
typeof tab.props.href === 'string'
)
.map((tab, index) => {
const slug = tab.props.href.split('/').pop()
const isActive = pathname.endsWith(slug ?? '')
return (
<Link
key={index}
href={tab.props.href}
className={`py-2 px-4 text-sm rounded-lg transition-colors ${
isActive
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700'
}`}
>
{tab.props.name}
</Link>
)
})}
</nav>
)
}

View File

@ -0,0 +1,103 @@
// components/TeamCard.tsx
'use client'
import { useEffect, useState } from 'react'
import Button from './Button'
import { useWebSocketListener } from '@/app/hooks/useWebSocketListener'
import { Team, Player } from '../types/team'
import { useLiveTeam } from '../hooks/useLiveTeam'
type Props = {
team: Team
currentUserSteamId: string
invitationId?: string
onUpdateInvitation: (teamId: string, newValue: string | null) => void
}
export default function TeamCard({ team, currentUserSteamId, invitationId, onUpdateInvitation }: Props) {
const [joining, setJoining] = useState(false)
const data = useLiveTeam(team)
if (!data || !data.players) {
return <p className="text-sm text-gray-400">Lade Team </p>
}
const handleClick = async () => {
if (joining) return
setJoining(true)
try {
if (invitationId) {
await fetch('/api/user/invitations/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId }),
})
onUpdateInvitation(data.id, null)
} else {
const res = await fetch('/api/team/request-join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: data.id }),
})
if (!res.ok) throw new Error()
onUpdateInvitation(data.id, 'dummy-id') // ← bei Bedarf mit realer ID aktualisieren
}
} catch (err) {
console.error('Fehler bei Join/Reject:', err)
} finally {
setJoining(false)
}
}
const isRequested = !!invitationId
const isDisabled = joining || currentUserSteamId === data.leader
return (
<div className="p-4 border rounded-lg bg-white dark:bg-neutral-800 dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-3">
<img
src={data.logo ? `/assets/img/logos/${data.logo}` : '/assets/img/logos/placeholder.png'}
alt={data.teamname ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
/>
<span className="font-medium truncate text-gray-500 dark:text-neutral-400">
{data.teamname ?? 'Team'}
</span>
</div>
<Button
title={isRequested ? 'Angefragt (zurückziehen)' : 'Beitreten'}
size="sm"
color={isRequested ? 'gray' : 'blue'}
disabled={isDisabled}
onClick={(e) => {
e.stopPropagation()
handleClick()
}}
>
{joining ? '...' : isRequested ? 'Angefragt' : 'Beitreten'}
</Button>
</div>
<div className="flex -space-x-3">
{data.players.slice(0, 5).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"
/>
))}
{data.players.length > 5 && (
<span className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 text-xs">
+{data.players.length - 5}
</span>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,92 @@
'use client'
import { forwardRef, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useTeamManager } from '../hooks/useTeamManager'
import TeamInvitationView from './TeamInvitationView'
import TeamMemberView from './TeamMemberView'
import NoTeamView from './NoTeamView'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import CreateTeamButton from './CreateTeamButton'
type Props = {
refetchKey?: string
}
/* eslintdisable react/displayname */
function TeamCardComponent(props: Props, ref: any) {
const { data: session } = useSession()
const steamId = session?.user?.steamId ?? ''
const [refetchKey, setRefetchKey] = useState<string>()
const teamManager = useTeamManager({ ...props, refetchKey }, ref)
// 1. Loading
if (teamManager.isLoading) return <LoadingSpinner />
// 2. Pending invitation
if (
!teamManager.team &&
teamManager.pendingInvitation &&
teamManager.pendingInvitation.type === 'team-invite'
) {
const notificationId = teamManager.pendingInvitation.id
return (
<TeamInvitationView
invitation={teamManager.pendingInvitation}
notificationId={notificationId}
onMarkAsRead={teamManager.markOneAsRead}
onAction={async (action, invitationId) => {
if (action === 'accept') {
await teamManager.acceptInvitation(invitationId)
} else {
await teamManager.rejectInvitation(invitationId)
}
await teamManager.markOneAsRead(notificationId)
}}
/>
)
}
// 3. Kein Team → Hinweis + CreateTeamButton
if (!teamManager.team) {
return (
<div className="p-6 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700 space-y-4">
<NoTeamView />
<div className="pt-2">
<CreateTeamButton setRefetchKey={setRefetchKey} />
</div>
</div>
)
}
// 4. Team vorhanden → Member view
return (
<div className="p-5 md:p-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700">
<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
{...teamManager}
currentUserSteamId={steamId}
/>
<div className="flex gap-x-3">
<CreateTeamButton setRefetchKey={setRefetchKey} />
</div>
</form>
</div>
)
}
export default forwardRef(TeamCardComponent)

View File

@ -0,0 +1,66 @@
'use client'
import { useEffect, useState } from 'react'
import ComboBox from './ComboBox'
export default function TeamComboBox() {
const [teams, setTeams] = useState<{ id: string; teamname: string }[]>([])
const [selectedTeam, setSelectedTeam] = useState('')
useEffect(() => {
const fetchTeams = async () => {
try {
const res = await fetch('/api/team/list')
const data = await res.json()
setTeams(data.teams || [])
if (data.teams?.[0]) setSelectedTeam(data.teams[0].teamname)
} catch (err) {
console.error('Fehler beim Laden der Teams:', err)
}
}
fetchTeams()
}, [])
return (
<div className="relative" data-hs-combo-box="">
<ComboBox value={selectedTeam}>
{teams.map((team) => (
<div
key={team.id}
className="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800"
role="option"
tabIndex={0}
data-hs-combo-box-output-item=""
data-hs-combo-box-item-stored-data={JSON.stringify({ id: team.id, name: team.teamname })}
onClick={() => setSelectedTeam(team.teamname)}
>
<div className="flex justify-between items-center w-full">
<span data-hs-combo-box-search-text={team.teamname} data-hs-combo-box-value="">
{team.teamname}
</span>
{selectedTeam === team.teamname && (
<span className="hidden hs-combo-box-selected:block">
<svg
className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
)}
</div>
</div>
))}
</ComboBox>
</div>
)
}

View File

@ -0,0 +1,60 @@
'use client'
import { useState } from 'react'
import Button from './Button'
import { Invitation } from '../hooks/useTeamManager'
type Props = {
invitation: Invitation
notificationId: string
onAction: (action: 'accept' | 'reject', invitationId: string) => Promise<void>
onMarkAsRead: (id: string) => Promise<void>
}
export default function TeamInvitationView({
invitation,
notificationId,
onAction,
onMarkAsRead,
}: Props) {
const [isSubmitting, setIsSubmitting] = useState(false)
if (!invitation) return null
const handleRespond = async (action: 'accept' | 'reject') => {
try {
setIsSubmitting(true)
await onAction(action, invitation.id)
await onMarkAsRead(notificationId)
} catch (err) {
console.error(`Fehler beim ${action === 'accept' ? 'Annehmen' : 'Ablehnen'} der Einladung:`, err)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="p-4 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
Du wurdest eingeladen!
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">{invitation.teamName}</p>
<div className="flex gap-2">
<Button
onClick={() => handleRespond('accept')}
className="px-2 py-1 text-sm font-medium rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
disabled={isSubmitting}
>
Annehmen
</Button>
<Button
onClick={() => handleRespond('reject')}
className="px-2 py-1 text-sm font-medium rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
disabled={isSubmitting}
>
Ablehnen
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,506 @@
'use client'
import { useEffect, useState } from 'react'
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { DroppableZone } from './DroppableZone'
import MiniCardDummy from './MiniCardDummy'
import SortableMiniCard from './SortableMiniCard'
import LeaveTeamModal from './LeaveTeamModal'
import InvitePlayersModal from './InvitePlayersModal'
import Modal from './Modal'
import { Player, Team } from '../types/team'
import { useSession } from 'next-auth/react'
import { useWS } from '@/app/lib/wsStore'
import { AnimatePresence, motion } from 'framer-motion'
import { useTeamManager } from '../hooks/useTeamManager'
import Button from './Button'
import Image from 'next/image'
type Props = {
team: Team | null
activePlayers: Player[]
inactivePlayers: Player[]
activeDragItem: Player | null
isDragging: boolean
showLeaveModal: boolean
showInviteModal: boolean
currentUserSteamId: string
setShowLeaveModal: (v: boolean) => void
setShowInviteModal: (v: boolean) => void
setActiveDragItem: (item: Player | null) => void
setIsDragging: (v: boolean) => void
setactivePlayers: (players: Player[]) => void
setInactivePlayers: (players: Player[]) => void
}
export default function TeamMemberView({
team,
activePlayers,
inactivePlayers,
activeDragItem,
isDragging,
showLeaveModal,
showInviteModal,
setShowLeaveModal,
setShowInviteModal,
setActiveDragItem,
setIsDragging,
setactivePlayers,
setInactivePlayers,
}: Props) {
const { data: session } = useSession()
const { socket } = useWS()
const [kickCandidate, setKickCandidate] = useState<Player | null>(null)
const [promoteCandidate, setPromoteCandidate] = useState<Player | null>(null)
const currentUserSteamId = session?.user?.steamId || ''
const isLeader = currentUserSteamId === team?.leader
const { leaveTeam, reloadTeam, renameTeam, deleteTeam } = useTeamManager({}, null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isEditingName, setIsEditingName] = useState(false)
const [editedName, setEditedName] = useState(team?.teamname || '')
const [isEditingLogo, setIsEditingLogo] = useState(false)
const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [logoFile, setLogoFile] = useState<File | null>(null)
useEffect(() => {
if (!socket || !team?.id) return
const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data)
const relevantTypes = [
'team-updated',
'team-kick',
'team-kick-other',
'team-member-joined',
'team-member-left',
'team-leader-changed',
'team-renamed',
'team-logo-updated',
]
if (relevantTypes.includes(data.type) && typeof data.teamId === 'string') {
fetch(`/api/team/${encodeURIComponent(data.teamId)}`)
.then((res) => res.json())
.then((data) => {
setactivePlayers(
(data.activePlayers ?? [])
.filter((p: Player) => p?.name)
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
);
setInactivePlayers(
(data.inactivePlayers ?? [])
.filter((p: Player) => p?.name)
.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
);
})
}
}
socket.addEventListener('message', handleMessage)
return () => socket.removeEventListener('message', handleMessage)
}, [socket, team?.id])
const handleDragStart = (event: any) => {
const id = event.active.id
const item = activePlayers.find(p => p.steamId === id) || inactivePlayers.find(p => p.steamId === id)
if (item) {
setActiveDragItem(item)
setIsDragging(true)
}
}
const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => {
try {
await fetch('/api/team/update-players', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teamId,
activePlayers: active.map(p => p.steamId),
inactivePlayers: inactive.map(p => p.steamId),
}),
})
} catch (err) {
console.error('Fehler beim Aktualisieren:', err)
}
}
const handleDragEnd = async (event: any) => {
setActiveDragItem(null)
setIsDragging(false)
const { active, over } = event
if (!over) return
const activeId = active.id
const overId = over.id
if (!activeId || !overId) return
const movingItem = [...activePlayers, ...inactivePlayers].find(p => p.steamId === activeId)
if (!movingItem) return
const isInActiveZone = activePlayers.some(p => p.steamId === activeId)
const isInInactiveZone = inactivePlayers.some(p => p.steamId === activeId)
const targetIsActiveZone = overId === 'active' || activePlayers.some(p => p.steamId === overId)
const targetIsInactiveZone = overId === 'inactive' || inactivePlayers.some(p => p.steamId === overId)
if ((isInActiveZone && targetIsActiveZone) || (isInInactiveZone && targetIsInactiveZone)) return
let newActive = [...activePlayers]
let newInactive = [...inactivePlayers]
if (targetIsActiveZone) {
if (newActive.length >= 5) return
newInactive = newInactive.filter(p => p.steamId !== activeId)
if (!newActive.some(p => p.steamId === activeId)) newActive.push(movingItem)
} else {
newActive = newActive.filter(p => p.steamId !== activeId)
if (!newInactive.some(p => p.steamId === activeId)) newInactive.push(movingItem)
}
newActive.sort((a, b) => a.name.localeCompare(b.name))
newInactive.sort((a, b) => a.name.localeCompare(b.name))
setactivePlayers(newActive)
setInactivePlayers(newInactive)
await updateTeamMembers(team!.id, newActive, newInactive)
}
const confirmKick = async () => {
if (!kickCandidate) return
const newActive = activePlayers.filter(p => p.steamId !== kickCandidate.steamId)
const newInactive = inactivePlayers.filter(p => p.steamId !== kickCandidate.steamId)
setactivePlayers(newActive)
setInactivePlayers(newInactive)
await fetch('/api/team/kick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steamId: kickCandidate.steamId, teamId: team!.id }),
})
await updateTeamMembers(team!.id, newActive, newInactive)
setKickCandidate(null)
}
const promoteToLeader = async (newLeaderId: string) => {
try {
const res = await fetch('/api/team/transfer-leader', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team!.id, newLeaderSteamId: newLeaderId }),
})
if (!res.ok) {
const data = await res.json()
console.error('Fehler bei Leader-Übertragung:', data.message)
return
}
await reloadTeam()
} catch (err) {
console.error('Fehler bei Leader-Übertragung:', err)
}
}
if (!team || !currentUserSteamId) return null
const renderMemberList = (players: Player[]) => (
<AnimatePresence>
{players.map(player => (
<motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
<SortableMiniCard
player={player}
onKick={setKickCandidate}
onPromote={() => setPromoteCandidate(player)}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader}
isDraggingGlobal={isDragging}
hideOverlay={isDragging}
matchParentBg={true}
/>
</motion.div>
))}
</AnimatePresence>
)
return (
<div className={`p-4 my-6 sm:my-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700 ${isDragging ? 'cursor-grabbing' : ''}`}>
<div className="flex justify-between items-center mb-6 flex-wrap gap-2">
<div className="flex items-center gap-4">
{/* Teamlogo mit Fallback */}
<div className="relative group">
<div
className="relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600 cursor-pointer"
onClick={() => isLeader && document.getElementById('logoUpload')?.click()}
>
<Image
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/placeholder.png`}
alt="Teamlogo"
fill
sizes="64px"
quality={75}
className="object-cover"
priority={false}
/>
{/* Overlay beim Hover */}
{isLeader && (
<div className="absolute inset-0 bg-black bg-opacity-50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5 mb-1"
viewBox="0 0 576 512"
fill="currentColor"
>
<path d="M288 109.3L288 352c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-242.7-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352l128 0c0 35.3 28.7 64 64 64s64-28.7 64-64l128 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/>
</svg>
</div>
)}
</div>
{/* Hidden file input */}
{isLeader && (
<input
type="file"
accept="image/*"
id="logoUpload"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('logo', file)
formData.append('teamId', team.id)
const res = await fetch('/api/team/upload-logo', {
method: 'POST',
body: formData,
})
if (res.ok) {
await reloadTeam()
} else {
alert('Fehler beim Hochladen des Logos.')
}
}}
/>
)}
</div>
{/* Teamname + Bearbeiten */}
<div className="flex items-center gap-2">
{isEditingName ? (
<>
<input
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
className="py-1.5 px-3 border rounded-lg text-sm dark:bg-neutral-800 dark:border-neutral-700 dark:text-white"
/>
{/* ✔ Übernehmen */}
<Button
title="Übernehmen"
color="green"
size="sm"
variant="soft"
onClick={async () => {
await renameTeam(team.id, editedName)
setIsEditingName(false)
await reloadTeam()
}}
className="p-1.5"
>
</Button>
{/* ✖ Abbrechen */}
<Button
title="Abbrechen"
color="red"
size="sm"
variant="ghost"
onClick={() => {
setIsEditingName(false)
setEditedName(team.teamname ?? '')
}}
className="p-1.5"
>
</Button>
</>
) : (
<>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
{team.teamname ?? 'Team'}
</h2>
{isLeader && (
<button
onClick={() => {
setIsEditingName(true)
setEditedName(team.teamname || '')
}}
className="text-sm text-blue-600 hover:underline"
>
Bearbeiten
</button>
)}
</>
)}
</div>
</div>
{/* Aktionen */}
<div className="flex gap-2">
{isLeader && (
<button
onClick={() => setShowDeleteModal(true)}
className="text-sm px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Team löschen
</button>
)}
<button
onClick={async () => {
if (isLeader) {
setShowLeaveModal(true)
} else {
try {
await leaveTeam(currentUserSteamId)
} catch (err) {
console.error('Fehler beim Verlassen:', err)
}
}
}}
className="text-sm px-3 py-1.5 bg-gray-200 text-black rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600"
>
Team verlassen
</button>
</div>
</div>
<DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8">
<DroppableZone id="active" label={`Aktives Team (${activePlayers.length} / 5)`} activeDragItem={activeDragItem}>
<SortableContext items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(activePlayers)}
</SortableContext>
</DroppableZone>
<DroppableZone id="inactive" label="Inaktives Team" activeDragItem={activeDragItem}>
<SortableContext items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
{renderMemberList(inactivePlayers)}
{isLeader && (
<motion.div key="mini-card-dummy" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.2 }}>
<MiniCardDummy
title="Einladen"
onClick={() => {
setShowInviteModal(false)
setTimeout(() => setShowInviteModal(true), 0)
}}
>
<div className="flex items-center justify-center w-16 h-16 bg-white rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8 text-black" fill="currentColor" viewBox="0 0 640 512">
<path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM504 312v-64h-64c-13.3 0-24-10.7-24-24s10.7-24 24-24h64v-64c0-13.3 10.7-24 24-24s24 10.7 24 24v64h64c13.3 0 24 10.7 24 24s-10.7 24-24 24h-64v64c0 13.3-10.7 24-24 24s-24-10.7-24-24z" />
</svg>
</div>
</MiniCardDummy>
</motion.div>
)}
</SortableContext>
</DroppableZone>
</div>
<DragOverlay>
{activeDragItem && (
<SortableMiniCard
player={activeDragItem}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader}
hideOverlay
matchParentBg
/>
)}
</DragOverlay>
</DndContext>
{isLeader && (
<>
<LeaveTeamModal show={showLeaveModal} onClose={() => setShowLeaveModal(false)} onSuccess={() => setShowLeaveModal(false)} team={team} />
<InvitePlayersModal show={showInviteModal} onClose={() => setShowInviteModal(false)} onSuccess={() => {}} team={team} />
</>
)}
{isLeader && promoteCandidate && (
<Modal
id={`modal-promote-player-${promoteCandidate.steamId}`}
title="Leader übertragen"
show={true}
onClose={() => setPromoteCandidate(null)}
onSave={async () => {
await promoteToLeader(promoteCandidate.steamId)
setPromoteCandidate(null)
}}
closeButtonTitle="Übertragen"
closeButtonColor="blue"
>
<p className="text-sm text-gray-700 dark:text-neutral-300">
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Teamleader machen?
</p>
</Modal>
)}
{isLeader && kickCandidate && (
<Modal
id={`modal-kick-player-${kickCandidate.steamId}`}
title="Mitglied entfernen"
show={true}
onClose={() => setKickCandidate(null)}
onSave={confirmKick}
closeButtonTitle="Entfernen"
closeButtonColor="red"
>
<p className="text-sm text-gray-700 dark:text-neutral-300">
Möchtest du <strong>{kickCandidate.name}</strong> wirklich aus dem Team entfernen?
</p>
</Modal>
)}
{isLeader && (
<Modal
id="modal-delete-team"
title="Team löschen"
show={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onSave={async () => {
await fetch('/api/team/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team.id }),
})
setShowDeleteModal(false)
window.location.href = '/'
}}
closeButtonTitle="Löschen"
closeButtonColor="red"
>
<p className="text-sm text-gray-700 dark:text-neutral-300">
Bist du sicher, dass du dieses Team löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.
</p>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,31 @@
'use client'
import { useEffect, useState } from 'react'
import ComboBox from '@/app/components/ComboBox'
export default function TeamSelector() {
const [teams, setTeams] = useState<string[]>([])
const [selectedTeam, setSelectedTeam] = useState('')
useEffect(() => {
const fetchTeams = async () => {
try {
const res = await fetch('/api/team/list')
const data = await res.json()
setTeams(data.teams ?? []) // Fallback zu leerem Array
} catch (err) {
console.error('Fehler beim Laden der Teams:', err)
}
}
fetchTeams()
}, [])
return (
<ComboBox
value={selectedTeam}
items={teams}
onSelect={setSelectedTeam}
/>
)
}

View File

@ -0,0 +1,27 @@
'use client'
import { ReactNode } from 'react'
export type TooltipProps = {
content: string
children: ReactNode
placement?: 'top' | 'bottom' | 'left' | 'right'
}
export default function Tooltip({ content, children, placement = 'top' }: TooltipProps) {
const placementClass = placement === 'top' ? '' : `[--placement:${placement}]`
return (
<div className={`hs-tooltip inline-block ${placementClass}`}>
<button type="button" className="hs-tooltip-toggle">
{children}
<span
className="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-gray-900 text-xs font-medium text-white rounded-md shadow-2xs dark:bg-neutral-700"
role="tooltip"
>
{content}
</span>
</button>
</div>
)
}

View File

@ -0,0 +1,83 @@
'use client'
import { useSession } from 'next-auth/react'
import { useEffect } from 'react'
import { useWS } from '@/app/lib/wsStore'
export default function WebSocketManager() {
const { data: session } = useSession()
const connectWS = useWS((s) => s.connect)
const disconnectWS = useWS((s) => s.disconnect)
useEffect(() => {
if (!session?.user?.steamId) return
connectWS(session.user.steamId)
const socket = useWS.getState().socket
if (!socket) return
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
// Typbasierter Event-Dispatch
switch (data.type) {
case 'invitation':
window.dispatchEvent(new CustomEvent('ws-invitation'))
break
case 'team-update':
window.dispatchEvent(new CustomEvent('ws-team-update'))
break
case 'team-kick':
window.dispatchEvent(new CustomEvent('ws-team-kick'))
break
case 'team-kick-other':
window.dispatchEvent(new CustomEvent('ws-team-kick-other'))
break
case 'team-joined':
window.dispatchEvent(new CustomEvent('ws-team-joined'))
break
case 'team-member-joined':
window.dispatchEvent(new CustomEvent('ws-team-member-joined'))
break
case 'team-invite':
window.dispatchEvent(new CustomEvent('ws-team-invite'))
break
case 'team-invite-reject':
window.dispatchEvent(new CustomEvent('ws-team-invite-reject'))
break
case 'team-left':
window.dispatchEvent(new CustomEvent('ws-team-left'))
break
case 'team-member-left':
window.dispatchEvent(new CustomEvent('ws-team-member-left'))
break
case 'team-leader-changed':
window.dispatchEvent(new CustomEvent('ws-team-leader-changed'))
break
case 'team-join-request':
window.dispatchEvent(new CustomEvent('ws-team-join-request'))
break
case 'team-renamed':
console.log('[WS] team-renamed', data.teamId)
window.dispatchEvent(new CustomEvent('ws-team-renamed', {
detail: { teamId: data.teamId }
}))
break
case 'team-logo-updated':
window.dispatchEvent(new CustomEvent('ws-team-logo-updated', { detail: { teamId: data.teamId } }))
break
// Weitere Events hier hinzufügen ...
}
} catch (error) {
console.error('[WebSocket] Ungültige Nachricht:', event.data)
}
}
return () => disconnectWS()
}, [session?.user?.steamId])
return null
}

View File

@ -0,0 +1,54 @@
'use client'
import DeleteAccountSettings from "./account/DeleteAccountSettings"
import AppearanceSettings from "./account/AppearanceSettings"
import AuthCodeSettings from "./account/AuthCodeSettings"
import LatestKnownCodeSettings from "./account/LatestKnownCodeSettings"
export default function AccountSettings() {
return (
<>{/* Account Card */}
<div className="p-5 md:p-8 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700">
{/* Title */}
<div className="mb-4 xl:mb-8">
<h1 className="text-lg font-semibold text-gray-800 dark:text-neutral-200">
Accounteinstellungen
</h1>
<p className="text-sm text-gray-500 dark:text-neutral-500">
Passe das Erscheinungsbild der Webseite an
</p>
</div>
{/* End Title */}
{/* Form */}
<form>
{/* Auth Code Settings */}
<AuthCodeSettings />
{/* End Auth Code Settings */}
{/* Appearance */}
<AppearanceSettings />
{/* End Appearance */}
{/* Appearance */}
<DeleteAccountSettings />
{/* End Appearance */}
{/* Button Group */}
<div className="flex gap-x-3">
<button type="button" className="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-transparent bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:ring-2 focus:ring-blue-500">
Save changes
</button>
<button type="button" className="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700">
Cancel
</button>
</div>
{/* End Button Group */}
</form>
{/* End Form */}
</div>
{/* End Account Card */}
</>
)
}

View File

@ -0,0 +1,84 @@
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export default function AppearanceSettings() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
const options = [
{ id: 'system', label: 'System', img: 'account-system-image.svg' },
{ id: 'light', label: 'Hell', img: 'account-light-image.svg' },
{ id: 'dark', label: 'Dunkel', img: 'account-dark-image.svg' },
]
return (
<div className="py-6 sm:py-8 space-y-5 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="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
</label>
</div>
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
<p className="text-sm text-gray-500 dark:text-neutral-500">
Wähle dein bevorzugtes Design. Du kannst einen festen Stil verwenden oder das Systemverhalten übernehmen.
</p>
<h3 className="mt-3 text-sm font-semibold text-gray-800 dark:text-neutral-200">
Theme-Modus
</h3>
<p className="text-sm text-gray-500 dark:text-neutral-500">
Die Darstellung passt sich automatisch deinem Gerät an, wenn System gewählt ist.
</p>
<div className="mt-5">
<div className="grid grid-cols-3 gap-x-2 sm:gap-x-4">
{options.map(({ id, label, img }) => {
const isChecked = theme === id
return (
<label
key={id}
htmlFor={`theme-${id}`}
className={`w-full sm:w-auto flex flex-col bg-white text-center cursor-pointer rounded-xl ring-1 ring-gray-200 dark:bg-neutral-800 dark:text-neutral-200 dark:ring-neutral-700
${isChecked ? 'ring-1 ring-blue-600 dark:ring-blue-500' : 'ring-1 ring-gray-200 dark:ring-neutral-700'}`}
>
<input
type="radio"
id={`theme-${id}`}
name="theme-mode"
value={id}
className="hidden"
checked={isChecked}
onChange={() => setTheme(id)}
/>
<img className="rounded-t-[14px] -mt-px" src={img ? `/assets/img/themes/${img}` : '/assets/img/logos/placeholder.png'} alt={label} loading="lazy" />
<span
className={`py-3 px-2 text-sm font-semibold rounded-b-xl
${isChecked
? 'bg-blue-600 text-white'
: 'text-gray-800 dark:text-neutral-200'
}`}
>
{label}
</span>
</label>
)
})}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,172 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
import Popover from '../../Popover'
import LatestKnownCodeSettings from './LatestKnownCodeSettings'
export default function AuthCodeSettings() {
const { data: session } = useSession()
const [userId, setUserId] = useState<string | null>(null)
const [authCode, setAuthCode] = useState('')
const [authCodeValid, setAuthCodeValid] = useState(false)
const [lastKnownShareCode, setLastKnownShareCode] = useState('')
const [lastKnownShareCodeValid, setLastKnownShareCodeValid] = useState(false)
const [touched, setTouched] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const formatAuthCode = (value: string) => {
const raw = value.replace(/[^A-Za-z0-9]/g, '').toUpperCase()
const part1 = raw.slice(0, 4)
const part2 = raw.slice(4, 9)
const part3 = raw.slice(9, 13)
return [part1, part2, part3].filter(Boolean).join('-')
}
const validateAuthCode = (value: string) =>
/^[A-Z0-9]{4}-[A-Z0-9]{5}-[A-Z0-9]{4}$/.test(value)
const validateShareCode = (value: string) =>
/^CSGO(-[a-zA-Z0-9]{5}){5}$/.test(value)
const saveCodes = async (updatedAuthCode = authCode, updatedKnownCode = lastKnownShareCode) => {
if (!validateAuthCode(updatedAuthCode) || !validateShareCode(updatedKnownCode)) return
try {
await fetch('/api/cs2/sharecode', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
authCode: updatedAuthCode,
lastKnownShareCode: updatedKnownCode,
}),
})
} catch (err) {
console.error('Fehler beim Speichern der Codes:', err)
}
}
const handleAuthCodeChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatAuthCode(e.target.value)
setAuthCode(formatted)
setTouched(true)
const valid = validateAuthCode(formatted)
setAuthCodeValid(valid)
if (valid && validateShareCode(lastKnownShareCode)) {
await saveCodes(formatted, lastKnownShareCode)
}
}
useEffect(() => {
if (session?.user?.id) setUserId(session.user.id)
}, [session])
useEffect(() => {
const fetchCodes = async () => {
try {
const res = await fetch('/api/cs2/sharecode')
const data = await res.json()
if (data?.authCode) {
setAuthCode(data.authCode)
setAuthCodeValid(validateAuthCode(data.authCode))
}
if (data?.lastKnownShareCode) {
setLastKnownShareCode(data.lastKnownShareCode)
setLastKnownShareCodeValid(validateShareCode(data.lastKnownShareCode))
}
setIsLoading(false)
} catch (err) {
console.error('Fehler beim Laden der Codes:', err)
}
}
fetchCodes()
}, [])
return (
<div className="py-6 sm:py-8 space-y-5 border-t border-gray-200 dark:border-neutral-700">
<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
</label>
<div className="mt-1">
<Popover text="Was ist der Authentifizierungscode?" 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>
<p>
Deinen Code findest du&nbsp;
<Link
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
target="_blank"
className="text-blue-600 underline hover:text-blue-800"
>
hier
</Link>.
</p>
</div>
</Popover>
</div>
</div>
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
<div className="flex items-center gap-3">
<div className="relative w-full">
<input
type="text"
id="auth-code"
name="auth-code"
value={authCode}
onChange={handleAuthCodeChange}
onBlur={() => setTouched(true)}
className={`border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700
${touched ? (authCodeValid ? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500' : 'border-red-500 focus:border-red-500 focus:ring-red-500') : 'border-gray-200'}
`}
placeholder="XXXX-XXXXX-XXXX"
required
/>
{touched && authCodeValid && (
<div className="absolute inset-y-0 end-0 flex items-center pe-3">
<svg className="shrink-0 size-4 text-teal-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</div>
</div>
{touched && (
<p className={`text-sm mt-2 ${authCodeValid ? 'text-teal-600' : 'text-red-600'}`}>
{authCodeValid ? '✓ Gespeichert!' : 'Ungültiger Authentifizierungscode'}
</p>
)}
</div>
</div>
{!isLoading && (!lastKnownShareCodeValid || !authCodeValid) && (
<LatestKnownCodeSettings
lastKnownShareCode={lastKnownShareCode}
setLastKnownShareCode={(val: string) => {
setLastKnownShareCode(val)
const valid = validateShareCode(val)
setLastKnownShareCodeValid(valid)
if (valid && authCodeValid) {
saveCodes(authCode, val)
}
}}
/>
)}
</div>
)
}

View File

@ -0,0 +1,24 @@
// components/settings/DeleteAccountSettings.tsx
'use client'
import Link from "next/link"
export default function DeleteAccountSettings() {
return (
<div className="py-6 sm:py-8 space-y-5 border-t border-gray-200 first:border-t-0 dark:border-neutral-700">
<div className="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">
Account löschen
</label>
</div>
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
<p className="text-sm text-gray-500 dark:text-neutral-500">
Wenn du deinen Account löschen willst, klicke <Link href="#">hier</Link>.
</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,87 @@
'use client'
import Link from 'next/link'
import Popover from '../../Popover'
interface Props {
lastKnownShareCode: string
setLastKnownShareCode: (value: string) => void
}
export default function LatestKnownCodeSettings({ lastKnownShareCode, setLastKnownShareCode }: Props) {
const formatLastKnownShareCode = (value: string) => {
const raw = value.replace(/[^a-zA-Z0-9]/g, '')
const part0 = raw.slice(0, 4)
const part1 = raw.slice(4, 9)
const part2 = raw.slice(9, 14)
const part3 = raw.slice(14, 19)
const part4 = raw.slice(19, 24)
const part5 = raw.slice(24, 29)
return [part0, part1, part2, part3, part4, part5].filter(Boolean).join('-')
}
const validate = (value: string) =>
/^CSGO(-[a-zA-Z0-9]{5}){5}$/.test(value)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatLastKnownShareCode(e.target.value)
setLastKnownShareCode(formatted)
}
const isValid = validate(lastKnownShareCode)
return (
<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 Ihr letztes Spiel
</label>
<div className="mt-1">
<Popover text="Was ist der Austauschcode?" size="xl">
<div className="space-y-3">
<i><q>Mit dem Austauschcode können Anwendungen dein letztes offizielles Match finden und analysieren.</q></i>
<p>
Du findest deinen Code&nbsp;
<Link
href="https://help.steampowered.com/de/wizard/HelpWithGameIssue/?appid=730&issueid=128"
target="_blank"
className="text-blue-600 underline hover:text-blue-800"
>
hier
</Link>.
</p>
</div>
</Popover>
</div>
</div>
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
<div className="relative">
<input
type="text"
id="known-code"
name="known-code"
value={lastKnownShareCode}
onChange={handleChange}
className={`border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700
${isValid ? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500' : 'border-red-500 focus:border-red-500 focus:ring-red-500'}
`}
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
required
/>
{isValid && (
<div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none">
<svg className="shrink-0 size-4 text-teal-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
{!isValid && (
<p className="text-sm text-red-600 mt-2">Ungültiger Austauschcode</p>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,76 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Modal from '@/app/components/Modal'
import Link from 'next/link'
import Button from '../components/Button'
import ComboBox from '../components/ComboBox'
export default function Dashboard() {
const { data: session, status } = useSession()
const [showTeamModal, setShowTeamModal] = useState(false)
const [teams, setTeams] = useState<string[]>([])
const [selectedTeam, setSelectedTeam] = useState('')
useEffect(() => {
if (status === 'authenticated' && !session?.user?.team) {
setShowTeamModal(true)
}
}, [session, status])
useEffect(() => {
if (showTeamModal) {
const open = setTimeout(() => {
const modalEl = document.getElementById('hs-vertically-centered-modal')
if (modalEl && typeof window.HSOverlay?.open === 'function') {
try {
window.HSOverlay.open(modalEl)
} catch (err) {
console.error('Fehler beim Öffnen des Modals:', err)
}
}
}, 300)
return () => clearTimeout(open)
}
}, [showTeamModal])
useEffect(() => {
const fetchTeams = async () => {
try {
const res = await fetch('/api/team/list')
const data = await res.json()
setTeams(data.teams.map((t: { teamname: string }) => t.teamname))
} catch (error) {
console.error('Fehler beim Laden der Teams:', error)
}
}
fetchTeams()
}, [])
return (
<>
{showTeamModal && (
<Modal
title="Kein Team gefunden"
show={true}
id="no-team-modal"
closeButtonColor="blue"
hideCloseButton
>
<p className="text-sm text-gray-700 dark:text-neutral-300">
Du bist aktuell keinem Team beigetreten. Bitte tritt einem Team bei oder erstelle eines.
</p>
<Link href='/settings/team' className='center'>
<Button title='Team auswählen'></Button>
</Link>
</Modal>
)}
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">
Willkommen im Dashboard!
</h1>
</>
)
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

57
src/app/globals.css Normal file
View File

@ -0,0 +1,57 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@import "preline/variants.css";
@source "../node_modules/preline/dist/*.js";
@import 'flag-icons/css/flag-icons.min.css';
@keyframes shake {
0% { transform: rotate(0deg); }
25% { transform: rotate(10deg); }
50% { transform: rotate(-10deg); }
75% { transform: rotate(10deg); }
100% { transform: rotate(0deg); }
}
.animate-shake {
animation: shake 0.4s ease-in-out;
}
/* Adds pointer cursor to buttons */
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
/* Defaults hover styles on all devices */
@custom-variant hover (&:hover);
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -0,0 +1,65 @@
'use client'
import { useEffect, useState } from 'react'
import { Team } from '../types/team'
const relevantEvents = [
'ws-team-renamed',
'ws-team-member-joined',
'ws-team-member-left',
'ws-team-kick',
'ws-team-kick-other',
'ws-team-leader-changed',
'ws-team-logo-updated'
]
export function useLiveTeam(initialTeam: Team) {
const [data, setData] = useState<Team>(initialTeam)
useEffect(() => {
const update = async () => {
try {
const res = await fetch(`/api/team/get?id=${initialTeam.id}`)
if (!res.ok) return
const json = await res.json()
const updatedTeam = json?.team
if (!updatedTeam) return
const players = [
...(updatedTeam.activePlayers ?? []),
...(updatedTeam.inactivePlayers ?? []),
]
setData({
id: updatedTeam.id,
teamname: updatedTeam.teamname,
logo: updatedTeam.logo,
leader: updatedTeam.leader,
players,
})
} catch (err) {
console.error('Fehler beim Nachladen des Teams:', err)
}
}
const handler = (e: Event) => {
const customEvent = e as CustomEvent
if (customEvent.detail?.teamId === initialTeam.id) {
update()
}
}
for (const evt of relevantEvents) {
window.addEventListener(evt, handler)
}
return () => {
for (const evt of relevantEvents) {
window.removeEventListener(evt, handler)
}
}
}, [initialTeam.id])
return data
}

View File

@ -0,0 +1,15 @@
// hooks/useSteamProfile.ts
import { useSession } from 'next-auth/react'
import { SteamProfile } from '../types/steam'
export const useSteamProfile = () => {
const { data: session, status } = useSession()
const steamProfile = session?.user as SteamProfile | undefined
return {
session,
steamProfile,
status, // kann 'loading' | 'authenticated' | 'unauthenticated' sein
}
}

View File

@ -0,0 +1,268 @@
import { useEffect, useState, useImperativeHandle } from 'react'
import { Player, Team } from '../types/team'
import { useSession } from 'next-auth/react'
import { useWebSocketListener } from '@/app/hooks/useWebSocketListener'
export type Invitation = {
id: string
teamId: string
teamName: string
type?: 'team-invite' | 'team-join-request' // 👈 hinzufügen
}
export function useTeamManager(
props: { refetchKey?: string },
ref: React.Ref<any>
) {
const [team, setTeam] = useState<Team | null>(null)
const [activePlayers, setactivePlayers] = useState<Player[]>([])
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [pendingInvitation, setPendingInvitation] = useState<Invitation | null>(null)
const { data: session } = useSession()
const fetchTeam = async () => {
setIsLoading(true)
try {
const res = await fetch('/api/team')
if (res.status === 404) {
setTeam(null)
setactivePlayers([])
setInactivePlayers([])
return
}
if (!res.ok) throw new Error('Fehler beim Abrufen des Teams')
const data = await res.json()
if (!data.team) {
setTeam(null)
setactivePlayers([])
setInactivePlayers([])
return
}
const newActive = data.team.activePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
const newInactive = data.team.inactivePlayers.sort((a: Player, b: Player) => a.name.localeCompare(b.name))
setTeam({
id: data.team.id,
teamname: data.team.teamname,
leader: data.team.leader,
logo: data.team.logo,
players: [...newActive, ...newInactive],
})
setactivePlayers(newActive)
setInactivePlayers(newInactive)
} catch (error) {
console.error('Fehler beim Laden des Teams:', error)
setTeam(null)
} finally {
setIsLoading(false)
}
}
const fetchInvitations = async () => {
try {
const res = await fetch('/api/user/invitations')
if (res.ok) {
const data = await res.json()
const invitations = (data.invitations || []) as Invitation[]
// Nur "team-invite" berücksichtigen
const invite = invitations.find(i => i.type === 'team-invite')
setPendingInvitation(invite || null)
}
} catch (error) {
console.error('Fehler beim Laden der Einladungen:', error)
}
}
useEffect(() => {
const load = async () => {
await Promise.all([fetchTeam(), fetchInvitations()])
}
load()
}, [props.refetchKey])
useWebSocketListener('ws-invitation', fetchInvitations)
useWebSocketListener('ws-team-invite', fetchInvitations)
useWebSocketListener('ws-team-invite-reject', fetchInvitations)
useWebSocketListener('ws-team-update', fetchTeam)
useWebSocketListener('ws-team-kick', () => {
fetchTeam()
fetchInvitations()
})
useWebSocketListener('ws-team-kick-other', fetchTeam)
useWebSocketListener('ws-team-joined', fetchTeam)
useWebSocketListener('ws-team-member-joined', fetchTeam)
useWebSocketListener('ws-team-left', () => {
fetchTeam()
fetchInvitations()
})
useWebSocketListener('ws-team-member-left', fetchTeam)
useWebSocketListener('ws-team-leader-changed', fetchTeam)
useWebSocketListener('ws-team-join-request', fetchInvitations)
useWebSocketListener('ws-team-renamed', fetchTeam)
const reloadTeam = async () => {
await fetchTeam()
await fetchInvitations()
}
useImperativeHandle(ref, () => ({ reloadTeam }))
const acceptInvitation = async (invitationId?: string) => {
const id = invitationId || pendingInvitation?.id
if (!id) return
try {
await fetch('/api/user/invitations/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId: id }),
})
if (pendingInvitation?.id === id) setPendingInvitation(null)
await fetchTeam()
} catch (error) {
console.error('Fehler beim Annehmen der Einladung:', error)
}
}
const rejectInvitation = async (invitationId?: string) => {
const id = invitationId || pendingInvitation?.id
if (!id) return
try {
await fetch('/api/user/invitations/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId: id }),
})
if (pendingInvitation?.id === id) setPendingInvitation(null)
} catch (error) {
console.error('Fehler beim Ablehnen der Einladung:', error)
}
}
const markAllAsRead = async () => {
try {
await fetch('/api/notifications/mark-all-read', { method: 'POST' })
} catch (err) {
console.error('Fehler beim Markieren aller Benachrichtigungen:', err)
}
}
const markOneAsRead = async (id: string) => {
try {
await fetch(`/api/notifications/mark-read/${id}`, { method: 'POST' })
} catch (err) {
console.error(`Fehler beim Markieren von Benachrichtigung ${id}:`, err)
}
}
const handleInviteAction = async (action: 'accept' | 'reject', invitationId: string) => {
try {
const res = await fetch(`/api/user/invitations/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitationId }),
})
// 💡 Einladung war schon gelöscht
if (res.status === 404 && action === 'accept') {
console.warn('Einladung wurde bereits entfernt.')
setPendingInvitation(null)
await fetchTeam()
return
}
if (!res.ok) throw new Error('Aktion fehlgeschlagen')
if (action === 'accept') await fetchTeam()
} catch (err) {
console.error(`[${action}] Fehler beim Ausführen:`, err)
}
}
const leaveTeam = async (steamId: string, newLeaderId?: string): Promise<boolean> => {
try {
const payload = newLeaderId ? { steamId, newLeaderId } : { steamId }
const res = await fetch('/api/team/leave', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const error = await res.json()
console.error('Fehler beim Verlassen:', error.message)
return false
}
await fetchTeam()
return true
} catch (err) {
console.error('Fehler beim Verlassen des Teams:', err)
return false
}
}
const renameTeam = async (teamId: string, newName: string) => {
try {
await fetch('/api/team/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId, newName }),
})
} catch (err) {
console.error('Fehler beim Umbenennen:', err)
}
}
const deleteTeam = async (teamId: string) => {
try {
await fetch('/api/team/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId }),
})
} catch (err) {
console.error('Fehler beim Löschen:', err)
}
}
return {
team,
activePlayers,
inactivePlayers,
activeDragItem,
isDragging,
isLoading,
showLeaveModal,
showInviteModal,
pendingInvitation,
setShowLeaveModal,
setShowInviteModal,
setActiveDragItem,
setIsDragging,
setactivePlayers,
setInactivePlayers,
acceptInvitation,
rejectInvitation,
markAllAsRead,
markOneAsRead,
handleInviteAction,
leaveTeam,
reloadTeam,
renameTeam,
deleteTeam,
}
}

View File

@ -0,0 +1,21 @@
'use client'
import { useEffect } from 'react'
export function useWebSocketListener<T = any>(
eventName: string,
handler: (data: T) => void
) {
useEffect(() => {
const listener = (event: Event) => {
if (!(event instanceof CustomEvent)) return
handler(event.detail)
}
window.addEventListener(eventName, listener as EventListener)
return () => {
window.removeEventListener(eventName, listener as EventListener)
}
}, [eventName, handler])
}

56
src/app/layout.tsx Normal file
View File

@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import PrelineScriptWrapper from './components/PrelineScriptWrapper';
import { Providers } from './components/Providers';
import Sidebar from './components/Sidebar';
import ThemeProvider from "@/theme/theme-provider";
import Script from "next/script";
import NotificationCenter from './components/NotificationCenter'
import Navbar from "./components/Navbar";
import WebSocketManager from "./components/WebSocketManager";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata = {
title: 'Meine App',
description: 'Steam Auth Dashboard',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white dark:bg-black`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Providers>
<WebSocketManager />
{/* Sidebar und Content direkt nebeneinander */}
<Sidebar>
{children}
</Sidebar>
<NotificationCenter />
</Providers>
</ThemeProvider>
<PrelineScriptWrapper />
</body>
</html>
);
}

78
src/app/lib/auth.ts Normal file
View File

@ -0,0 +1,78 @@
import type { NextAuthOptions } from 'next-auth'
import { NextRequest } from 'next/server'
import Steam from 'next-auth-steam'
import { prisma } from '@/app/lib/prisma'
import type { SteamProfile } from '@/app/types/steam'
export const authOptions = (req: NextRequest): NextAuthOptions => ({
secret: process.env.NEXTAUTH_SECRET,
providers: [
Steam(req, {
clientSecret: process.env.STEAM_API_KEY!,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account && profile) {
const steamProfile = profile as SteamProfile
const location = steamProfile.loccountrycode ?? null
await prisma.user.upsert({
where: { steamId: steamProfile.steamid },
update: {
name: steamProfile.personaname,
avatar: steamProfile.avatarfull,
...(location && { location }),
},
create: {
steamId: steamProfile.steamid,
name: steamProfile.personaname,
avatar: steamProfile.avatarfull,
location: steamProfile.loccountrycode,
isAdmin: false,
...(location && { location }),
},
})
token.steamId = steamProfile.steamid
token.name = steamProfile.personaname
token.image = steamProfile.avatarfull
}
const userInDb = await prisma.user.findUnique({
where: { steamId: token.steamId || token.sub || '' },
})
if (userInDb) {
token.team = userInDb.teamId ?? null
token.isAdmin = userInDb.isAdmin ?? false
}
return token
},
async session({ session, token }) {
if (!token.steamId) throw new Error('steamId is missing in token')
session.user = {
...session.user,
steamId: token.steamId,
name: token.name,
image: token.image,
team: token.team ?? null,
isAdmin: token.isAdmin ?? false,
}
return session
},
async redirect({ url, baseUrl }) {
if (url.includes('/api/auth/signout')) {
return `${baseUrl}/` // Zurück zur Startseite
}
return `${baseUrl}/dashboard`
},
},
})
// Base config für `getServerSession()` ohne req
export const baseAuthOptions: NextAuthOptions = authOptions({} as NextRequest)

19
src/app/lib/crypto.ts Normal file
View File

@ -0,0 +1,19 @@
import crypto from 'crypto'
const algorithm = 'aes-256-cbc'
const secretKey = process.env.SHARE_CODE_SECRET_KEY as string
const iv = Buffer.from(process.env.SHARE_CODE_IV as string, 'hex') // 16 bytes
export function encrypt(text: string): string {
const cipher = crypto.createCipheriv(algorithm, Buffer.from(secretKey, 'hex'), iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return encrypted
}
export function decrypt(encrypted: string): string {
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(secretKey, 'hex'), iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}

14
src/app/lib/prisma.ts Normal file
View File

@ -0,0 +1,14 @@
// src/lib/prisma.ts
import { PrismaClient } from '@/generated/prisma'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
//log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

View File

@ -0,0 +1,26 @@
type TeamData = {
activePlayers: string[]
inactivePlayers: string[]
leader: string | null
}
/**
* Entfernt einen Spieler aus dem Team.
*/
export function removePlayerFromTeam(team: TeamData, steamId: string) {
const updatedActive = team.activePlayers.filter(id => id !== steamId)
const updatedInactive = team.inactivePlayers.filter(id => id !== steamId)
let newLeader: string | null = team.leader
if (team.leader === steamId) {
newLeader = updatedActive[0] ?? updatedInactive[0] ?? null
}
return {
activePlayers: updatedActive,
inactivePlayers: updatedInactive,
leader: newLeader,
}
}

View File

@ -0,0 +1,54 @@
export class WebSocketClient {
private ws: WebSocket | null = null
private baseUrl: string
private steamId: string
private listeners: ((data: any) => void)[] = []
constructor(baseUrl: string, steamId: string) {
this.baseUrl = baseUrl
this.steamId = steamId
}
connect() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return
const fullUrl = `${this.baseUrl}?steamId=${encodeURIComponent(this.steamId)}`
this.ws = new WebSocket(fullUrl)
this.ws.onopen = () => {
console.log('[WebSocket] Verbunden mit Server.')
}
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data)
//console.log('[WebSocket] Nachricht erhalten:', data)
this.listeners.forEach((listener) => listener(data))
}
this.ws.onclose = () => {
console.warn('[WebSocket] Verbindung verloren. Reconnect in 3 Sekunden...')
setTimeout(() => this.connect(), 3000)
}
this.ws.onerror = (error) => {
console.error('[WebSocket] Fehler:', error)
this.ws?.close()
}
}
onMessage(callback: (data: any) => void) {
this.listeners.push(callback)
}
send(message: any) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else {
console.warn('[WebSocket] Nachricht konnte nicht gesendet werden.')
}
}
close() {
this.ws?.close()
}
}

Some files were not shown because too many files have changed in this diff Show More