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
+
-
-
{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?
+
+ setPending(null)} style={S.btn('#666666', true)}>✕
+
+ {moving ? '…' : '✓ Ja'}
+
+
+ )}
)}
-
- {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 (
-
onSelect(g.id)} style={{
- ...S.card, width:'100%', textAlign:'left', cursor:'pointer',
- padding:'12px 14px', marginBottom:8, display:'flex', alignItems:'center', gap:12,
- border: isMyTurn ? '1px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.07)',
- }}>
-
-
- {isMyTurn && ▶ }
- vs {opp}
-
-
- {myCount} : {oppCount} Felder
-
-
- {result
- ? {result.label}
- :
- {isMyTurn ? 'Du bist dran' : 'Gegner dran'}
-
- }
-
- );
- };
-
+ // Sub-Komponente AUSSERHALB von GameList definiert (weiter unten als top-level)
return (
-
+
+ Neues Spiel starten
+
{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 (
+
+
onSelect(g.id)} style={{ flex:1, background:'none', border:'none', cursor:'pointer', textAlign:'left', padding:0 }}>
+
+ {isMyTurn && ▶ }
+ vs {opp}
+
+
+ {myCount} : {oppCount} Felder · {g.move_count || 0} Züge
+
+
+ {result
+ ?
{result.label}
+ :
+ {isMyTurn ? 'Du bist dran' : 'Gegner dran'}
+
+ }
+ {isAdmin && finished && (
+
{ e.stopPropagation(); onDelete(g.id); }}
+ style={{ ...S.btn('#ff6b9d', true), flexShrink:0, padding:'4px 8px' }}
+ title="Spiel löschen (Admin)"
+ >🗑
+ )}
+
+ );
+}
+
// ── 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 && (
{ setActiveId(null); setGame(null); }} style={S.btn('#666666', true)}>← Zurück
)}
-
⬡ HEX WARS
+
+ ⬡ HEX WARS
+
+
setShowHelp(true)} style={S.btn('#ffe66d', true)}>? Regeln
{activeId && game?.status === 'active' && (
⚑ Aufgeben
@@ -370,9 +457,13 @@ export default function HexWars({ toast }) {
{!activeId
- ?
setShowNew(true)} />
+ ? setShowNew(true)}
+ onDelete={handleDelete} toast={toast}
+ />
: game && myId
- ?
+ ?
: Lade Spiel…
}