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