diff --git a/public/assets/img/logos/4317203f-1072-4249-b662-81e4e646455a.webp b/public/assets/img/logos/4317203f-1072-4249-b662-81e4e646455a.webp new file mode 100644 index 0000000..b313882 Binary files /dev/null and b/public/assets/img/logos/4317203f-1072-4249-b662-81e4e646455a.webp differ diff --git a/public/assets/img/logos/4a8ea2db-3598-43af-a0ed-60c1dbf8e169-ff8a8234-61e4-484c-966c-b41de4419855.png b/public/assets/img/logos/4a8ea2db-3598-43af-a0ed-60c1dbf8e169-ff8a8234-61e4-484c-966c-b41de4419855.png deleted file mode 100644 index 4a23667..0000000 Binary files a/public/assets/img/logos/4a8ea2db-3598-43af-a0ed-60c1dbf8e169-ff8a8234-61e4-484c-966c-b41de4419855.png and /dev/null differ diff --git a/public/assets/img/logos/b19f3e39-08b8-43ab-a07c-8bbc09b9d54a-7fc62646-952b-48a8-8233-66909931eac2.png b/public/assets/img/logos/b19f3e39-08b8-43ab-a07c-8bbc09b9d54a-7fc62646-952b-48a8-8233-66909931eac2.png deleted file mode 100644 index 21b1861..0000000 Binary files a/public/assets/img/logos/b19f3e39-08b8-43ab-a07c-8bbc09b9d54a-7fc62646-952b-48a8-8233-66909931eac2.png and /dev/null differ diff --git a/public/assets/img/logos/cma8gqda80002ph9477ljnpib-f1e05dba-34d1-4b5e-8be9-da270a1acda5.png b/public/assets/img/logos/cma8gqda80002ph9477ljnpib-f1e05dba-34d1-4b5e-8be9-da270a1acda5.png deleted file mode 100644 index 4a23667..0000000 Binary files a/public/assets/img/logos/cma8gqda80002ph9477ljnpib-f1e05dba-34d1-4b5e-8be9-da270a1acda5.png and /dev/null differ diff --git a/public/assets/img/logos/cma9koull0002phjk2nwf6udx-65556764-0352-4d4e-a4eb-55e00fba20e9.png b/public/assets/img/logos/cma9koull0002phjk2nwf6udx-65556764-0352-4d4e-a4eb-55e00fba20e9.png deleted file mode 100644 index 21b1861..0000000 Binary files a/public/assets/img/logos/cma9koull0002phjk2nwf6udx-65556764-0352-4d4e-a4eb-55e00fba20e9.png and /dev/null differ diff --git a/public/assets/img/logos/cma9rzb9v0000pht4b9vpahpv-5fc30481-f424-458d-8591-6cc605808676.png b/public/assets/img/logos/cma9rzb9v0000pht4b9vpahpv-5fc30481-f424-458d-8591-6cc605808676.png deleted file mode 100644 index 4a23667..0000000 Binary files a/public/assets/img/logos/cma9rzb9v0000pht4b9vpahpv-5fc30481-f424-458d-8591-6cc605808676.png and /dev/null differ diff --git a/public/assets/img/logos/cmab28h050001ph4s8w2a0wsc-85dff3f0-ddbd-4197-baf8-17246cac8ec0.png b/public/assets/img/logos/cmab28h050001ph4s8w2a0wsc-85dff3f0-ddbd-4197-baf8-17246cac8ec0.png deleted file mode 100644 index 21b1861..0000000 Binary files a/public/assets/img/logos/cmab28h050001ph4s8w2a0wsc-85dff3f0-ddbd-4197-baf8-17246cac8ec0.png and /dev/null differ diff --git a/public/assets/img/logos/cmab2acln0003ph4stxe3ad38-471b5c6e-b256-4c6a-a71f-ed69f11926a3.png b/public/assets/img/logos/cmab2acln0003ph4stxe3ad38-471b5c6e-b256-4c6a-a71f-ed69f11926a3.png deleted file mode 100644 index 4a23667..0000000 Binary files a/public/assets/img/logos/cmab2acln0003ph4stxe3ad38-471b5c6e-b256-4c6a-a71f-ed69f11926a3.png and /dev/null differ diff --git a/public/assets/img/logos/ef1ff872-be44-4c69-b81d-a5fef1173896-a08bb1d8-b3b3-45b6-a866-70d5e451539c.png b/public/assets/img/logos/ef1ff872-be44-4c69-b81d-a5fef1173896-a08bb1d8-b3b3-45b6-a866-70d5e451539c.png deleted file mode 100644 index 4a23667..0000000 Binary files a/public/assets/img/logos/ef1ff872-be44-4c69-b81d-a5fef1173896-a08bb1d8-b3b3-45b6-a866-70d5e451539c.png and /dev/null differ diff --git a/public/assets/img/logos/fc1d7902-5c08-4e3a-8c42-36b6f9efc7f2-e0e567f6-678d-4999-9580-0c88c6da6b3e.jpg b/public/assets/img/logos/fc1d7902-5c08-4e3a-8c42-36b6f9efc7f2-e0e567f6-678d-4999-9580-0c88c6da6b3e.jpg deleted file mode 100644 index b7c9974..0000000 Binary files a/public/assets/img/logos/fc1d7902-5c08-4e3a-8c42-36b6f9efc7f2-e0e567f6-678d-4999-9580-0c88c6da6b3e.jpg and /dev/null differ diff --git a/src/app/api/matches/route.ts b/src/app/api/matches/route.ts index 1cdffa3..98c0210 100644 --- a/src/app/api/matches/route.ts +++ b/src/app/api/matches/route.ts @@ -1,57 +1,113 @@ -import { NextResponse } from 'next/server' +// /app/api/user/[steamId]/matches/route.ts +import { NextResponse, type NextRequest } from 'next/server' import { prisma } from '@/app/lib/prisma' -export async function GET(req: Request) { - try { - /* optionalen Query-Parameter lesen */ - const { searchParams } = new URL(req.url) - const matchType = searchParams.get('type') // z. B. "community" +export async function GET( + req: NextRequest, + { params }: { params: { steamId: string } }, +) { + const steamId = params.steamId + if (!steamId) { + return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 }) + } - /* falls übergeben ⇒ danach filtern */ + const { searchParams } = new URL(req.url) + + // Query-Parameter + const typesParam = searchParams.get('types') // z. B. "premier,competitive" + const types = typesParam + ? typesParam.split(',').map(t => t.trim()).filter(Boolean) + : [] + + const limit = Math.min( + Math.max(parseInt(searchParams.get('limit') || '10', 10), 1), + 50, + ) // 1..50 (Default 10) + + const cursor = searchParams.get('cursor') // letzte match.id der vorherigen Page + + try { + // Matches, in denen der Spieler vorkommt const matches = await prisma.match.findMany({ - where : matchType ? { matchType } : undefined, - orderBy: { demoDate: 'desc' }, - include: { - teamA : true, - teamB : true, - players: { include: { user: true, stats: true, team: true } }, + where: { + players: { some: { steamId } }, + ...(types.length ? { matchType: { in: types } } : {}), + }, + orderBy: [{ demoDate: 'desc' }, { id: 'desc' }], + take: limit + 1, // eine extra zum Prüfen, ob es weiter geht + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + + select: { + id: true, + demoDate: true, + map: true, + roundCount: true, + scoreA: true, + scoreB: true, + matchType: true, + teamAId: true, + teamBId: true, + teamAUsers: { select: { steamId: true } }, + teamBUsers: { select: { steamId: true } }, + winnerTeam: true, + // nur die MatchPlayer-Zeile des aktuellen Users (für Stats) + players: { + where: { steamId }, + select: { stats: true }, + }, }, }) - /* … rest bleibt unverändert … */ - const formatted = matches.map(m => ({ - id : m.id, - map : m.map, - demoDate: m.demoDate, - matchType: m.matchType, - scoreA : m.scoreA, - scoreB : m.scoreB, - winnerTeam: m.winnerTeam ?? null, - teamA: { - id : m.teamA?.id ?? null, - name: m.teamA?.name ?? 'CT', - logo: m.teamA?.logo ?? null, - score: m.scoreA, - }, - teamB: { - id : m.teamB?.id ?? null, - name: m.teamB?.name ?? 'T', - logo: m.teamB?.logo ?? null, - score: m.scoreB, - }, - players: m.players.map(p => ({ - steamId : p.steamId, - name : p.user?.name, - avatar : p.user?.avatar, - stats : p.stats, - teamId : p.teamId, - teamName: p.team?.name ?? null, - })), - })) + const hasMore = matches.length > limit + const page = hasMore ? matches.slice(0, limit) : matches - return NextResponse.json(formatted) + const items = page.map(m => { + const stats = m.players[0]?.stats ?? null + const kills = stats?.kills ?? 0 + const deaths = stats?.deaths ?? 0 + const kdr = deaths ? (kills / deaths).toFixed(2) : '∞' + const rankOld = stats?.rankOld ?? null + const rankNew = stats?.rankNew ?? null + const aim = stats?.aim ?? null + const rankChange = + rankNew != null && rankOld != null ? rankNew - rankOld : null + + // Teamzugehörigkeit aus Sicht des Spielers (CT/T) + const playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T' + const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}` + + return { + id: m.id, + map: m.map ?? 'Unknown', + date: m.demoDate?.toISOString() ?? '', + matchType: m.matchType ?? 'community', + + score, + roundCount: m.roundCount, + + rankOld, + rankNew, + rankChange, + + kills, + deaths, + kdr, + aim, + + winnerTeam: m.winnerTeam ?? null, + team: playerTeam, // 'CT' | 'T' + } + }) + + const nextCursor = hasMore ? page[page.length - 1].id : null + + return NextResponse.json({ + items, + nextCursor, + hasMore, + }) } catch (err) { - console.error('GET /matches failed:', err) - return NextResponse.json({ error: 'Failed to load matches' }, { status: 500 }) + console.error('[API] /user/[steamId]/matches Fehler:', err) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) } } diff --git a/src/app/api/team/upload-logo/route.ts b/src/app/api/team/upload-logo/route.ts index f2be047..677677a 100644 --- a/src/app/api/team/upload-logo/route.ts +++ b/src/app/api/team/upload-logo/route.ts @@ -2,57 +2,64 @@ import { NextResponse, type NextRequest } from 'next/server' import { writeFile, mkdir, unlink } from 'fs/promises' import { join, dirname } from 'path' -import { randomUUID } from 'crypto' +import sharp from 'sharp' import { sendServerSSEMessage } from '@/app/lib/sse-server-client' +export const runtime = 'nodejs' // wichtig, falls du irgendwo Edge aktiviert hast + 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 + const file = formData.get('logo') as File | null + const teamId = formData.get('teamId') as string | null 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) + // Bytes lesen + const inputBuffer = Buffer.from(await file.arrayBuffer()) + + // WebP erzeugen (resize optional – nimm Werte, die für dich passen) + const webpBuffer = await sharp(inputBuffer) + .resize(512, 512, { fit: 'cover' }) // optional + .webp({ quality: 85, effort: 4 }) // effort=Kompressionsaufwand + .toBuffer() + + const logosDir = join(process.cwd(), 'public/assets/img/logos') + const filename = `${teamId}.webp` + const filepath = join(logosDir, filename) await mkdir(dirname(filepath), { recursive: true }) - // Prisma laden und altes Logo abfragen + // Altes Logo laut DB löschen (falls abweichender Name/Endung) const { prisma } = await import('@/app/lib/prisma') - const existingTeam = await prisma.team.findUnique({ + const existing = 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) - } + if (existing?.logo && existing.logo !== filename) { + try { await unlink(join(logosDir, existing.logo)) } catch {} } - // Neues Logo speichern - await writeFile(filepath, buffer) + // Neues Logo schreiben (overwrite ok) + await writeFile(filepath, webpBuffer) - // Team in DB aktualisieren + // DB aktualisieren await prisma.team.update({ where: { id: teamId }, - data: { logo: filename }, + data : { logo: filename }, }) - await sendServerSSEMessage({ - type: 'team-logo-updated', - title: 'Team-Logo hochgeladen!', - message: `Das Teamlogo wurde aktualisiert.`, - teamId - }); + const version = Date.now() - return NextResponse.json({ success: true, filename }) + // Optional: Clients direkt mit neuem Dateinamen + Version versorgen + await sendServerSSEMessage({ + type : 'team-logo-updated', + teamId, + // falls dein SSE-Client `payload` liest: + payload : { filename: filename, version } + }) + await sendServerSSEMessage({ type: 'team-updated', teamId }) + + return NextResponse.json({ success: true, filename, version }) } diff --git a/src/app/api/user/[steamId]/matches/route.ts b/src/app/api/user/[steamId]/matches/route.ts index 43611f1..2d88e97 100644 --- a/src/app/api/user/[steamId]/matches/route.ts +++ b/src/app/api/user/[steamId]/matches/route.ts @@ -1,65 +1,56 @@ // /app/api/user/[steamId]/matches/route.ts import { NextResponse, type NextRequest } from 'next/server' -import { prisma } from '@/app/lib/prisma' +import { prisma } from '@/app/lib/prisma' export async function GET( - req : NextRequest, // ← Request wird gebraucht! + req: NextRequest, { params }: { params: { steamId: string } }, ) { const steamId = params.steamId - if (!steamId) { - return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 }) - } + if (!steamId) return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 }) - /* ───────── Query-Parameter „types“ auslesen ───────── */ const { searchParams } = new URL(req.url) - // ?types=premier,competitive - const typesParam = searchParams.get('types') // string | null - const types = typesParam - ? typesParam.split(',').map(t => t.trim()).filter(Boolean) - : [] // leer ⇒ kein Filter - /* ───────── Daten holen ───────── */ + // Filter: ?types=premier,competitive + const typesParam = searchParams.get('types') + const types = typesParam ? typesParam.split(',').map(s => s.trim()).filter(Boolean) : [] + + // Pagination + const limit = Math.min(Math.max(parseInt(searchParams.get('limit') || '10', 10), 1), 50) + const cursor = searchParams.get('cursor') // letzte Match-ID der vorherigen Seite + try { - const matchPlayers = await prisma.matchPlayer.findMany({ + // Wir holen Matches, an denen der User teilgenommen hat + const matches = await prisma.match.findMany({ where: { - steamId, - - /* nur wenn Filter gesetzt ist */ - ...(types.length && { - match: { matchType: { in: types } }, - }), + players: { some: { steamId } }, + ...(types.length ? { matchType: { in: types } } : {}), }, - + // deterministische Ordnung + stabil mit Cursor + orderBy: [{ demoDate: 'desc' }, { id: 'desc' }], + take: limit + 1, // eine extra für hasMore + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), select: { - teamId: true, - team : true, - match : { - select: { - id : true, - demoDate : true, - map : true, - roundCount : true, - scoreA : true, - scoreB : true, - matchType : true, - teamAId : true, - teamBId : true, - teamAUsers : { select: { steamId: true } }, - teamBUsers : { select: { steamId: true } }, - winnerTeam : true, - }, - }, - stats: true, + id: true, + demoDate: true, + map: true, + roundCount: true, + scoreA: true, + scoreB: true, + matchType: true, + teamAUsers: { select: { steamId: true } }, + teamBUsers: { select: { steamId: true } }, + winnerTeam: true, + // nur die Stats des angefragten Spielers + players: { where: { steamId }, select: { stats: true } }, }, - - orderBy: { match: { demoDate: 'desc' } }, }) - /* ───────── Aufbereiten fürs Frontend ───────── */ - const data = matchPlayers.map(mp => { - const m = mp.match - const stats = mp.stats + const hasMore = matches.length > limit + const page = hasMore ? matches.slice(0, limit) : matches + + const items = page.map(m => { + const stats = m.players[0]?.stats ?? null const kills = stats?.kills ?? 0 const deaths = stats?.deaths ?? 0 @@ -67,16 +58,10 @@ export async function GET( const rankOld = stats?.rankOld ?? null const rankNew = stats?.rankNew ?? null - + const rankChange = rankNew != null && rankOld != null ? rankNew - rankOld : null const aim = stats?.aim ?? null - const rankChange = - rankNew != null && rankOld != null ? rankNew - rankOld : null - - /* Team des Spielers ermitteln */ - const playerTeam = - m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T' - + const playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T' const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}` return { @@ -84,25 +69,23 @@ export async function GET( map : m.map ?? 'Unknown', date : m.demoDate?.toISOString() ?? '', matchType : m.matchType ?? 'community', - score, roundCount: m.roundCount, - rankOld, rankNew, rankChange, - kills, deaths, kdr, aim, - winnerTeam: m.winnerTeam ?? null, - team : playerTeam, // „CT“ oder „T“ + team : playerTeam, } }) - return NextResponse.json(data) + const nextCursor = hasMore ? page[page.length - 1].id : null + + return NextResponse.json({ items, nextCursor, hasMore }) } catch (err) { console.error('[API] Fehler beim Laden der Matches:', err) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) diff --git a/src/app/components/DroppableZone.tsx b/src/app/components/DroppableZone.tsx index f910c9e..0a52418 100644 --- a/src/app/components/DroppableZone.tsx +++ b/src/app/components/DroppableZone.tsx @@ -1,6 +1,6 @@ 'use client' -import { useDroppable } from '@dnd-kit/core' +import { useDroppable, useDndContext } from '@dnd-kit/core' import { Player } from '../types/team' import clsx from 'clsx' @@ -19,14 +19,19 @@ export function DroppableZone({ saveSuccess = false, }: DroppableZoneProps) { const { isOver, setNodeRef } = useDroppable({ id }) - + const { over } = useDndContext() + /* ───────────── sichtbare Zone ───────────── */ + const isOverZone = isOver || + over?.id === id || + // Sortable-Items: containerId steckt unter .sortable.containerId + over?.data?.current?.sortable?.containerId === id || + // generische Droppables (z.B. MiniCardDummy): containerId direkt auf data.current + over?.data?.current?.containerId === id + const zoneClasses = clsx( - // immer volle Zeilenbreite - 'w-full rounded-lg p-4 transition-colors', - // Mindesthöhe einer MiniCard (damit sie bei leeren Teams nicht einklappt) - 'min-h-[200px]', - isOver + 'w-full rounded-lg p-4 transition-colors min-h-[200px]', + isOverZone ? 'border-2 border-dashed border-blue-400 bg-blue-400/10' : 'border border-gray-300 dark:border-neutral-700' ) diff --git a/src/app/components/InvitePlayersModal.tsx b/src/app/components/InvitePlayersModal.tsx index 7d0fa68..8947d1b 100644 --- a/src/app/components/InvitePlayersModal.tsx +++ b/src/app/components/InvitePlayersModal.tsx @@ -9,6 +9,8 @@ import { Player, Team } from '../types/team' import Pagination from './Pagination' import { AnimatePresence, motion } from 'framer-motion' +type InviteStatus = 'sent' | 'failed' | 'added' | 'pending' + type Props = { show: boolean onClose: () => void @@ -30,12 +32,14 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir const [searchTerm, setSearchTerm] = useState('') const usersPerPage = 9 const [currentPage, setCurrentPage] = useState(1) + const [invitedStatus, setInvitedStatus] = useState>({}) useEffect(() => { if (show) { fetchUsersNotInTeam() setIsSuccess(false) setInvitedIds([]) + setInvitedStatus({}) } }, [show]) @@ -60,12 +64,13 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir const handleInvite = async () => { if (selectedIds.length === 0 || !steamId) return + const ids = [...selectedIds] try { const url = directAdd ? '/api/team/add-players' : '/api/team/invite' const body = directAdd - ? { teamId: team.id, steamIds: selectedIds } - : { teamId: team.id, userIds: selectedIds, invitedBy: steamId } + ? { teamId: team.id, steamIds: ids } + : { teamId: team.id, userIds: ids, invitedBy: steamId } const res = await fetch(url, { method: 'POST', @@ -73,18 +78,50 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir body: JSON.stringify(body), }) - if (!res.ok) { - const error = await res.json() - console.error('Fehler beim Einladen:', error.message) + // Versuch: partielle Resultate lesen + let detail: any = null + try { detail = await res.clone().json() } catch {} + + if (res.ok) { + const okStatus: InviteStatus = directAdd ? 'added' : 'sent' + setInvitedStatus(prev => ({ + ...prev, + ...Object.fromEntries(ids.map(id => [id, okStatus])) + })) + setSentCount(ids.length) + } else if (detail?.results && Array.isArray(detail.results)) { + // erwartetes Schema: [{ steamId, ok }] + let okCount = 0 + const next: Record = {} + for (const r of detail.results) { + const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed' + next[r.steamId] = st + if (r.ok) okCount++ + } + setInvitedStatus(prev => ({ ...prev, ...next })) + setSentCount(okCount) } else { - setSentCount(selectedIds.length) - setInvitedIds(selectedIds) // 👈 Einladungsliste speichern - setIsSuccess(true) - setSelectedIds([]) // ⛔ nicht zu früh löschen! - onSuccess() + // alles fehlgeschlagen + setInvitedStatus(prev => ({ + ...prev, + ...Object.fromEntries(ids.map(id => [id, 'failed' as InviteStatus])) + })) + setSentCount(0) } + + setInvitedIds(ids) + setIsSuccess(true) + setSelectedIds([]) + onSuccess() } catch (err) { console.error('Fehler beim Einladen:', err) + setInvitedStatus(prev => ({ + ...prev, + ...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus])) + })) + setInvitedIds(selectedIds) + setSentCount(0) + setIsSuccess(true) } } @@ -214,15 +251,42 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir : 'Keine Benutzer gefunden.'} ) : ( - <> - - {!isSuccess && paginatedUsers.map((user) => ( + + {!isSuccess && paginatedUsers.map((user) => ( + + + + ))} + + {isSuccess && invitedIds.map((id) => { + const user = allUsers.find((u) => u.steamId === id) + if (!user) return null + return ( - ))} - {isSuccess && - invitedIds.map((id) => { - const user = allUsers.find((u) => u.steamId === id) - if (!user) return null - return ( - - - - ) - })} - - - { !isSuccess && ( -
- setCurrentPage(page)} - /> -
- ) - } - + ) + })} +
)} diff --git a/src/app/components/MiniCard.tsx b/src/app/components/MiniCard.tsx index adb929c..bd9227b 100644 --- a/src/app/components/MiniCard.tsx +++ b/src/app/components/MiniCard.tsx @@ -6,6 +6,8 @@ import Image from 'next/image' import PremierRankBadge from './PremierRankBadge' import { motion, AnimatePresence } from 'framer-motion' +type InviteStatus = 'sent' | 'failed' | 'added' | 'pending' + type MiniCardProps = { title: string avatar: string @@ -26,7 +28,7 @@ type MiniCardProps = { hideOverlay?: boolean isSelectable?: boolean isAdmin?: boolean - message?: string + invitedStatus?: InviteStatus isInvite?: boolean invitationId?: string } @@ -51,20 +53,34 @@ export default function MiniCard({ hideOverlay = false, isSelectable = true, isAdmin = false, - message, + invitedStatus, isInvite = false, invitationId }: MiniCardProps) { //const isSelectable = typeof onSelect === 'function' const canEdit = (isAdmin || currentUserSteamId === teamLeaderSteamId) && steamId !== teamLeaderSteamId + const statusBg = + invitedStatus === 'sent' ? 'bg-green-500 dark:bg-green-700' : + invitedStatus === 'added' ? 'bg-teal-500 dark:bg-teal-700' : + invitedStatus === 'failed' ? 'bg-red-500 dark:bg-red-700' : + invitedStatus === 'pending' ? 'bg-yellow-500 dark:bg-yellow-700': + 'bg-white dark:bg-neutral-800' + + // Rand unabhängig vom Status (nur bei Auswahl Blau; sonst neutral oder transparent) + const baseBorder = + selected + ? 'ring-1 ring-blue-500 border-blue-500 dark:ring-blue-400' + : invitedStatus + ? 'border-transparent' // kein grüner/roter Rand, Fokus liegt auf dem BG + : 'border-gray-200 dark:border-neutral-700' + const cardClasses = ` relative flex flex-col items-center p-4 border rounded-lg transition - max-h-[200px] max-w-[160px] 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'} + max-h-[200px] max-w-[160px] overflow-hidden shadow-2xs rounded-xl + ${statusBg} ${baseBorder} ${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''} - ${isSelectable ? 'hover:border-blue-400 dark:hover:border-blue-400 cursor-pointer' : ''} + ${isSelectable ? 'cursor-pointer' : ''} ` const avatarWrapper = 'relative w-16 h-16 mb-2' @@ -73,6 +89,8 @@ export default function MiniCard({ if (isSelectable) onSelect?.(steamId) } + const stopDrag = (e: React.PointerEvent | React.MouseEvent) => e.stopPropagation() + const handleRevokeClick = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -91,9 +109,17 @@ export default function MiniCard({ onPromote?.(steamId) } - const stopDrag = (e: React.PointerEvent | React.MouseEvent) => { - e.stopPropagation() - } + const statusLabel = + invitedStatus === 'sent' ? 'Eingeladen' : + invitedStatus === 'added' ? 'Hinzugefügt' : + invitedStatus === 'failed'? 'Fehlgeschlagen' : + invitedStatus === 'pending'? 'Wird gesendet…' : null + + const statusPillClasses = + invitedStatus === 'sent' ? 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-200' : + invitedStatus === 'added' ? 'bg-teal-100 text-teal-600 dark:bg-teal-900/40 dark:text-teal-300' : + invitedStatus === 'failed' ? 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-300' : + invitedStatus === 'pending' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': ''; return (
@@ -139,10 +165,12 @@ export default function MiniCard({ {title} - {rank ? ( - + {statusLabel ? ( + + {statusLabel} + ) : ( - + )} { /* @@ -152,21 +180,6 @@ export default function MiniCard({ 🌐 )} */ } - - {message && ( - - - {message} - - - )}
) diff --git a/src/app/components/MiniCardDummy.tsx b/src/app/components/MiniCardDummy.tsx index 41c9591..4a8cd04 100644 --- a/src/app/components/MiniCardDummy.tsx +++ b/src/app/components/MiniCardDummy.tsx @@ -1,12 +1,25 @@ +// MiniCardDummy.tsx +'use client' + +import { useDroppable } from "@dnd-kit/core" + type MiniCardDummyProps = { title: string onClick?: () => void children?: React.ReactNode + zoneId?: string } -export default function MiniCardDummy({ title, onClick, children }: MiniCardDummyProps) { +export default function MiniCardDummy({ title, onClick, children, zoneId }: MiniCardDummyProps) { + const { setNodeRef, isOver } = useDroppable({ + id: `${zoneId ?? 'dummy'}-drop`, + data: { containerId: zoneId, role: 'dummy' }, // ⬅️ wichtig für Zone-Highlight + }) + + return (
-
+
{children}
diff --git a/src/app/components/TeamMemberView.tsx b/src/app/components/TeamMemberView.tsx index 05fdad9..cc3a680 100644 --- a/src/app/components/TeamMemberView.tsx +++ b/src/app/components/TeamMemberView.tsx @@ -56,7 +56,6 @@ export default function TeamMemberView({ setIsDragging, adminMode = false, }: Props) { - const { team: storeTeam, setTeam } = useTeamStore() const team = teamProp ?? storeTeam if (!team) return null @@ -86,15 +85,18 @@ export default function TeamMemberView({ const [editedName, setEditedName] = useState(team.name || '') const [saveSuccess, setSaveSuccess] = useState(false) - // Upload-Progress für Teamlogo + // Cache-Busting fürs Logo + const [logoVersion, setLogoVersion] = useState(null) + + // Upload-Progress const [isUploadingLogo, setIsUploadingLogo] = useState(false) const [uploadPct, setUploadPct] = useState(0) - const R = 28 - const S = 64 - const CIRC = 2 * Math.PI * R + const R = 28, S = 64, CIRC = 2 * Math.PI * R const dashOffset = CIRC - (uploadPct / 100) * CIRC + const fileInputRef = useRef(null) + const isClickable = canManage && !isUploadingLogo - // 1) SSE-Verbindung sicherstellen + // SSE-Verbindung useEffect(() => { if (!currentUserSteamId) return if (!isConnected) connect(currentUserSteamId) @@ -107,13 +109,13 @@ export default function TeamMemberView({ return aa === bb } + // Team-Listen lokal synchronisieren useEffect(() => { if (!team) return const nextActive = (team.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name)) const nextInactive = (team.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name)) - const nextInvited = Array.from( - new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values() - ).sort((a,b)=>a.name.localeCompare(b.name)) + const nextInvited = Array.from(new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values()) + .sort((a,b)=>a.name.localeCompare(b.name)) const unchanged = eqByIds(activePlayers, nextActive) && @@ -132,13 +134,26 @@ export default function TeamMemberView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [team?.id, team?.name, team?.logo, team?.leader, team?.activePlayers, team?.inactivePlayers, team?.invitedPlayers]) - // 2) Relevante SSE-Events -> Team neu laden + // Relevante SSE-Events useEffect(() => { if (!lastEvent || !team?.id) return if (!isSseEventType(lastEvent.type)) return - if (!RELEVANT.has(lastEvent.type)) return - const payload = lastEvent.payload ?? {} + + // ► Spezialfall: nur Logo aktualisieren (ohne komplettes Reload) + if (lastEvent.type === 'team-logo-updated') { + if (payload.teamId && payload.teamId !== team.id) return + + const current = useTeamStore.getState().team + if (payload?.filename && current) { + setTeam({ ...current, logo: payload.filename }) + } + if (payload?.version) setLogoVersion(payload.version) + return + } + + // andere Team/Self-Events + if (!RELEVANT.has(lastEvent.type)) return if (payload.teamId && payload.teamId !== team.id) return ;(async () => { @@ -149,9 +164,8 @@ export default function TeamMemberView({ const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name)) const nextInactive = (updated.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name)) - const nextInvited = Array.from( - new Map((updated.invitedPlayers ?? []).map(p => [p.steamId, p])).values() - ).sort((a,b)=>a.name.localeCompare(b.name)) + const nextInvited = Array.from(new Map((updated.invitedPlayers ?? []).map(p => [p.steamId, p])).values()) + .sort((a,b)=>a.name.localeCompare(b.name)) if (isDraggingRef.current) { setPendingRemote({ active: nextActive, inactive: nextInactive, invited: nextInvited }) @@ -224,7 +238,6 @@ export default function TeamMemberView({ const dropToActive = overId === 'active' || activePlayers.some(p => p.steamId === overId) - // selbe Zone -> nichts speichern if ((wasInActive && dropToActive) || (!wasInActive && !dropToActive)) { if (pendingRemote) { setActivePlayers(pendingRemote.active) @@ -323,7 +336,7 @@ export default function TeamMemberView({ } } - // Upload mit Progress via XHR + // Upload mit Progress via XHR – setzt filename/version direkt, kein Reload nötig async function uploadTeamLogo(file: File) { return new Promise((resolve, reject) => { const formData = new FormData() @@ -331,15 +344,24 @@ export default function TeamMemberView({ formData.append('teamId', team!.id) const xhr = new XMLHttpRequest() - xhr.open('POST', '/api/team/upload-logo') // ggf. anpassen + xhr.open('POST', '/api/team/upload-logo') xhr.upload.onprogress = (e) => { - if (e.lengthComputable) { - setUploadPct(Math.round((e.loaded / e.total) * 100)) + if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100)) + else setUploadPct(p => (p < 90 ? p + 1 : p)) + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const json = JSON.parse(xhr.responseText) + const current = useTeamStore.getState().team + if (json?.filename && current) setTeam({ ...current, logo: json.filename }) + if (json?.version) setLogoVersion(json.version) + } catch {} + resolve() } else { - setUploadPct((p) => (p < 90 ? p + 1 : p)) + reject(new Error('Upload fehlgeschlagen')) } } - xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error('Upload fehlgeschlagen'))) xhr.onerror = () => reject(new Error('Netzwerkfehler beim Upload')) setIsUploadingLogo(true) setUploadPct(0) @@ -390,11 +412,31 @@ export default function TeamMemberView({
canManage && !isUploadingLogo && document.getElementById('logoUpload')?.click()} + role="button" + aria-disabled={!isClickable} + aria-busy={isUploadingLogo} + tabIndex={isClickable ? 0 : -1} + className={[ + "relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600", + isClickable ? "cursor-pointer" : "cursor-not-allowed opacity-70" + ].join(" ")} + onClick={() => { if (isClickable) fileInputRef.current?.click() }} + onKeyDown={(e) => { + if (!isClickable) return + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + fileInputRef.current?.click() + } + }} + title={isUploadingLogo ? "Upload läuft…" : (canManage ? "Logo hochladen" : undefined)} > Teamlogo - {canManage && !isUploadingLogo && ( + {/* Hover-Overlay nur, wenn klickbar */} + {canManage && isClickable && (
@@ -411,25 +454,23 @@ export default function TeamMemberView({
)} + {/* Progress-Kreis (Start bei 12 Uhr via rotate(-90 …)) */} {isUploadingLogo && (
- - + + + + {uploadPct}%
@@ -438,16 +479,18 @@ export default function TeamMemberView({ {canManage && ( { + if (isUploadingLogo) return const file = e.target.files?.[0] if (!file) return try { await uploadTeamLogo(file) - await handleReload() } catch (err) { console.error('Fehler beim Hochladen des Logos:', err) alert('Fehler beim Hochladen des Logos.') @@ -459,7 +502,6 @@ export default function TeamMemberView({ e.currentTarget.value = '' } }} - disabled={isUploadingLogo} /> )}
@@ -531,20 +573,16 @@ export default function TeamMemberView({
{canManage && ( - )}