feat: Ergebnis-Banner, Dev-Simulations-Panel & Spiele-Reset
- MatchCard: farbiger Ergebnis-Banner (Exakt/Tendenz/Falsch) ersetzt tipRow, passender Card-Glow je Ergebnis; Lucide-Icons (lucide-react) - MatchCard: Ändern-Button links, vertikal mittig zur Tipp-Box ausgerichtet - DevPanel: Simulationsmodus mit User-Switcher, Zeit- & Status-Presets - DevPanel: Reset-Section mit "Spiel zurücksetzen", "Alle Spiele" und "Tipps löschen" (3 Buttons, farblich differenziert) - Backend: dev.ts mit set-time, set-status, reset-tips, reset-match - reset-match stellt Original-Datum wieder her (original_utc_date Spalte) - set-time sichert Original-Datum per COALESCE beim ersten Aufruf - Supabase Migration: original_utc_date TIMESTAMPTZ zu matches hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import tipsRouter from './routes/tips';
|
|||||||
import leaderboardRouter from './routes/leaderboard';
|
import leaderboardRouter from './routes/leaderboard';
|
||||||
import adminRouter from './routes/admin';
|
import adminRouter from './routes/admin';
|
||||||
import profileRouter from './routes/profile';
|
import profileRouter from './routes/profile';
|
||||||
|
import devRouter from './routes/dev';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.PORT ?? '3001');
|
const PORT = parseInt(process.env.PORT ?? '3001');
|
||||||
@@ -118,6 +119,9 @@ app.use('/api/tips', tipsRouter);
|
|||||||
app.use('/api/leaderboard', leaderboardRouter);
|
app.use('/api/leaderboard', leaderboardRouter);
|
||||||
app.use('/api/admin', adminRouter);
|
app.use('/api/admin', adminRouter);
|
||||||
app.use('/api/profile', profileRouter);
|
app.use('/api/profile', profileRouter);
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
app.use('/api/dev', devRouter);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Frontend (React Build) – statisches Serving
|
// Frontend (React Build) – statisches Serving
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { query } from '../db/client';
|
||||||
|
import { logger } from '../services/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only Routes — werden in Production blockiert
|
||||||
|
* Erlaubt Daten-Manipulation für Testzwecke
|
||||||
|
*/
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Sicherheitscheck: nur in Development verfügbar
|
||||||
|
router.use((_req: Request, res: Response, next: Function) => {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/dev/match/:id/set-time
|
||||||
|
* Setzt das Datum eines Spiels auf einen bestimmten Zeitpunkt
|
||||||
|
* Body: { minutesFromNow: number } (negativ = Vergangenheit)
|
||||||
|
*/
|
||||||
|
router.post('/match/:id/set-time', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const matchId = parseInt(req.params.id);
|
||||||
|
const { minutesFromNow } = req.body as { minutesFromNow: number };
|
||||||
|
|
||||||
|
if (isNaN(matchId) || typeof minutesFromNow !== 'number') {
|
||||||
|
res.status(400).json({ error: 'matchId und minutesFromNow erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDate = new Date(Date.now() + minutesFromNow * 60 * 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Original-Datum beim ersten set-time sichern (nur wenn noch nicht gesetzt)
|
||||||
|
await query(
|
||||||
|
`UPDATE matches
|
||||||
|
SET original_utc_date = COALESCE(original_utc_date, utc_date),
|
||||||
|
utc_date = $1
|
||||||
|
WHERE id = $2`,
|
||||||
|
[newDate.toISOString(), matchId]
|
||||||
|
);
|
||||||
|
logger.info(`[DEV] Match ${matchId} utc_date gesetzt auf ${newDate.toISOString()}`);
|
||||||
|
res.json({ success: true, matchId, newDate: newDate.toISOString(), minutesFromNow });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/dev/match/:id/set-status
|
||||||
|
* Setzt den Status eines Spiels (z.B. FINISHED, IN_PLAY, TIMED)
|
||||||
|
* Body: { status: string, scoreHome?: number, scoreAway?: number }
|
||||||
|
*/
|
||||||
|
router.post('/match/:id/set-status', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const matchId = parseInt(req.params.id);
|
||||||
|
const { status, scoreHome, scoreAway } = req.body as {
|
||||||
|
status: string;
|
||||||
|
scoreHome?: number;
|
||||||
|
scoreAway?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALID_STATUSES = ['TIMED', 'SCHEDULED', 'IN_PLAY', 'PAUSED', 'FINISHED'];
|
||||||
|
if (!VALID_STATUSES.includes(status)) {
|
||||||
|
res.status(400).json({ error: `Status muss einer von: ${VALID_STATUSES.join(', ')} sein` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`UPDATE matches SET status = $1, score_home = $2, score_away = $3 WHERE id = $4`,
|
||||||
|
[status, scoreHome ?? null, scoreAway ?? null, matchId]
|
||||||
|
);
|
||||||
|
logger.info(`[DEV] Match ${matchId} Status → ${status}`);
|
||||||
|
res.json({ success: true, matchId, status, scoreHome, scoreAway });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/dev/reset-match
|
||||||
|
* Setzt ein einzelnes Spiel zurück: Status → TIMED, Score → null, null
|
||||||
|
* Body: { matchId?: number } — ohne matchId werden ALLE Spiele zurückgesetzt
|
||||||
|
*/
|
||||||
|
router.post('/reset-match', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { matchId } = req.body as { matchId?: number };
|
||||||
|
try {
|
||||||
|
if (matchId) {
|
||||||
|
await query(
|
||||||
|
`UPDATE matches
|
||||||
|
SET status = 'TIMED',
|
||||||
|
score_home = NULL,
|
||||||
|
score_away = NULL,
|
||||||
|
utc_date = COALESCE(original_utc_date, utc_date),
|
||||||
|
original_utc_date = NULL
|
||||||
|
WHERE id = $1`,
|
||||||
|
[matchId]
|
||||||
|
);
|
||||||
|
logger.info(`[DEV] Match ${matchId} zurückgesetzt auf TIMED + Original-Datum`);
|
||||||
|
res.json({ success: true, reset: 'single', matchId });
|
||||||
|
} else {
|
||||||
|
const result = await query<{ id: number }>(
|
||||||
|
`UPDATE matches
|
||||||
|
SET status = 'TIMED',
|
||||||
|
score_home = NULL,
|
||||||
|
score_away = NULL,
|
||||||
|
utc_date = COALESCE(original_utc_date, utc_date),
|
||||||
|
original_utc_date = NULL
|
||||||
|
WHERE status IN ('IN_PLAY','PAUSED','FINISHED')
|
||||||
|
OR original_utc_date IS NOT NULL
|
||||||
|
RETURNING id`
|
||||||
|
);
|
||||||
|
logger.info(`[DEV] ${result.length} Spiele zurückgesetzt`);
|
||||||
|
res.json({ success: true, reset: 'all', count: result.length });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/dev/reset
|
||||||
|
* Setzt alle manipulierten Spiele zurück (löscht Dev-Tipps, setzt Status zurück)
|
||||||
|
* Nur Spiele die durch Dev-User angelegt wurden
|
||||||
|
*/
|
||||||
|
router.post('/reset-tips', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { userId } = req.body as { userId?: string };
|
||||||
|
try {
|
||||||
|
const result = await query<{ count: string }>(
|
||||||
|
`DELETE FROM tips WHERE user_id = $1 RETURNING id`,
|
||||||
|
[userId ?? 'dev-user-001']
|
||||||
|
);
|
||||||
|
await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard');
|
||||||
|
res.json({ success: true, deletedTips: result.length });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "wm2026-tippspiel-frontend",
|
"name": "wm2026-tippspiel-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.24.0"
|
"react-router-dom": "^6.24.0"
|
||||||
@@ -1445,6 +1446,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.24.0"
|
"react-router-dom": "^6.24.0"
|
||||||
|
|||||||
@@ -39,6 +39,17 @@
|
|||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.devBadge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gold);
|
||||||
|
background: rgba(254,174,50,0.1);
|
||||||
|
border: 1px solid rgba(254,174,50,0.25);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+56
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Routes, Route, NavLink } from 'react-router-dom';
|
import { Routes, Route, NavLink } from 'react-router-dom';
|
||||||
import MatchesPage from './pages/MatchesPage';
|
import MatchesPage from './pages/MatchesPage';
|
||||||
import LeaderboardPage from './pages/LeaderboardPage';
|
import LeaderboardPage from './pages/LeaderboardPage';
|
||||||
@@ -5,7 +6,47 @@ import ProfilePage from './pages/ProfilePage';
|
|||||||
import AdminPage from './pages/AdminPage';
|
import AdminPage from './pages/AdminPage';
|
||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
|
const IS_DEV = import.meta.env.DEV;
|
||||||
|
|
||||||
|
// Lazy-load DevPanel nur in Development
|
||||||
|
let DevPanel: React.ComponentType<any> | null = null;
|
||||||
|
if (IS_DEV) {
|
||||||
|
// Dynamic import — kein Bundle-Impact in Production
|
||||||
|
import('./components/DevPanel').then(m => { DevPanel = m.default; });
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [devUser, setDevUser] = useState(1);
|
||||||
|
const [devMatches, setDevMatches] = useState<any[]>([]);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// DevUser als Query-Parameter im API-Fetch setzen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_DEV) return;
|
||||||
|
// Patch fetch für Dev-Mode: devUser Query-Param anhängen
|
||||||
|
const origFetch = window.fetch;
|
||||||
|
window._devUser = devUser;
|
||||||
|
window.fetch = (input, init) => {
|
||||||
|
if (typeof input === 'string' && input.startsWith('/api')) {
|
||||||
|
const url = new URL(input, window.location.origin);
|
||||||
|
url.searchParams.set('devUser', String(window._devUser ?? 1));
|
||||||
|
return origFetch(url.toString(), init);
|
||||||
|
}
|
||||||
|
return origFetch(input, init);
|
||||||
|
};
|
||||||
|
return () => { window.fetch = origFetch; };
|
||||||
|
}, [devUser]);
|
||||||
|
|
||||||
|
// Matches für DevPanel laden
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_DEV) return;
|
||||||
|
fetch('/api/matches').then(r => r.json()).then(d => setDevMatches(d.matches ?? [])).catch(() => {});
|
||||||
|
}, [refreshKey, devUser]);
|
||||||
|
|
||||||
|
function handleDevRefresh() {
|
||||||
|
setRefreshKey(k => k + 1);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
@@ -13,6 +54,9 @@ export default function App() {
|
|||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<span className={styles.logoFlag}>🏆</span>
|
<span className={styles.logoFlag}>🏆</span>
|
||||||
<span className={styles.logoText}>WM 2026 Tippspiel</span>
|
<span className={styles.logoText}>WM 2026 Tippspiel</span>
|
||||||
|
{IS_DEV && (
|
||||||
|
<span className={styles.devBadge}>DEV · User {devUser}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
<NavLink to="/" end className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
<NavLink to="/" end className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||||
@@ -33,12 +77,21 @@ export default function App() {
|
|||||||
|
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<MatchesPage />} />
|
<Route path="/" element={<MatchesPage key={refreshKey} devUser={devUser} />} />
|
||||||
<Route path="/rangliste" element={<LeaderboardPage />} />
|
<Route path="/rangliste" element={<LeaderboardPage key={refreshKey} />} />
|
||||||
<Route path="/profil" element={<ProfilePage />} />
|
<Route path="/profil" element={<ProfilePage key={refreshKey} />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{IS_DEV && DevPanel && (
|
||||||
|
<DevPanel
|
||||||
|
currentUser={devUser}
|
||||||
|
onUserChange={(u: number) => { setDevUser(u); setRefreshKey(k => k + 1); }}
|
||||||
|
matches={devMatches}
|
||||||
|
onRefresh={handleDevRefresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/* Dev Panel — floating bottom right */
|
||||||
|
.wrap {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px; /* über der Nav-Bar */
|
||||||
|
right: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(254,174,50,0.15);
|
||||||
|
border: 1px solid rgba(254,174,50,0.3);
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.toggleBtn:hover { background: rgba(254,174,50,0.25); }
|
||||||
|
.toggleLabel { font-size: 12px; }
|
||||||
|
|
||||||
|
/* Panel */
|
||||||
|
.panel {
|
||||||
|
width: 300px;
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid rgba(254,174,50,0.2);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(254,174,50,0.08);
|
||||||
|
border-bottom: 1px solid rgba(254,174,50,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitle {
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.sectionDisabled { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User buttons */
|
||||||
|
.userButtons { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
.userBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-high);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.userBtn:hover { border-color: rgba(75,183,248,0.3); }
|
||||||
|
.userBtnActive {
|
||||||
|
background: rgba(75,183,248,0.12);
|
||||||
|
border-color: rgba(75,183,248,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInitial {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-dim);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.userBtnActive .userInitial { background: rgba(75,183,248,0.2); }
|
||||||
|
|
||||||
|
.userName { font-size: 13px; font-weight: 600; color: var(--text-primary); flex: 1; }
|
||||||
|
.userRole { font-size: 10px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Select */
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-high);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.select:focus { border-color: var(--primary); }
|
||||||
|
|
||||||
|
/* Preset grid */
|
||||||
|
.presetGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presetBtn {
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-high);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.presetBtn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.presetBtn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.presetBtnLive { color: var(--error) !important; border-color: rgba(248,113,113,0.2) !important; }
|
||||||
|
.presetBtnDanger { color: #34D399 !important; border-color: rgba(52,211,153,0.2) !important; }
|
||||||
|
|
||||||
|
/* Reset */
|
||||||
|
.resetGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tipps-löschen nimmt volle Breite (dritte Zeile) */
|
||||||
|
.resetBtnTips {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetBtn {
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(75,183,248,0.08);
|
||||||
|
border: 1px solid rgba(75,183,248,0.18);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.resetBtn:hover:not(:disabled) { background: rgba(75,183,248,0.16); }
|
||||||
|
.resetBtn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* "Alle Spiele" — etwas warnender */
|
||||||
|
.resetBtnAll {
|
||||||
|
background: rgba(254,174,50,0.08);
|
||||||
|
border-color: rgba(254,174,50,0.2);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
.resetBtnAll:hover:not(:disabled) { background: rgba(254,174,50,0.16); }
|
||||||
|
|
||||||
|
/* Tipps löschen — rot */
|
||||||
|
.resetBtnTips {
|
||||||
|
background: rgba(248,113,113,0.08);
|
||||||
|
border-color: rgba(248,113,113,0.18);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
.resetBtnTips:hover:not(:disabled) { background: rgba(248,113,113,0.18); }
|
||||||
|
|
||||||
|
/* Log */
|
||||||
|
.log {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.logLine {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { api, Match } from '../api/client';
|
||||||
|
import styles from './DevPanel.module.css';
|
||||||
|
|
||||||
|
const DEV_USERS = [
|
||||||
|
{ id: 1, name: 'Ronny M.', role: 'Editor' },
|
||||||
|
{ id: 2, name: 'Max M.', role: 'Viewer' },
|
||||||
|
{ id: 3, name: 'Anna S.', role: 'Viewer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIME_PRESETS = [
|
||||||
|
{ label: 'In 2 Std.', minutes: 120 },
|
||||||
|
{ label: 'In 10 Min.', minutes: 10 },
|
||||||
|
{ label: 'Jetzt +1 Min.', minutes: 1 },
|
||||||
|
{ label: 'Läuft (−30)', minutes: -30 },
|
||||||
|
{ label: 'Beendet (−120)', minutes: -120 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_PRESETS = [
|
||||||
|
{ label: 'TIMED', status: 'TIMED', scoreHome: null, scoreAway: null },
|
||||||
|
{ label: 'LIVE', status: 'IN_PLAY', scoreHome: 0, scoreAway: 0 },
|
||||||
|
{ label: 'Pause', status: 'PAUSED', scoreHome: 1, scoreAway: 0 },
|
||||||
|
{ label: '2:1 Fertig', status: 'FINISHED', scoreHome: 2, scoreAway: 1 },
|
||||||
|
{ label: '0:0 Fertig', status: 'FINISHED', scoreHome: 0, scoreAway: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentUser: number;
|
||||||
|
onUserChange: (userId: number) => void;
|
||||||
|
matches: Match[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevPanel({ currentUser, onUserChange, matches, onRefresh }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedMatch, setSelectedMatch] = useState<number | ''>('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [log, setLog] = useState<string[]>([]);
|
||||||
|
|
||||||
|
function addLog(msg: string) {
|
||||||
|
setLog(prev => [`${new Date().toLocaleTimeString('de-DE')} ${msg}`, ...prev].slice(0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTime(minutes: number) {
|
||||||
|
if (!selectedMatch) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/dev/match/${selectedMatch}/set-time`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ minutesFromNow: minutes }),
|
||||||
|
});
|
||||||
|
addLog(`✓ Spiel #${selectedMatch}: Zeit → ${minutes > 0 ? `+${minutes}` : minutes} Min.`);
|
||||||
|
onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
addLog(`✗ Fehler: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStatus(status: string, scoreHome: number | null, scoreAway: number | null) {
|
||||||
|
if (!selectedMatch) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/dev/match/${selectedMatch}/set-status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status, scoreHome, scoreAway }),
|
||||||
|
});
|
||||||
|
addLog(`✓ Spiel #${selectedMatch}: Status → ${status}`);
|
||||||
|
onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
addLog(`✗ Fehler: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTips() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/dev/reset-tips', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: `dev-user-00${currentUser}` }),
|
||||||
|
});
|
||||||
|
addLog(`✓ Tipps von User ${currentUser} gelöscht`);
|
||||||
|
onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
addLog(`✗ Fehler: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetMatch(all: boolean) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const body = all ? {} : { matchId: selectedMatch };
|
||||||
|
const res = await fetch('/api/dev/reset-match', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json() as { count?: number; matchId?: number };
|
||||||
|
if (all) {
|
||||||
|
addLog(`✓ ${data.count ?? 0} Spiele zurückgesetzt (TIMED)`);
|
||||||
|
} else {
|
||||||
|
addLog(`✓ Spiel #${selectedMatch} zurückgesetzt (TIMED)`);
|
||||||
|
}
|
||||||
|
onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
addLog(`✗ Fehler: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur erste 20 Spiele zur Auswahl anbieten
|
||||||
|
const selectableMatches = matches.slice(0, 20);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{/* Toggle Button */}
|
||||||
|
<button className={styles.toggleBtn} onClick={() => setOpen(o => !o)}>
|
||||||
|
{open ? '✕' : '🧪'} {!open && <span className={styles.toggleLabel}>Dev</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<span className={styles.panelTitle}>🧪 Simulations-Modus</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Switcher */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<div className={styles.sectionLabel}>Aktiver User</div>
|
||||||
|
<div className={styles.userButtons}>
|
||||||
|
{DEV_USERS.map(u => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
className={`${styles.userBtn} ${currentUser === u.id ? styles.userBtnActive : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onUserChange(u.id);
|
||||||
|
addLog(`→ Wechsel zu User ${u.id}: ${u.name}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.userInitial}>{u.name.charAt(0)}</span>
|
||||||
|
<span className={styles.userName}>{u.name}</span>
|
||||||
|
<span className={styles.userRole}>{u.role}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Match Selector */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<div className={styles.sectionLabel}>Spiel auswählen</div>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={selectedMatch}
|
||||||
|
onChange={e => setSelectedMatch(e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
>
|
||||||
|
<option value="">— Spiel wählen —</option>
|
||||||
|
{selectableMatches.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
#{m.id} {m.homeTeam.shortName} vs {m.awayTeam.shortName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Zeit-Manipulation */}
|
||||||
|
<section className={`${styles.section} ${!selectedMatch ? styles.sectionDisabled : ''}`}>
|
||||||
|
<div className={styles.sectionLabel}>Anstoßzeit setzen</div>
|
||||||
|
<div className={styles.presetGrid}>
|
||||||
|
{TIME_PRESETS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
className={styles.presetBtn}
|
||||||
|
onClick={() => applyTime(p.minutes)}
|
||||||
|
disabled={!selectedMatch || busy}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Status-Manipulation */}
|
||||||
|
<section className={`${styles.section} ${!selectedMatch ? styles.sectionDisabled : ''}`}>
|
||||||
|
<div className={styles.sectionLabel}>Status setzen</div>
|
||||||
|
<div className={styles.presetGrid}>
|
||||||
|
{STATUS_PRESETS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
className={`${styles.presetBtn} ${p.status === 'FINISHED' ? styles.presetBtnDanger : p.status === 'IN_PLAY' ? styles.presetBtnLive : ''}`}
|
||||||
|
onClick={() => applyStatus(p.status, p.scoreHome, p.scoreAway)}
|
||||||
|
disabled={!selectedMatch || busy}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Reset */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<div className={styles.sectionLabel}>Zurücksetzen</div>
|
||||||
|
<div className={styles.resetGrid}>
|
||||||
|
<button
|
||||||
|
className={styles.resetBtn}
|
||||||
|
onClick={() => resetMatch(false)}
|
||||||
|
disabled={!selectedMatch || busy}
|
||||||
|
title="Ausgewähltes Spiel auf TIMED zurücksetzen"
|
||||||
|
>
|
||||||
|
↺ Spiel zurücksetzen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.resetBtn} ${styles.resetBtnAll}`}
|
||||||
|
onClick={() => resetMatch(true)}
|
||||||
|
disabled={busy}
|
||||||
|
title="Alle laufenden/beendeten Spiele zurücksetzen"
|
||||||
|
>
|
||||||
|
↺ Alle Spiele
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.resetBtn} ${styles.resetBtnTips}`}
|
||||||
|
onClick={resetTips}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
🗑 Tipps löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Log */}
|
||||||
|
{log.length > 0 && (
|
||||||
|
<div className={styles.log}>
|
||||||
|
{log.map((l, i) => <div key={i} className={styles.logLine}>{l}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -183,6 +183,13 @@
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wenn tipRow zum Banner wird */
|
||||||
|
.tipRow.resultBanner {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
.tipBtn {
|
.tipBtn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 260px;
|
max-width: 260px;
|
||||||
@@ -191,20 +198,36 @@
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Existing tip display */
|
/* Existing tip display — 3-column grid so center stays centered */
|
||||||
.tipDisplay {
|
.tipDisplay {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
width: 100%;
|
||||||
flex-wrap: wrap;
|
gap: 8px;
|
||||||
justify-content: center;
|
}
|
||||||
|
|
||||||
|
.tipLeft { display: flex; justify-content: flex-start; align-items: center; }
|
||||||
|
.tipRight { display: flex; justify-content: flex-end; align-items: center; }
|
||||||
|
|
||||||
|
/* Banner-Variante: Icon + Label als Zeile */
|
||||||
|
.bannerLeft {
|
||||||
|
gap: 6px;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipCenter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipLabel {
|
.tipLabel {
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipScore {
|
.tipScore {
|
||||||
@@ -213,21 +236,90 @@
|
|||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
background: var(--primary-dim);
|
background: var(--primary-dim);
|
||||||
padding: 3px 12px;
|
padding: 3px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(75,183,248,0.15);
|
border: 1px solid rgba(75,183,248,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.points {
|
/* ── Ergebnis-Banner ──────────────────────────────────────────── */
|
||||||
font-size: 12px;
|
.resultBanner {
|
||||||
font-weight: 700;
|
margin: 0 -24px -20px !important;
|
||||||
padding: 4px 10px;
|
padding: 10px 24px !important;
|
||||||
border-radius: 20px;
|
border-bottom-left-radius: 16px;
|
||||||
|
border-bottom-right-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.exact { background: rgba(52,211,153,0.12); color: var(--success); border: 1px solid rgba(52,211,153,0.2); }
|
.resultIcon {
|
||||||
.tendency { background: rgba(75,183,248,0.12); color: var(--primary); border: 1px solid rgba(75,183,248,0.2); }
|
display: flex;
|
||||||
.wrong { background: rgba(248,113,113,0.10); color: var(--error); border: 1px solid rgba(248,113,113,0.15); }
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultLabel {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tipScore-Variante im Banner: dunkler Hintergrund statt Primary-Blau */
|
||||||
|
.tipScoreBanner {
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 17px;
|
||||||
|
padding: 3px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultPoints {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Farb-Varianten Banner */
|
||||||
|
.exact {
|
||||||
|
background: linear-gradient(90deg, rgba(52,211,153,0.18) 0%, rgba(52,211,153,0.08) 100%);
|
||||||
|
color: #4ade80;
|
||||||
|
border-top: 1px solid rgba(52,211,153,0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tendency {
|
||||||
|
background: linear-gradient(90deg, rgba(75,183,248,0.18) 0%, rgba(75,183,248,0.08) 100%);
|
||||||
|
color: var(--primary);
|
||||||
|
border-top: 1px solid rgba(75,183,248,0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrong {
|
||||||
|
background: linear-gradient(90deg, rgba(248,113,113,0.15) 0%, rgba(248,113,113,0.06) 100%);
|
||||||
|
color: var(--error);
|
||||||
|
border-top: 1px solid rgba(248,113,113,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card-Glow je Ergebnis */
|
||||||
|
.glowExact {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(52,211,153,0.18),
|
||||||
|
0 10px 30px rgba(52,211,153,0.07),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glowTendency {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(75,183,248,0.18),
|
||||||
|
0 10px 30px rgba(75,183,248,0.07),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glowWrong {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(248,113,113,0.15),
|
||||||
|
0 10px 30px rgba(248,113,113,0.05),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.editBtn {
|
.editBtn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Check, TrendingUp, X } from 'lucide-react';
|
||||||
import { Match } from '../api/client';
|
import { Match } from '../api/client';
|
||||||
import styles from './MatchCard.module.css';
|
import styles from './MatchCard.module.css';
|
||||||
|
|
||||||
@@ -47,9 +48,19 @@ export default function MatchCard({ match, onTip }: Props) {
|
|||||||
const isFinished = match.status === 'FINISHED';
|
const isFinished = match.status === 'FINISHED';
|
||||||
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
|
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
|
||||||
const hasTip = !!match.userTip;
|
const hasTip = !!match.userTip;
|
||||||
|
const points = match.userTip?.points ?? null;
|
||||||
|
const resultClass =
|
||||||
|
points === 3 ? styles.exact :
|
||||||
|
points === 1 ? styles.tendency :
|
||||||
|
(points === 0 && isFinished) ? styles.wrong : '';
|
||||||
|
|
||||||
|
const glowClass =
|
||||||
|
isFinished && points === 3 ? styles.glowExact :
|
||||||
|
isFinished && points === 1 ? styles.glowTendency :
|
||||||
|
isFinished && points === 0 ? styles.glowWrong : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`card ${styles.card} ${isLive ? styles.live : ''}`}>
|
<div className={`card ${styles.card} ${isLive ? styles.live : ''} ${glowClass}`}>
|
||||||
|
|
||||||
{/* Top row: Status / Kickoff / Badges */}
|
{/* Top row: Status / Kickoff / Badges */}
|
||||||
<div className={styles.topRow}>
|
<div className={styles.topRow}>
|
||||||
@@ -92,27 +103,56 @@ export default function MatchCard({ match, onTip }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tipp area */}
|
{/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */}
|
||||||
<div className={styles.tipRow}>
|
<div className={`${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`}>
|
||||||
{hasTip ? (
|
{hasTip ? (
|
||||||
<div className={styles.tipDisplay}>
|
points !== null ? (
|
||||||
<span className={styles.tipLabel}>Dein Tipp</span>
|
/* ── Auswertungs-Banner ── */
|
||||||
<span className={styles.tipScore}>
|
<div className={styles.tipDisplay}>
|
||||||
{match.userTip!.home} : {match.userTip!.away}
|
{/* Links: Icon + Ergebnis-Label nebeneinander, zentriert zur Tippbox */}
|
||||||
</span>
|
<div className={`${styles.tipLeft} ${styles.bannerLeft}`}>
|
||||||
{match.userTip!.points !== null && (
|
<span className={styles.resultIcon}>
|
||||||
<span className={`${styles.points} ${
|
{points === 3 ? <Check size={14} strokeWidth={3} /> :
|
||||||
match.userTip!.points === 3 ? styles.exact :
|
points === 1 ? <TrendingUp size={14} strokeWidth={2.5} /> :
|
||||||
match.userTip!.points === 1 ? styles.tendency : styles.wrong
|
<X size={14} strokeWidth={3} />}
|
||||||
}`}>
|
</span>
|
||||||
{match.userTip!.points === 3 ? '🎯 3 Punkte' :
|
<span className={styles.resultLabel}>
|
||||||
match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'}
|
{points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
{match.tippable && (
|
|
||||||
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
|
{/* Mitte: nur Score, kein Label */}
|
||||||
)}
|
<div className={styles.tipCenter}>
|
||||||
</div>
|
<span className={styles.tipScoreBanner}>
|
||||||
|
{match.userTip!.home} : {match.userTip!.away}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rechts: Punkte */}
|
||||||
|
<div className={styles.tipRight}>
|
||||||
|
<span className={styles.resultPoints}>
|
||||||
|
{points === 0 ? '0 Pkt.' : `+${points} Pkt.`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Tipp vorhanden, noch nicht ausgewertet ── */
|
||||||
|
<div className={styles.tipDisplay}>
|
||||||
|
<div className={styles.tipLeft}>
|
||||||
|
{match.tippable && (
|
||||||
|
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.tipCenter}>
|
||||||
|
{/* Label nur zeigen wenn kein Ändern-Button da ist, sonst fluchtet der Button nicht */}
|
||||||
|
{!match.tippable && <span className={styles.tipLabel}>DEIN TIPP</span>}
|
||||||
|
<span className={styles.tipScore}>
|
||||||
|
{match.userTip!.home} : {match.userTip!.away}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tipRight} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : match.tippable ? (
|
) : match.tippable ? (
|
||||||
<button className={`btn-primary ${styles.tipBtn}`} onClick={onTip}>
|
<button className={`btn-primary ${styles.tipBtn}`} onClick={onTip}>
|
||||||
Tipp abgeben
|
Tipp abgeben
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
interface Window {
|
||||||
|
_devUser?: number;
|
||||||
|
}
|
||||||
@@ -52,6 +52,34 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gruppen-Filter (zweite Ebene) */
|
||||||
|
.groupFilters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupFilter, .groupFilterActive {
|
||||||
|
padding: 5px 13px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.groupFilter:hover { border-color: rgba(75,183,248,0.3); color: var(--text-secondary); }
|
||||||
|
.groupFilterActive {
|
||||||
|
background: rgba(75,183,248,0.12);
|
||||||
|
border-color: rgba(75,183,248,0.25);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.dayGroup { display: flex; flex-direction: column; gap: 12px; }
|
.dayGroup { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
.dayHeader {
|
.dayHeader {
|
||||||
@@ -62,6 +90,18 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
box-shadow: 0 0 6px rgba(75,183,248,0.6);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matchList { display: flex; flex-direction: column; gap: 10px; }
|
.matchList { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { api, Match } from '../api/client';
|
import { api, Match } from '../api/client';
|
||||||
import MatchCard from '../components/MatchCard';
|
import MatchCard from '../components/MatchCard';
|
||||||
import TipModal from '../components/TipModal';
|
import TipModal from '../components/TipModal';
|
||||||
@@ -7,37 +7,54 @@ import styles from './MatchesPage.module.css';
|
|||||||
const STAGES: { key: string; label: string }[] = [
|
const STAGES: { key: string; label: string }[] = [
|
||||||
{ key: '', label: 'Alle' },
|
{ key: '', label: 'Alle' },
|
||||||
{ key: 'GROUP_STAGE', label: 'Gruppenphase' },
|
{ key: 'GROUP_STAGE', label: 'Gruppenphase' },
|
||||||
{ key: 'ROUND_OF_32', label: 'Runde der 32' },
|
{ key: 'LAST_32', label: 'Runde der 32' },
|
||||||
{ key: 'ROUND_OF_16', label: 'Achtelfinale' },
|
{ key: 'LAST_16', label: 'Achtelfinale' },
|
||||||
{ key: 'QUARTER_FINALS', label: 'Viertelfinale' },
|
{ key: 'QUARTER_FINALS', label: 'Viertelfinale' },
|
||||||
{ key: 'SEMI_FINALS', label: 'Halbfinale' },
|
{ key: 'SEMI_FINALS', label: 'Halbfinale' },
|
||||||
|
{ key: 'THIRD_PLACE', label: 'Platz 3' },
|
||||||
{ key: 'FINAL', label: 'Finale' },
|
{ key: 'FINAL', label: 'Finale' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const GROUPS = ['A','B','C','D','E','F','G','H','I','J','K','L'];
|
||||||
|
|
||||||
export default function MatchesPage() {
|
export default function MatchesPage() {
|
||||||
const [matches, setMatches] = useState<Match[]>([]);
|
const [allMatches, setAllMatches] = useState<Match[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [stage, setStage] = useState('');
|
const [stage, setStage] = useState('');
|
||||||
|
const [group, setGroup] = useState('');
|
||||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||||
|
const todayRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const hasScrolled = useRef(false);
|
||||||
|
|
||||||
|
// Alle Spiele einmalig laden — Filterung passiert im Frontend
|
||||||
const loadMatches = useCallback(async () => {
|
const loadMatches = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.getMatches(stage ? { stage } : undefined);
|
const res = await api.getMatches();
|
||||||
setMatches(res.matches);
|
setAllMatches(res.matches);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [stage]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { loadMatches(); }, [loadMatches]);
|
useEffect(() => { loadMatches(); }, [loadMatches]);
|
||||||
|
|
||||||
|
// Auto-Scroll zum heutigen Datum nach dem ersten Laden
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !hasScrolled.current && todayRef.current && !stage) {
|
||||||
|
hasScrolled.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
todayRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [loading, stage]);
|
||||||
|
|
||||||
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
|
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
|
||||||
setMatches(prev => prev.map(m =>
|
setAllMatches(prev => prev.map(m =>
|
||||||
m.id === matchId
|
m.id === matchId
|
||||||
? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
|
? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
|
||||||
: m
|
: m
|
||||||
@@ -45,19 +62,40 @@ export default function MatchesPage() {
|
|||||||
setSelectedMatch(null);
|
setSelectedMatch(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spiele nach Datum gruppieren
|
// Gefilterte Matches für aktuelle Ansicht
|
||||||
|
const matches = allMatches
|
||||||
|
.filter(m => !stage || m.stage === stage)
|
||||||
|
.filter(m => !group || m.group === `GROUP_${group}`);
|
||||||
|
|
||||||
|
// Stats immer über alle Spiele (nicht gefiltert)
|
||||||
|
const tipped = allMatches.filter(m => m.userTip).length;
|
||||||
|
const tippable = allMatches.filter(m => m.tippable && !m.userTip).length;
|
||||||
|
|
||||||
|
// Verfügbare Gruppen für den aktiven Stage-Filter
|
||||||
|
const availableGroups = stage === 'GROUP_STAGE'
|
||||||
|
? [...new Set(allMatches
|
||||||
|
.filter(m => m.stage === 'GROUP_STAGE' && m.group)
|
||||||
|
.map(m => m.group!.replace('GROUP_', ''))
|
||||||
|
)].sort()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Spiele nach Datum gruppieren (Gruppenphase) oder nach Gruppe (wenn Gruppe gewählt)
|
||||||
const grouped = matches.reduce<Record<string, Match[]>>((acc, m) => {
|
const grouped = matches.reduce<Record<string, Match[]>>((acc, m) => {
|
||||||
const day = new Date(m.utcDate).toLocaleDateString('de-DE', {
|
let key: string;
|
||||||
weekday: 'long', day: 'numeric', month: 'long'
|
if (stage === 'GROUP_STAGE' && !group) {
|
||||||
});
|
// Gruppenphase ohne Gruppen-Filter: nach Gruppe gruppieren
|
||||||
if (!acc[day]) acc[day] = [];
|
key = `Gruppe ${m.group?.replace('GROUP_', '') ?? '?'}`;
|
||||||
acc[day].push(m);
|
} else {
|
||||||
|
// Sonst nach Datum
|
||||||
|
key = new Date(m.utcDate).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(m);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const tipped = matches.filter(m => m.userTip).length;
|
|
||||||
const tippable = matches.filter(m => m.tippable).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
{/* Header Stats */}
|
{/* Header Stats */}
|
||||||
@@ -82,13 +120,34 @@ export default function MatchesPage() {
|
|||||||
<button
|
<button
|
||||||
key={s.key}
|
key={s.key}
|
||||||
className={stage === s.key ? styles.filterActive : styles.filter}
|
className={stage === s.key ? styles.filterActive : styles.filter}
|
||||||
onClick={() => setStage(s.key)}
|
onClick={() => { setStage(s.key); setGroup(''); }}
|
||||||
>
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gruppen-Filter (nur bei Gruppenphase) */}
|
||||||
|
{availableGroups.length > 0 && (
|
||||||
|
<div className={styles.groupFilters}>
|
||||||
|
<button
|
||||||
|
className={group === '' ? styles.groupFilterActive : styles.groupFilter}
|
||||||
|
onClick={() => setGroup('')}
|
||||||
|
>
|
||||||
|
Alle Gruppen
|
||||||
|
</button>
|
||||||
|
{availableGroups.map(g => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
className={group === g ? styles.groupFilterActive : styles.groupFilter}
|
||||||
|
onClick={() => setGroup(g)}
|
||||||
|
>
|
||||||
|
Gruppe {g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className={styles.loadingState}>
|
<div className={styles.loadingState}>
|
||||||
@@ -114,20 +173,56 @@ export default function MatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && Object.entries(grouped).map(([day, dayMatches]) => (
|
{!loading && !error && (() => {
|
||||||
<div key={day} className={styles.dayGroup}>
|
const entries = Object.entries(grouped);
|
||||||
<h2 className={styles.dayHeader}>{day}</h2>
|
if (stage === 'GROUP_STAGE' && !group) {
|
||||||
<div className={styles.matchList}>
|
entries.sort(([a], [b]) => a.localeCompare(b));
|
||||||
{dayMatches.map(match => (
|
}
|
||||||
<MatchCard
|
|
||||||
key={match.id}
|
const todayLabel = new Date().toLocaleDateString('de-DE', {
|
||||||
match={match}
|
weekday: 'long', day: 'numeric', month: 'long'
|
||||||
onTip={() => setSelectedMatch(match)}
|
});
|
||||||
/>
|
|
||||||
))}
|
// "Nächster bevorstehender Tag" als Fallback wenn kein heutiger Spieltag
|
||||||
</div>
|
let scrollTarget: string | null = null;
|
||||||
</div>
|
if (!stage) {
|
||||||
))}
|
if (entries.find(([l]) => l === todayLabel)) {
|
||||||
|
scrollTarget = todayLabel;
|
||||||
|
} else {
|
||||||
|
const now = Date.now();
|
||||||
|
const future = entries.find(([, ms]) => new Date(ms[0].utcDate).getTime() > now);
|
||||||
|
scrollTarget = future?.[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map(([label, labelMatches]) => {
|
||||||
|
const isScrollTarget = label === scrollTarget;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className={styles.dayGroup}
|
||||||
|
ref={isScrollTarget ? todayRef : null}
|
||||||
|
>
|
||||||
|
<h2 className={styles.dayHeader}>
|
||||||
|
{label === todayLabel ? (
|
||||||
|
<><span className={styles.todayDot} />Heute — {label}</>
|
||||||
|
) : label}
|
||||||
|
</h2>
|
||||||
|
<div className={styles.matchList}>
|
||||||
|
{labelMatches
|
||||||
|
.sort((a, b) => new Date(a.utcDate).getTime() - new Date(b.utcDate).getTime())
|
||||||
|
.map(match => (
|
||||||
|
<MatchCard
|
||||||
|
key={match.id}
|
||||||
|
match={match}
|
||||||
|
onTip={() => setSelectedMatch(match)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
{selectedMatch && (
|
{selectedMatch && (
|
||||||
<TipModal
|
<TipModal
|
||||||
|
|||||||
Reference in New Issue
Block a user