This commit is contained in:
Linrador 2025-08-18 15:29:09 +02:00
parent dfc3e6bf99
commit 1b5cd58f7f
14 changed files with 509 additions and 532 deletions

View File

@ -19,6 +19,8 @@ const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
/* -------------------- Helper -------------------- */ /* -------------------- Helper -------------------- */
const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
// Admin-Edit-Flag setzen/zurücksetzen // Admin-Edit-Flag setzen/zurücksetzen
async function setAdminEdit(voteId: string, by: string | null) { async function setAdminEdit(voteId: string, by: string | null) {
return prisma.mapVote.update({ return prisma.mapVote.update({
@ -497,6 +499,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
// match-ready senden (erste Map + Teilnehmer) // match-ready senden (erste Map + Teilnehmer)
if (updated?.locked) { if (updated?.locked) {
await sleep(3000);
const chosen = deriveChosenSteps(updated) const chosen = deriveChosenSteps(updated)
const first = chosen[0] const first = chosen[0]
const key = first?.map ?? null const key = first?.map ?? null
@ -550,6 +553,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
// match-ready senden // match-ready senden
if (updated?.locked) { if (updated?.locked) {
await sleep(3000);
const chosen = deriveChosenSteps(updated) const chosen = deriveChosenSteps(updated)
const first = chosen[0] const first = chosen[0]
const key = first?.map ?? null const key = first?.map ?? null
@ -642,6 +646,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
// Falls durch diesen Schritt locked wurde → Export & match-ready // Falls durch diesen Schritt locked wurde → Export & match-ready
if (updated?.locked) { if (updated?.locked) {
await sleep(3000);
const chosen = deriveChosenSteps(updated) const chosen = deriveChosenSteps(updated)
const first = chosen[0] const first = chosen[0]
const key = first?.map ?? null const key = first?.map ?? null

View File

@ -168,12 +168,12 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
> >
{loading && ( {loading && (
<span <span
className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full" className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-2"
role="status" role="status"
aria-label="loading" aria-label="loading"
></span> ></span>
)} )}
{loading ? 'Lädt' : (children ?? title)} {children ?? title}
</button> </button>
) )
}) })

View File

@ -267,32 +267,18 @@ export default function CommunityMatchList({ matchType }: Props) {
${dimmed ? 'opacity-40' : 'opacity-100'} ${dimmed ? 'opacity-40' : 'opacity-100'}
`} `}
> >
{isLive && ( {/* Live / Map-Vote Badge */}
<span className="absolute top-2 px-2 py-0.5 text-xs font-semibold rounded-full bg-red-300 dark:bg-red-500 text-white"> {isLive ? (
<span className="absolute top-2 px-2 py-0.5 text-xs font-semibold rounded-full bg-red-500 text-white shadow">
LIVE LIVE
</span> </span>
)} ) : (m.mapVote?.isOpen ? (
<span className="absolute top-2 px-2 py-0.5 text-[11px] font-semibold rounded-full bg-green-600 text-white shadow">
{/* Map-Vote Badge */} Map-Vote offen
{m.mapVote && (
<span
className={`
px-2 py-0.5 rounded-full text-[11px] font-semibold
${m.mapVote.isOpen ? 'bg-green-300 dark:bg-green-600 text-white' : 'bg-neutral-200 dark:bg-neutral-700'}
`}
title={
m.mapVote.opensAt
? `Öffnet ${format(new Date(m.mapVote.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr`
: undefined
}
>
{m.mapVote.isOpen
? (m.mapVote.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen')
: m.mapVote.opensAt
? `Map-Vote ab ${format(new Date(m.mapVote.opensAt), 'HH:mm', { locale: de })} Uhr`
: 'Map-Vote bald'}
</span> </span>
)} ) : null
)
}
<div className="flex w-full justify-around items-center"> <div className="flex w-full justify-around items-center">
<div className="flex flex-col items-center w-1/3"> <div className="flex flex-col items-center w-1/3">
@ -306,12 +292,17 @@ export default function CommunityMatchList({ matchType }: Props) {
</div> </div>
</div> </div>
<div className="flex flex-col items-center space-y-1 mt-2"> {/* Datum + Uhrzeit: höher & highlight */}
<span className={`px-3 py-0.5 rounded-full text-sm font-semibold ${isLive ? 'bg-red-300 dark:bg-red-500' : 'bg-yellow-300 dark:bg-yellow-500'}`}> <div className="flex flex-col items-center -mt-1 space-y-1">
<span
className={`px-3 py-1 rounded-full text-[13px] font-bold shadow ring-1 ring-black/10
${isLive ? 'bg-red-500 text-white' : 'bg-yellow-400 text-gray-900 dark:bg-yellow-500 dark:text-black'}
`}
>
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })} {format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
</span> </span>
<span className="flex items-center gap-1 text-xs opacity-80"> <span className="flex items-center gap-1 text-xs font-semibold opacity-90">
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 512 512">
<path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" /> <path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" />
</svg> </svg>

View File

@ -1,11 +1,9 @@
// CompRankBadge.tsx
'use client'; 'use client';
import Image from 'next/image'; import Image from 'next/image';
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';
type Props = { type Props = { rank: number | null };
rank: number | null;
};
const rankNames: Record<number, string> = { const rankNames: Record<number, string> = {
1: 'Silver I', 1: 'Silver I',
@ -32,7 +30,6 @@ const rankNames: Record<number, string> = {
export default function CompRankBadge({ rank }: Props) { export default function CompRankBadge({ rank }: Props) {
let imageName = 'skillgroup_none.webp'; let imageName = 'skillgroup_none.webp';
let altText = 'No Rank'; let altText = 'No Rank';
if (typeof rank === 'number') { if (typeof rank === 'number') {
if (rank >= 1 && rank <= 18) { if (rank >= 1 && rank <= 18) {
imageName = `skillgroup${rank}.webp`; imageName = `skillgroup${rank}.webp`;
@ -50,8 +47,10 @@ export default function CompRankBadge({ rank }: Props) {
alt={altText} alt={altText}
width={60} width={60}
height={60} height={60}
sizes="(max-width: 768px) 100px, 70px" // Wichtig: feste Höhe für die Zeile, Breite auto
style={{ objectFit: 'contain' }} // ← korrekt! className="inline-block align-middle h-7 w-auto" // h-7 = 28px
sizes="70px"
style={{ objectFit: 'contain' }}
/> />
</Tooltip> </Tooltip>
); );

View File

@ -27,6 +27,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const [selectedIds, setSelectedIds] = useState<string[]>([]) const [selectedIds, setSelectedIds] = useState<string[]>([])
const [invitedIds, setInvitedIds] = useState<string[]>([]) const [invitedIds, setInvitedIds] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isInviting, setIsInviting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false) const [isSuccess, setIsSuccess] = useState(false)
const [sentCount, setSentCount] = useState(0) const [sentCount, setSentCount] = useState(0)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@ -87,10 +88,12 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
} }
const handleInvite = async () => { const handleInvite = async () => {
if (isInviting) return
if (selectedIds.length === 0 || !steamId) return if (selectedIds.length === 0 || !steamId) return
const ids = [...selectedIds] const ids = [...selectedIds]
try { try {
setIsInviting(true)
const url = directAdd ? '/api/team/add-players' : '/api/team/invite' const url = directAdd ? '/api/team/add-players' : '/api/team/invite'
const body = directAdd const body = directAdd
? { teamId: team.id, steamIds: ids } ? { teamId: team.id, steamIds: ids }
@ -135,6 +138,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
setSentCount(0) setSentCount(0)
setIsSuccess(true) setIsSuccess(true)
} }
finally {
setIsInviting(false)
}
} }
useEffect(() => { useEffect(() => {
@ -272,13 +278,18 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
title={directAdd ? 'Spieler hinzufügen' : 'Spieler einladen'} title={directAdd ? 'Spieler hinzufügen' : 'Spieler einladen'}
show={show} show={show}
onClose={onClose} onClose={onClose}
onSave={handleInvite} onSave={() => { if (!isInviting) handleInvite() }}
closeButtonColor={isSuccess ? 'teal' : 'blue'} closeButtonColor={isSuccess ? 'teal' : 'blue'}
closeButtonTitle={ closeButtonTitle={
isSuccess isSuccess
? directAdd ? 'Spieler hinzugefügt' : 'Einladungen versendet' ? (directAdd ? 'Spieler hinzugefügt' : 'Einladungen versendet')
: directAdd ? 'Hinzufügen' : 'Einladungen senden' : (
isInviting
? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...')
: (directAdd ? 'Hinzufügen' : 'Einladungen senden')
)
} }
closeButtonLoading={isInviting}
scrollBody={true} scrollBody={true}
> >
<p ref={descRef} className="text-sm text-gray-700 dark:text-neutral-300 mb-2"> <p ref={descRef} className="text-sm text-gray-700 dark:text-neutral-300 mb-2">

View File

@ -88,11 +88,11 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`) const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`)
const cardClasses = const cardClasses =
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' + 'group relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
'dark:border-neutral-700 shadow-sm transition cursor-pointer focus:outline-none ' + 'dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ' +
(isOpen (isOpen
? 'ring-1 ring-green-500/15 hover:ring-green-500/25 hover:shadow-md' ? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg'
: 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md') : 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md');
return ( return (
<div <div
@ -103,11 +103,16 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
className={cardClasses} className={cardClasses}
aria-label="Map-Vote öffnen" aria-label="Map-Vote öffnen"
> >
{isOpen && <div aria-hidden className="absolute inset-0 z-0 pointer-events-none mapVoteGradient" />} {isOpen && (
<>
<div aria-hidden className="absolute inset-0 z-0 pointer-events-none mapVoteGradient" />
<span aria-hidden className="shine pointer-events-none" />
</>
)}
<div className="relative z-[1] px-4 py-3 flex items-center justify-between gap-3"> <div className="relative z-[1] px-4 py-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className="shrink-0 w-9 h-9 rounded-full grid place-items-center bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200"> <div className="shrink-0 w-9 h-9 rounded-full grid place-items-center bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200 transition-transform group-hover:scale-[1.03] group-hover:translate-x-[1px]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
<path d="M15 4.5 9 7.5l-6-3v15l6 3 6-3 6 3v-15l-6-3Zm-6 16.5-4-2V6l4 2v13Zm2-13 4-2v13l-4 2V8Z"/> <path d="M15 4.5 9 7.5l-6-3v15l6 3 6-3 6 3v-15l-6-3Zm-6 16.5-4-2V6l4 2v13Zm2-13 4-2v13l-4 2V8Z"/>
</svg> </svg>
@ -151,6 +156,7 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
</div> </div>
<style jsx>{` <style jsx>{`
/* Hintergrund-Schimmer (läuft permanent) */
@keyframes slide-x { @keyframes slide-x {
from { background-position-x: 0%; } from { background-position-x: 0%; }
to { background-position-x: 200%; } to { background-position-x: 200%; }
@ -164,7 +170,7 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
); );
background-size: 200% 100%; background-size: 200% 100%;
background-repeat: repeat-x; background-repeat: repeat-x;
animation: slide-x 3s linear infinite; animation: slide-x 6s linear infinite; /* etwas ruhiger */
} }
:global(.dark) .mapVoteGradient { :global(.dark) .mapVoteGradient {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
@ -174,8 +180,41 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
rgba(16,168,54,0.28) 100% rgba(16,168,54,0.28) 100%
); );
} }
/* Shine-Sweep nur auf Hover */
@keyframes shine {
0% { transform: translateX(-120%) skewX(-20deg); opacity: 0; }
10% { opacity: .7; }
27% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
100% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
}
.shine {
position: absolute;
inset: 0;
}
.shine::before {
content: "";
position: absolute;
top: -25%;
bottom: -25%;
left: -20%;
width: 35%;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
filter: blur(2px);
opacity: 0;
transform: translateX(-120%) skewX(-20deg);
transition: opacity .2s;
}
/* nur wenn die Karte offen ist und gehovert wird */
:global(.group:hover) .shine::before {
animation: shine 3.8s ease-out infinite;
}
/* Respektiere Bewegungs-Präferenzen */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.mapVoteGradient { animation: none; } .mapVoteGradient { animation: none; }
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }
} }
`}</style> `}</style>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@ import { Team } from '../types/team'
import Alert from './Alert' import Alert from './Alert'
import Image from 'next/image' import Image from 'next/image'
import { MATCH_EVENTS } from '../lib/sseEvents' import { MATCH_EVENTS } from '../lib/sseEvents'
import Link from 'next/link'
type TeamWithPlayers = Team & { players?: MatchPlayer[] } type TeamWithPlayers = Team & { players?: MatchPlayer[] }
@ -246,20 +247,37 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
/* ─── Render ─────────────────────────────────────────────── */ /* ─── Render ─────────────────────────────────────────────── */
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold"> {/* Kopfzeile: Zurück + Admin-Buttons */}
Match auf {mapLabel} ({match.matchType}) <div className="flex items-center justify-between">
</h1> {/* Links: Zurück */}
<Link href="/schedule">
<Button color="gray" variant="outline">
Zurück
</Button>
</Link>
{/* Rechts: Admin-Buttons */}
{isAdmin && ( {isAdmin && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={() => setEditMetaOpen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md"> <Button
onClick={() => setEditMetaOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md"
>
Match bearbeiten Match bearbeiten
</Button> </Button>
<Button onClick={handleDelete} className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"> <Button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"
>
Match löschen Match löschen
</Button> </Button>
</div> </div>
)} )}
</div>
<h1 className="text-2xl font-bold">
Match auf {mapLabel} ({match.matchType})
</h1>
<p className="text-sm text-gray-500">Datum: {readableDate}</p> <p className="text-sm text-gray-500">Datum: {readableDate}</p>
@ -280,20 +298,50 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h2 className="text-xl font-semibold">{match.teamA?.name ?? 'Team A'}</h2> <h2 className="text-xl font-semibold">{match.teamA?.name ?? 'Team A'}</h2>
{showEditA && ( <Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<Alert type="soft" color="info" className="flex items-center justify-between gap-4"> <div className="flex items-center gap-2">
<span> {showEditA ? (
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten. <>
{/* Unlocked-Icon */}
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
</>
) : (
<>
{/* Locked-Icon */}
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
</svg>
</>
)
}
<span className='text-gray-300'>
{showEditA ? (
<>
Du kannst die Aufstellung noch bis{' '}
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
</>
) : (
<>Die Aufstellung kann nicht mehr bearbeitet werden.</>
)}
</span> </span>
</div>
<Button <Button
size="sm" size="sm"
onClick={() => setEditSide('A')} onClick={() => showEditA && setEditSide('A')}
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white" disabled={!showEditA}
className={`px-3 py-1.5 text-sm rounded-lg ${
showEditA
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
}`}
> >
Spieler bearbeiten Spieler bearbeiten
</Button> </Button>
</Alert> </Alert>
)}
</div> </div>
{renderTable(teamAPlayers)} {renderTable(teamAPlayers)}
@ -322,20 +370,51 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{match.teamB?.name ?? 'Team B'} {match.teamB?.name ?? 'Team B'}
</h2> </h2>
{showEditB && (
<Alert type="soft" color="info" className="flex items-center justify-between gap-4"> <Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<span> <div className="flex items-center gap-2">
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten. {showEditB ? (
<>
{/* Unlocked-Icon */}
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
</>
) : (
<>
{/* Locked-Icon */}
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
</svg>
</>
)
}
<span className='text-gray-300'>
{showEditB ? (
<>
Du kannst die Aufstellung noch bis{' '}
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
</>
) : (
<>Die Aufstellung kann nicht mehr bearbeitet werden.</>
)}
</span> </span>
</div>
<Button <Button
size="sm" size="sm"
onClick={() => setEditSide('B')} onClick={() => showEditB && setEditSide('B')}
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white" disabled={!showEditB}
className={`px-3 py-1.5 text-sm rounded-lg ${
showEditB
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
}`}
> >
Spieler bearbeiten Spieler bearbeiten
</Button> </Button>
</Alert> </Alert>
)}
</div> </div>
{renderTable(teamBPlayers)} {renderTable(teamBPlayers)}

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import Button from './Button'
type Width = type Width =
| 'sm:max-w-sm' | 'sm:max-w-sm'
@ -20,6 +21,7 @@ type ModalProps = {
hideCloseButton?: boolean hideCloseButton?: boolean
closeButtonColor?: 'blue' | 'red' | 'green' | 'teal' closeButtonColor?: 'blue' | 'red' | 'green' | 'teal'
closeButtonTitle?: string closeButtonTitle?: string
closeButtonLoading?: boolean
disableSave?: boolean disableSave?: boolean
maxWidth?: Width maxWidth?: Width
/** Wenn false, wird der Body nicht gescrollt (wir paginieren stattdessen) */ /** Wenn false, wird der Body nicht gescrollt (wir paginieren stattdessen) */
@ -119,8 +121,8 @@ export default function Modal({
</h3> </h3>
{!hideCloseButton && ( {!hideCloseButton && (
<button <Button
type="button" size='sm'
aria-label="Close" aria-label="Close"
data-hs-overlay={`#${id}`} data-hs-overlay={`#${id}`}
onClick={onClose} onClick={onClose}
@ -139,7 +141,7 @@ export default function Modal({
<path d="M18 6 6 18" /> <path d="M18 6 6 18" />
<path d="m6 6 12 12" /> <path d="m6 6 12 12" />
</svg> </svg>
</button> </Button>
)} )}
</div> </div>
@ -156,25 +158,25 @@ export default function Modal({
{/* Footer (fixe Höhe) */} {/* Footer (fixe Höhe) */}
<div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700"> <div className="flex justify-end items-center gap-x-2 py-3 px-4 border-t border-gray-200 dark:border-neutral-700">
{!hideCloseButton && ( {!hideCloseButton && (
<button <Button
type="button" size='sm'
data-hs-overlay={`#${id}`} data-hs-overlay={`#${id}`}
onClick={onClose} onClick={onClose}
className="py-2 px-3 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-700" className="py-2 px-3 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-700"
> >
Schließen Schließen
</button> </Button>
)} )}
{onSave && ( {onSave && (
<button <Button
type="button" size='sm'
onClick={onSave} onClick={onSave}
disabled={disableSave} disabled={disableSave}
className={`py-2 px-3 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 hover:bg-${closeButtonColor}-700 focus:bg-${closeButtonColor}-700 text-white`} className={`py-2 px-3 text-sm font-medium rounded-lg border border-transparent bg-${closeButtonColor}-600 hover:bg-${closeButtonColor}-700 focus:bg-${closeButtonColor}-700 text-white`}
> >
{closeButtonTitle} {closeButtonTitle}
</button> </Button>
)} )}
</div> </div>
</div> </div>

View File

@ -70,7 +70,7 @@ function Cell({
const baseClass = const baseClass =
Component === 'th' Component === 'th'
? 'px-6 py-3 text-start font-medium text-xs text-gray-500 uppercase dark:text-neutral-400' ? 'px-6 py-3 text-start font-medium text-xs text-gray-500 uppercase dark:text-neutral-400'
: 'px-6 py-3 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200' : 'px-6 py-3 text-sm text-gray-800 dark:text-neutral-200'
return ( return (
<Component {...rest} className={`${baseClass} ${hoverClass} ${className}`}> <Component {...rest} className={`${baseClass} ${hoverClass} ${className}`}>
{children} {children}

View File

@ -144,14 +144,12 @@ export default function TeamInvitationBanner({
</div> </div>
<style jsx>{` <style jsx>{`
/* kontinuierlich nach rechts schieben */ /* Hintergrund-Schimmer (läuft permanent) */
@keyframes slide-x { @keyframes slide-x {
from { background-position-x: 0%; } from { background-position-x: 0%; }
to { background-position-x: 200%; } to { background-position-x: 200%; }
} }
.invitationGradient { .invitationGradient {
/* weicher, dezenter Verlauf */
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
90deg, 90deg,
rgba(16,168,54,0.20) 0%, rgba(16,168,54,0.20) 0%,
@ -160,9 +158,8 @@ export default function TeamInvitationBanner({
); );
background-size: 200% 100%; background-size: 200% 100%;
background-repeat: repeat-x; background-repeat: repeat-x;
animation: slide-x 3s linear infinite; animation: slide-x 6s linear infinite; /* etwas ruhiger */
} }
:global(.dark) .invitationGradient { :global(.dark) .invitationGradient {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
90deg, 90deg,
@ -172,8 +169,40 @@ export default function TeamInvitationBanner({
); );
} }
/* Shine-Sweep nur auf Hover */
@keyframes shine {
0% { transform: translateX(-120%) skewX(-20deg); opacity: 0; }
10% { opacity: .7; }
27% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
100% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
}
.shine {
position: absolute;
inset: 0;
}
.shine::before {
content: "";
position: absolute;
top: -25%;
bottom: -25%;
left: -20%;
width: 35%;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
filter: blur(2px);
opacity: 0;
transform: translateX(-120%) skewX(-20deg);
transition: opacity .2s;
}
/* nur wenn die Karte offen ist und gehovert wird */
:global(.group:hover) .shine::before {
animation: shine 3.8s ease-out infinite;
}
/* Respektiere Bewegungs-Präferenzen */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.invitationGradient { animation: none; } .invitationGradient { animation: none; }
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }
} }
`}</style> `}</style>
</div> </div>

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import TeamCardComponent from "../components/TeamCardComponent"; import TeamCardComponent from "../components/TeamCardComponent";
import Card from "../components/Card"; import Card from "../components/Card";
import LoadingSpinner from "../components/LoadingSpinner";
type TeamsResponse = { teams?: any[] } | undefined; type TeamsResponse = { teams?: any[] } | undefined;
type InvitesResponse = { invitations?: any[] } | undefined; type InvitesResponse = { invitations?: any[] } | undefined;
@ -57,7 +58,11 @@ export default function TeamPageClient() {
}, []); }, []);
if (loading) { if (loading) {
return <p>Lade Teams </p>; return (
<p>
<LoadingSpinner />
</p>
);
} }
return ( return (

View File

@ -346,7 +346,7 @@ const config = {
"value": "prisma-client-js" "value": "prisma-client-js"
}, },
"output": { "output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null "fromEnvVar": null
}, },
"config": { "config": {
@ -360,7 +360,7 @@ const config = {
} }
], ],
"previewFeatures": [], "previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {
@ -374,6 +374,7 @@ const config = {
"db" "db"
], ],
"activeProvider": "postgresql", "activeProvider": "postgresql",
"postinstall": false,
"inlineDatasources": { "inlineDatasources": {
"db": { "db": {
"url": { "url": {

View File

@ -347,7 +347,7 @@ const config = {
"value": "prisma-client-js" "value": "prisma-client-js"
}, },
"output": { "output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null "fromEnvVar": null
}, },
"config": { "config": {
@ -361,7 +361,7 @@ const config = {
} }
], ],
"previewFeatures": [], "previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {
@ -375,6 +375,7 @@ const config = {
"db" "db"
], ],
"activeProvider": "postgresql", "activeProvider": "postgresql",
"postinstall": false,
"inlineDatasources": { "inlineDatasources": {
"db": { "db": {
"url": { "url": {