push
This commit is contained in:
commit
b79c4faa03
41
src/app/admin/[tab]/page.tsx
Normal file
41
src/app/admin/[tab]/page.tsx
Normal 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
17
src/app/admin/layout.tsx
Normal 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
6
src/app/admin/page.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// src/app/admin/page.tsx
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function AdminRedirectPage() {
|
||||||
|
redirect('/admin/matches')
|
||||||
|
}
|
||||||
10
src/app/api/admin/teams/route.ts
Normal file
10
src/app/api/admin/teams/route.ts
Normal 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)
|
||||||
|
}
|
||||||
9
src/app/api/auth/[...nextauth]/route.ts
Normal file
9
src/app/api/auth/[...nextauth]/route.ts
Normal 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 }
|
||||||
82
src/app/api/cs2/getNextCode/route.ts
Normal file
82
src/app/api/cs2/getNextCode/route.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
src/app/api/cs2/sharecode/route.ts
Normal file
66
src/app/api/cs2/sharecode/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/app/api/matches/[id]/route.ts
Normal file
168
src/app/api/matches/[id]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/api/matches/create/route.ts
Normal file
73
src/app/api/matches/create/route.ts
Normal 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 & Match‑Players 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/api/matches/route.ts
Normal file
25
src/app/api/matches/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/api/notifications/create/route.ts
Normal file
55
src/app/api/notifications/create/route.ts
Normal 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' })
|
||||||
|
}
|
||||||
20
src/app/api/notifications/mark-all-read/route.ts
Normal file
20
src/app/api/notifications/mark-all-read/route.ts
Normal 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' })
|
||||||
|
}
|
||||||
39
src/app/api/notifications/mark-read/[id]/route.ts
Normal file
39
src/app/api/notifications/mark-read/[id]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
src/app/api/notifications/route.ts
Normal file
20
src/app/api/notifications/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
31
src/app/api/notifications/user/route.ts
Normal file
31
src/app/api/notifications/user/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
37
src/app/api/steam/profile/route.ts
Normal file
37
src/app/api/steam/profile/route.ts
Normal 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])
|
||||||
|
}
|
||||||
34
src/app/api/team/[teamId]/route.ts
Normal file
34
src/app/api/team/[teamId]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/api/team/available-users/route.ts
Normal file
26
src/app/api/team/available-users/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/api/team/change-logo/route.ts
Normal file
25
src/app/api/team/change-logo/route.ts
Normal 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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/api/team/create/route.ts
Normal file
60
src/app/api/team/create/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/api/team/delete/route.ts
Normal file
21
src/app/api/team/delete/route.ts
Normal 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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/api/team/get/route.ts
Normal file
56
src/app/api/team/get/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/team/invite/route.ts
Normal file
67
src/app/api/team/invite/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/app/api/team/kick/route.ts
Normal file
96
src/app/api/team/kick/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/app/api/team/leave/route.ts
Normal file
113
src/app/api/team/leave/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/api/team/list/route.ts
Normal file
53
src/app/api/team/list/route.ts
Normal 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 (User‑Objekte)
|
||||||
|
* 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 User‑Datensä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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/team/rename/route.ts
Normal file
32
src/app/api/team/rename/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/app/api/team/request-join/route.ts
Normal file
82
src/app/api/team/request-join/route.ts
Normal 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
76
src/app/api/team/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/api/team/transfer-leader/route.ts
Normal file
55
src/app/api/team/transfer-leader/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/team/update-players/route.ts
Normal file
32
src/app/api/team/update-players/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/api/team/upload-logo/route.ts
Normal file
58
src/app/api/team/upload-logo/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
135
src/app/api/user/invitations/[action]/route.ts
Normal file
135
src/app/api/user/invitations/[action]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/user/invitations/route.ts
Normal file
37
src/app/api/user/invitations/route.ts
Normal 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
30
src/app/api/user/route.ts
Normal 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)
|
||||||
|
}
|
||||||
153
src/app/components/Button.tsx
Normal file
153
src/app/components/Button.tsx
Normal 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
|
||||||
59
src/app/components/Card.tsx
Normal file
59
src/app/components/Card.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
type CardWidth =
|
||||||
|
| 'sm' // 24rem (max‑w‑sm)
|
||||||
|
| 'md' // 28rem (max‑w‑md)
|
||||||
|
| 'lg' // 32rem (max‑w‑lg)
|
||||||
|
| 'xl' // 36rem (max‑w‑xl)
|
||||||
|
| '2xl' // 42rem (max‑w‑2xl)
|
||||||
|
| 'full' // 100 % (w‑full)
|
||||||
|
| 'auto' // keine Begrenzung
|
||||||
|
|
||||||
|
type CardProps = {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
/** links, rechts oder (Default) zentriert */
|
||||||
|
align?: 'left' | 'right' | 'center'
|
||||||
|
/** gewünschte Max‑Breite (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 Tailwind‑Klasse ü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>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/app/components/ComboBox.tsx
Normal file
80
src/app/components/ComboBox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
src/app/components/CreateTeamButton.tsx
Normal file
154
src/app/components/CreateTeamButton.tsx
Normal 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
|
||||||
209
src/app/components/DatePickerWithTime.tsx
Normal file
209
src/app/components/DatePickerWithTime.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/components/Dropdown.tsx
Normal file
78
src/app/components/Dropdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/app/components/DroppableZone.tsx
Normal file
29
src/app/components/DroppableZone.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/app/components/EditButton.tsx
Normal file
26
src/app/components/EditButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
src/app/components/EditMatchPlayersModal.tsx
Normal file
179
src/app/components/EditMatchPlayersModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/app/components/Input.tsx
Normal file
39
src/app/components/Input.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
140
src/app/components/InvitePlayersModal.tsx
Normal file
140
src/app/components/InvitePlayersModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
src/app/components/LeaveTeamModal.tsx
Normal file
88
src/app/components/LeaveTeamModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/app/components/LoadingSpinner.tsx
Normal file
17
src/app/components/LoadingSpinner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
175
src/app/components/MatchDetails.tsx
Normal file
175
src/app/components/MatchDetails.tsx
Normal 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>
|
||||||
|
|
||||||
|
{/* Spieler‑Listen */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
src/app/components/MatchList.tsx
Normal file
121
src/app/components/MatchList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/app/components/MatchPlayerCard.tsx
Normal file
43
src/app/components/MatchPlayerCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/app/components/MatchTeamCard.tsx
Normal file
95
src/app/components/MatchTeamCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
209
src/app/components/MatchesAdminManager.tsx
Normal file
209
src/app/components/MatchesAdminManager.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/app/components/MiniCard.tsx
Normal file
125
src/app/components/MiniCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/app/components/MiniCardDummy.tsx
Normal file
39
src/app/components/MiniCardDummy.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
src/app/components/Modal.tsx
Normal file
153
src/app/components/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/app/components/Navbar.tsx
Normal file
91
src/app/components/Navbar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
src/app/components/NoTeamView.tsx
Normal file
70
src/app/components/NoTeamView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
src/app/components/NotificationCenter.tsx
Normal file
182
src/app/components/NotificationCenter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
src/app/components/NotificationDropdown.tsx
Normal file
173
src/app/components/NotificationDropdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/app/components/PlayerCard.tsx
Normal file
81
src/app/components/PlayerCard.tsx
Normal 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. Team‑Farbe brauchst
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
maxWidth?: CardWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerCard({
|
||||||
|
player,
|
||||||
|
team,
|
||||||
|
align = 'left',
|
||||||
|
maxWidth = 'sm',
|
||||||
|
}: Props) {
|
||||||
|
/* --- Hilfs‑Klassen ----------------------------------------------------- */
|
||||||
|
|
||||||
|
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 Flex‑Container innen:
|
||||||
|
// * links: Avatar – Name (row)
|
||||||
|
// * rechts: Name – Avatar (row‑reverse)
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/app/components/Popover.tsx
Normal file
58
src/app/components/Popover.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/app/components/PrelineScript.tsx
Normal file
34
src/app/components/PrelineScript.tsx
Normal 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;
|
||||||
|
}
|
||||||
11
src/app/components/PrelineScriptWrapper.tsx
Normal file
11
src/app/components/PrelineScriptWrapper.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
383
src/app/components/Profile.tsx
Normal file
383
src/app/components/Profile.tsx
Normal 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="What’s 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/app/components/Providers.tsx
Normal file
13
src/app/components/Providers.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
93
src/app/components/Select.tsx
Normal file
93
src/app/components/Select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
src/app/components/Sidebar.tsx
Normal file
209
src/app/components/Sidebar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
src/app/components/SidebarFooter.tsx
Normal file
172
src/app/components/SidebarFooter.tsx
Normal 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] Team‑Name 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. Steam‑ID (wenn bereits vom Hook gemappt)
|
||||||
|
?? user.id // 3. Fallback auf JWT‑id
|
||||||
|
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/app/components/SortableMiniCard.tsx
Normal file
71
src/app/components/SortableMiniCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
50
src/app/components/Switch.tsx
Normal file
50
src/app/components/Switch.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
src/app/components/Tab.tsx
Normal file
16
src/app/components/Tab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
src/app/components/Table.tsx
Normal file
70
src/app/components/Table.tsx
Normal 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
|
||||||
46
src/app/components/Tabs.tsx
Normal file
46
src/app/components/Tabs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/app/components/TeamCard.tsx
Normal file
103
src/app/components/TeamCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/app/components/TeamCardComponent.tsx
Normal file
92
src/app/components/TeamCardComponent.tsx
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint‑disable react/display‑name */
|
||||||
|
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)
|
||||||
66
src/app/components/TeamComboBox.tsx
Normal file
66
src/app/components/TeamComboBox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/app/components/TeamInvitationView.tsx
Normal file
60
src/app/components/TeamInvitationView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
506
src/app/components/TeamMemberView.tsx
Normal file
506
src/app/components/TeamMemberView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/app/components/TeamSelector.tsx
Normal file
31
src/app/components/TeamSelector.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/app/components/Tooltip.tsx
Normal file
27
src/app/components/Tooltip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/app/components/WebSocketManager.tsx
Normal file
83
src/app/components/WebSocketManager.tsx
Normal 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
|
||||||
|
}
|
||||||
54
src/app/components/settings/AccountSettings.tsx
Normal file
54
src/app/components/settings/AccountSettings.tsx
Normal 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 */}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/app/components/settings/account/AppearanceSettings.tsx
Normal file
84
src/app/components/settings/account/AppearanceSettings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
src/app/components/settings/account/AuthCodeSettings.tsx
Normal file
172
src/app/components/settings/account/AuthCodeSettings.tsx
Normal 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
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/app/dashboard/page.tsx
Normal file
76
src/app/dashboard/page.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
57
src/app/globals.css
Normal file
57
src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
65
src/app/hooks/useLiveTeam.tsx
Normal file
65
src/app/hooks/useLiveTeam.tsx
Normal 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
|
||||||
|
}
|
||||||
15
src/app/hooks/useSteamProfile.tsx
Normal file
15
src/app/hooks/useSteamProfile.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
268
src/app/hooks/useTeamManager.tsx
Normal file
268
src/app/hooks/useTeamManager.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/hooks/useWebSocketListener.ts
Normal file
21
src/app/hooks/useWebSocketListener.ts
Normal 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
56
src/app/layout.tsx
Normal 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
78
src/app/lib/auth.ts
Normal 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
19
src/app/lib/crypto.ts
Normal 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
14
src/app/lib/prisma.ts
Normal 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
|
||||||
26
src/app/lib/removePlayerFromTeam.ts
Normal file
26
src/app/lib/removePlayerFromTeam.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
54
src/app/lib/websocket-client.ts
Normal file
54
src/app/lib/websocket-client.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user