From ada262581357030524dc9f743ffcb20895bae395 Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 26 Jun 2026 11:30:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Hex=20Wars=20-=20Zoom,=20Zug-Best=C3=A4?= =?UTF-8?q?tigung,=20Design-Fix,=20Admin-L=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/gebietseroberung/routes.js | 10 + frontend/src/tools/gebietseroberung.jsx | 397 ++++++++++++------- 2 files changed, 254 insertions(+), 153 deletions(-) diff --git a/backend/src/tools/gebietseroberung/routes.js b/backend/src/tools/gebietseroberung/routes.js index 6c76e8c..8ade5e5 100644 --- a/backend/src/tools/gebietseroberung/routes.js +++ b/backend/src/tools/gebietseroberung/routes.js @@ -251,3 +251,13 @@ router.post('/:id/resign', authenticate, (req, res) => { }); module.exports = router; + +// ── Spiel löschen (nur Admin, nur beendete/aufgegebene) ─────────────────────── +router.delete('/:id', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + if (game.status !== 'finished') return res.status(400).json({ error: 'Nur beendete Spiele können gelöscht werden' }); + db.prepare('DELETE FROM geo_games WHERE id=?').run(req.params.id); + res.json({ ok: true }); +}); diff --git a/frontend/src/tools/gebietseroberung.jsx b/frontend/src/tools/gebietseroberung.jsx index 7f3f3c2..3e9cfac 100644 --- a/frontend/src/tools/gebietseroberung.jsx +++ b/frontend/src/tools/gebietseroberung.jsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { api, S } from '../lib.js'; -const GRID = 20; +const GRID = 20; const MY_COLOR = '#4ecdc4'; const OPP_COLOR = '#ff6b9d'; +const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]]; function getMyId() { try { @@ -12,30 +13,39 @@ function getMyId() { return JSON.parse(atob(token.split('.')[1])).id || null; } catch { return null; } } +function getMyRole() { + try { + const token = localStorage.getItem('sk_token'); + if (!token) return null; + return JSON.parse(atob(token.split('.')[1])).role || null; + } catch { return null; } +} // ── Spielerklärung ──────────────────────────────────────────────────────────── function HelpModal({ onClose }) { return ( -
+
-
- ⬡ Wie funktioniert's? +
+ ⬡ SPIELREGELN
-
-

Dein Ziel: Am Ende mehr Felder als dein Gegner besitzen.

-

So geht's:
- Jeder Spieler startet in einer Ecke des 20×20 Rasters. Abwechselnd wählst du eine - neutrale (graue) Zelle, - die an dein Gebiet grenzt — auch diagonal.

+
+

Ziel: Am Ende mehr Felder als dein Gegner besitzen.

+

Ablauf:
+ Beide starten in einer Ecke. Abwechselnd wählst du eine + graue Zelle die an dein Gebiet grenzt + (auch diagonal). Bestätige deinen Zug bevor er gespeichert wird. +

Taktik:
- Breite dich aus — aber mauere deinen Gegner - ein, bevor er dich einsperrt!

+ Expand schnell — aber mauere deinen Gegner ein bevor er dich einsperrt! +

Ende:
- Wenn niemand mehr expandieren kann, gewinnt wer mehr Felder hat. - Du kriegst eine Pushover-Benachrichtigung wenn du dran bist.

-
- 💡 Leuchtende Felder sind anklickbar — alle 8 Richtungen (inkl. Diagonal). + Wenn niemand mehr ziehen kann, gewinnt wer mehr Felder hat. + Du bekommst eine Pushover-Benachrichtigung wenn du dran bist. +

+
+ 📱 Auf dem Handy: Pinch zum Zoomen, dann Zug antippen und bestätigen.
@@ -64,14 +74,14 @@ function NewGameModal({ onClose, onCreated, toast }) { }; return ( -
+
-
- Neues Spiel +
+ NEUES SPIEL
- - setOppId(e.target.value)} style={{ ...S.inp, marginBottom:18 }}> {users.map(u => )} @@ -89,31 +99,26 @@ function NewGameModal({ onClose, onCreated, toast }) { // ── Topliste ────────────────────────────────────────────────────────────────── function Leaderboard() { const [rows, setRows] = useState(null); - - useEffect(() => { - api('/tools/gebietseroberung/leaderboard').then(setRows).catch(() => setRows([])); - }, []); - - if (rows === null) return
Lade…
; - + useEffect(() => { api('/tools/gebietseroberung/leaderboard').then(setRows).catch(() => setRows([])); }, []); + if (rows === null) return null; const medals = ['🥇','🥈','🥉']; - return ( -
+
TOPLISTE — SIEGE
{rows.length === 0 - ?
Noch keine abgeschlossenen Spiele.
+ ?
+ Noch keine abgeschlossenen Spiele. +
: rows.map((r, i) => (
- {medals[i] || `${i+1}.`} + {medals[i] || `${i+1}.`} {r.username} - {r.wins} + {r.wins} Siege
)) @@ -122,10 +127,11 @@ function Leaderboard() { ); } -// ── Spielfeld ───────────────────────────────────────────────────────────────── -function GameBoard({ game, myId, onMove }) { - const [hover, setHover] = useState(null); - const [moving, setMoving] = useState(false); +// ── Spielfeld mit Zoom + Zug-Bestätigung ────────────────────────────────────── +function GameBoard({ game, myId, onMove, toast }) { + const [pending, setPending] = useState(null); // { row, col } — gewählter aber noch nicht bestätigter Zug + const [moving, setMoving] = useState(false); + const containerRef = useRef(null); const grid = JSON.parse(game.grid); const isMyTurn = game.current_turn === myId; @@ -134,18 +140,28 @@ function GameBoard({ game, myId, onMove }) { const oppColor = game.owner_id === myId ? OPP_COLOR : MY_COLOR; const oppName = game.owner_id === myId ? game.opp_name : game.owner_name; - // Diagonal erlaubt: alle 8 Richtungen - const isAdj = (r, c) => [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]].some(([dr,dc]) => { + const isAdj = (r, c) => DIRS8.some(([dr,dc]) => { const nr = r+dr, nc = c+dc; return nr>=0 && nr=0 && nc isMyTurn && game.status === 'active' && grid[r][c] === 0 && isAdj(r, c); - const handleClick = async (r, c) => { + const handleCellClick = (r, c) => { if (!canClick(r, c) || moving) return; + // Gleiche Zelle nochmal → Abwählen + if (pending?.row === r && pending?.col === c) { setPending(null); return; } + setPending({ row: r, col: c }); + }; + + const confirmMove = async () => { + if (!pending || moving) return; setMoving(true); - try { await onMove(r, c); } finally { setMoving(false); } + try { + await onMove(pending.row, pending.col); + setPending(null); + } catch(e) { + toast?.(e.message || 'Fehler', 'error'); + } finally { setMoving(false); } }; const myCount = grid.flat().filter(v => v === myId).length; @@ -154,152 +170,209 @@ function GameBoard({ game, myId, onMove }) { let banner = null; if (game.status === 'finished') { - if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' }; - else if (game.winner_id === myId) banner = { text:'Du hast gewonnen! 🎉', color:MY_COLOR }; - else banner = { text:'Du hast verloren.', color:OPP_COLOR }; + if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' }; + else if (game.winner_id === myId) banner = { text:'Du hast gewonnen! 🎉', color:MY_COLOR }; + else banner = { text:'Du hast verloren.', color:OPP_COLOR }; } - const vw = Math.min(window.innerWidth, 520); - const cellSize = Math.floor((vw - 40) / GRID); + // Zellgröße: Grid passt ins Viewport, aber per CSS transform zoom möglich + const vw = Math.min(window.innerWidth, 600); + const cellSize = Math.max(14, Math.floor((vw - 32) / GRID)); return (
-
-
-
DU
-
{myCount}
+ {/* Scoreboard */} +
+
+
DU
+
{myCount}
+
Felder
-
- {neutral}
frei +
+
vs
+
{neutral} frei
-
-
{oppName}
-
{oppCount}
+
+
{oppName?.toUpperCase()}
+
{oppCount}
+
Felder
+ {/* Gewinner-Banner */} {banner && (
{banner.text}
)} + {/* Zug-Status + Bestätigungs-Bar */} {game.status === 'active' && ( -
- {isMyTurn ? (moving ? 'Zug wird gespeichert…' : '▶ Dein Zug') : `⏳ ${oppName} ist dran…`} +
+ {!isMyTurn && ( +
+ ⏳ {oppName} ist dran… +
+ )} + {isMyTurn && !pending && ( +
+ ▶ Wähle ein Feld +
+ )} + {isMyTurn && pending && ( +
+ + ✦ Zeile {pending.row + 1}, Spalte {pending.col + 1} — Zug bestätigen? + + + +
+ )}
)} -
- {grid.map((row, r) => row.map((cell, c) => { - const key = `${r}-${c}`; - const clic = canClick(r, c); - const hov = hover === key && clic; - let bg = 'rgba(255,255,255,0.05)'; - if (cell === myId) bg = `${myColor}55`; - if (cell === opponent) bg = `${oppColor}55`; - if (clic && !hov) bg = `${myColor}22`; - if (hov) bg = `${myColor}66`; - return ( -
handleClick(r, c)} - onMouseEnter={() => setHover(key)} - onMouseLeave={() => setHover(null)} - style={{ - width:cellSize, height:cellSize, - background:bg, borderRadius:1, - cursor: clic ? 'pointer' : 'default', - transition:'background 0.08s', - border: clic ? `1px solid ${myColor}55` : '1px solid transparent', - boxSizing:'border-box', - }} - /> - ); - }))} + {/* Grid — overflow:auto + touch-action:pinch-zoom für Handy-Zoom */} +
+
+ {grid.map((row, r) => row.map((cell, c) => { + const clic = canClick(r, c); + const isPending = pending?.row === r && pending?.col === c; + let bg = 'rgba(255,255,255,0.05)'; + if (cell === myId) bg = `${myColor}55`; + if (cell === opponent) bg = `${oppColor}55`; + if (clic) bg = `${myColor}20`; + if (isPending) bg = myColor; + return ( +
handleCellClick(r, c)} + style={{ + width:cellSize, height:cellSize, + background:bg, + borderRadius:1, + cursor: clic ? 'pointer' : 'default', + transition:'background 0.08s', + border: isPending ? `2px solid ${myColor}` : clic ? `1px solid ${myColor}44` : '1px solid transparent', + boxSizing:'border-box', + }} + /> + ); + }))} +
+
+
+ 📱 Pinch zum Zoomen
); } // ── Spielliste ──────────────────────────────────────────────────────────────── -function GameList({ games, myId, onSelect, onNew }) { +function GameList({ games, myId, isAdmin, onSelect, onNew, onDelete, toast }) { const active = games.filter(g => g.status === 'active'); - // Beendet: nur Spiele mit mind. 2 Zügen (mind. 1 pro Spieler) const finished = games.filter(g => g.status === 'finished' && (g.move_count || 0) >= 2); - const GameRow = ({ g }) => { - const isMyTurn = g.status === 'active' && g.current_turn === myId; - const opp = g.owner_id === myId ? g.opp_name : g.owner_name; - const grid = JSON.parse(g.grid); - const myCount = grid.flat().filter(v => v === myId).length; - const oppCount = grid.flat().filter(v => v !== myId && v !== 0).length; - let result = null; - if (g.status === 'finished') { - if (!g.winner_id) result = { label:'Unentschieden', color:'#ffe66d' }; - else if (g.winner_id === myId) result = { label:'Gewonnen 🎉', color:MY_COLOR }; - else result = { label:'Verloren', color:OPP_COLOR }; - } - return ( - - ); - }; - + // Sub-Komponente AUSSERHALB von GameList definiert (weiter unten als top-level) return (
- + {active.length > 0 && <> -
LAUFEND ({active.length})
- {active.map(g => )} +
LAUFENDE SPIELE ({active.length})
+ {active.map(g => )} } + {finished.length > 0 && <> -
BEENDET
- {finished.map(g => )} +
BEENDETE SPIELE
+ {finished.map(g => )} } + {games.length === 0 && ( -
+
Noch keine Spiele — starte eines!
)} +
); } +// ── Spielzeile (top-level, nicht innerhalb GameList) ────────────────────────── +function GameRow({ g, myId, isAdmin, onSelect, onDelete, finished }) { + const isMyTurn = g.status === 'active' && g.current_turn === myId; + const opp = g.owner_id === myId ? g.opp_name : g.owner_name; + const grid = JSON.parse(g.grid); + const myCount = grid.flat().filter(v => v === myId).length; + const oppCount = grid.flat().filter(v => v !== myId && v !== 0).length; + let result = null; + if (g.status === 'finished') { + if (!g.winner_id) result = { label:'Unentschieden', color:'#ffe66d' }; + else if (g.winner_id === myId) result = { label:'Gewonnen 🎉', color:MY_COLOR }; + else result = { label:'Verloren', color:OPP_COLOR }; + } + return ( +
+ + {result + ? {result.label} + : + {isMyTurn ? 'Du bist dran' : 'Gegner dran'} + + } + {isAdmin && finished && ( + + )} +
+ ); +} + // ── Hauptkomponente ─────────────────────────────────────────────────────────── -export default function HexWars({ toast }) { +export default function HexWars({ toast, mobile }) { const [games, setGames] = useState([]); const [activeId, setActiveId] = useState(null); const [game, setGame] = useState(null); @@ -307,7 +380,8 @@ export default function HexWars({ toast }) { const [showHelp, setShowHelp] = useState(false); const [loading, setLoading] = useState(true); - const myId = getMyId(); + const myId = getMyId(); + const isAdmin = getMyRole() === 'admin'; const loadGames = useCallback(async () => { try { @@ -352,17 +426,30 @@ export default function HexWars({ toast }) { toast('Du hast aufgegeben.'); }; + const handleDelete = async (id) => { + if (!window.confirm('Spiel wirklich löschen?')) return; + try { + await api(`/tools/gebietseroberung/${id}`, { method: 'DELETE' }); + setGames(prev => prev.filter(g => g.id !== id)); + toast('Spiel gelöscht.'); + } catch(e) { toast?.(e.message || 'Fehler', 'error'); } + }; + if (loading) return (
Lade…
); return ( -
-
+
+ {/* Header — gleicher Stil wie Kanban/Whiteboard */} +
{activeId && ( )} - ⬡ HEX WARS +

+ ⬡ HEX WARS +

+
{activeId && game?.status === 'active' && ( @@ -370,9 +457,13 @@ export default function HexWars({ toast }) {
{!activeId - ? setShowNew(true)} /> + ? setShowNew(true)} + onDelete={handleDelete} toast={toast} + /> : game && myId - ? + ? :
Lade Spiel…
}