Implemented DELETE `/games/{game_id}` endpoint allowing hosts to cancel games with cascade deletion of sheet states, chips, and memberships. Added `cancelGame` handler in App.jsx to delete game, refresh game list, reset state, and show confirmation snack. Added cancel button to GamePickerCard lobby section (pre-start) and started section (non-finished games) with bilingual confirmation dialog, loading state, and error handling.
267 lines
11 KiB
React
267 lines
11 KiB
React
import React, { useState } from "react";
|
|
import { styles } from "../styles/styles";
|
|
import { stylesTokens } from "../styles/theme";
|
|
import { useLanguage } from "../i18n";
|
|
|
|
export default function GamePickerCard({
|
|
games,
|
|
gameId,
|
|
setGameId,
|
|
onOpenHelp,
|
|
members = [],
|
|
me,
|
|
hostUserId,
|
|
isHost = false,
|
|
started = false,
|
|
finished = false,
|
|
winnerName = "",
|
|
chipCount = 0,
|
|
onStartGame,
|
|
onCancelGame,
|
|
}) {
|
|
const { language, t } = useLanguage();
|
|
const cur = games.find((x) => x.id === gameId);
|
|
const [starting, setStarting] = useState(false);
|
|
const [startError, setStartError] = useState("");
|
|
const [cancelling, setCancelling] = useState(false);
|
|
|
|
const renderMemberName = (m) => {
|
|
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
|
|
const isMe = !!(me?.id && String(me.id) === String(m.id));
|
|
const isHost = !!(hostUserId && String(hostUserId) === String(m.id));
|
|
|
|
const suffix = `${isHost ? " " : ""}${isMe ? " (du)" : ""}`;
|
|
return base + suffix;
|
|
};
|
|
|
|
const pillStyle = (isHost, isMe) => ({
|
|
padding: "7px 10px",
|
|
borderRadius: 999,
|
|
border: `1px solid ${
|
|
isHost ? "rgba(233,216,166,0.35)" : "rgba(233,216,166,0.16)"
|
|
}`,
|
|
background: isHost
|
|
? "linear-gradient(180deg, rgba(233,216,166,0.14), rgba(10,10,12,0.35))"
|
|
: "rgba(10,10,12,0.30)",
|
|
color: stylesTokens.textMain,
|
|
fontSize: 13,
|
|
fontWeight: 950,
|
|
boxShadow: isHost ? "0 8px 18px rgba(0,0,0,0.25)" : "none",
|
|
opacity: isMe ? 1 : 0.95,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
whiteSpace: "nowrap",
|
|
});
|
|
|
|
const handleStartGame = async () => {
|
|
if (!onStartGame || starting || members.length < 2) return;
|
|
if (!window.confirm(language === "en" ? "Start the game now? No more players can join afterwards." : "Spiel jetzt starten? Danach können keine weiteren Spieler beitreten.")) return;
|
|
|
|
setStarting(true);
|
|
setStartError("");
|
|
try {
|
|
await onStartGame();
|
|
} catch (e) {
|
|
setStartError(e?.message || (language === "en" ? "The game could not be started." : "Das Spiel konnte nicht gestartet werden."));
|
|
} finally {
|
|
setStarting(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelGame = async () => {
|
|
if (!onCancelGame || cancelling) return;
|
|
const confirmed = window.confirm(language === "en" ? "Cancel and delete this game? All game data will be removed." : "Dieses Spiel wirklich abbrechen und löschen? Alle Spieldaten werden entfernt.");
|
|
if (!confirmed) return;
|
|
setCancelling(true);
|
|
setStartError("");
|
|
try {
|
|
await onCancelGame();
|
|
} catch (e) {
|
|
setStartError(e?.message || (language === "en" ? "The game could not be cancelled." : "Das Spiel konnte nicht abgebrochen werden."));
|
|
} finally {
|
|
setCancelling(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{ marginTop: 14 }}>
|
|
<div style={styles.card}>
|
|
<div style={styles.sectionHeader}>{t("game")}</div>
|
|
|
|
<div style={styles.cardBody}>
|
|
<select
|
|
value={gameId || ""}
|
|
onChange={(e) => setGameId(e.target.value)}
|
|
style={{ ...styles.input, flex: 1 }}
|
|
>
|
|
{games.map((g) => (
|
|
<option key={g.id} value={g.id}>
|
|
{g.name} {g.code ? `• ${g.code}` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")}>
|
|
{t("help")}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Code Zeile */}
|
|
{cur?.code && (
|
|
<div
|
|
style={{
|
|
padding: "0 12px 10px",
|
|
fontSize: 12,
|
|
color: stylesTokens.textDim,
|
|
opacity: 0.92,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<div>
|
|
Code: <b style={{ color: stylesTokens.textGold }}>{cur.code}</b>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!started ? (
|
|
<div
|
|
className="hp-lobby"
|
|
style={{
|
|
margin: "0 12px 12px",
|
|
padding: 12,
|
|
borderRadius: 16,
|
|
border: `1px solid ${stylesTokens.panelBorder}`,
|
|
background: "rgba(8,8,11,0.28)",
|
|
}}
|
|
>
|
|
<div className="hp-lobby-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
|
<div>
|
|
<div style={{ color: stylesTokens.textGold, fontWeight: 1000, fontSize: 15 }}>
|
|
{t("lobby")}
|
|
</div>
|
|
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>
|
|
{isHost ? (language === "en" ? "You are the host of this game." : "Du bist der Host dieses Spiels.") : (language === "en" ? "You joined the lobby." : "Du bist der Lobby beigetreten.")}
|
|
</div>
|
|
</div>
|
|
<div style={{ textAlign: "right", color: stylesTokens.textGold, fontWeight: 1000 }}>
|
|
<div style={{ fontSize: 20, lineHeight: 1 }}>{members.length}</div>
|
|
<div style={{ fontSize: 11, color: stylesTokens.textDim, fontWeight: 700 }}>{t("players")}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hp-lobby-slots" style={{ marginTop: 12, display: "grid", gap: 6 }}>
|
|
{members.length > 0 ? members.map((m, index) => {
|
|
const isMe = String(me?.id) === String(m.id);
|
|
const isMemberHost = String(hostUserId) === String(m.id);
|
|
const name = ((m.display_name || "").trim() || (m.email || "").trim() || t("players"));
|
|
return (
|
|
<div key={m.id} className="hp-lobby-player" style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 10px", borderRadius: 11, background: "rgba(255,255,255,0.055)", border: "1px solid rgba(233,216,166,0.10)" }}>
|
|
<span style={{ color: stylesTokens.textDim, fontSize: 12, minWidth: 16 }}>{index + 1}</span>
|
|
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: stylesTokens.textMain, fontWeight: 900 }}>
|
|
{name}{isMe ? " (du)" : ""}
|
|
</span>
|
|
{isMemberHost && <span title="Host" style={{ color: stylesTokens.textGold }}>👑</span>}
|
|
</div>
|
|
);
|
|
}) : (
|
|
<div style={{ padding: "10px 4px", color: stylesTokens.textDim, fontSize: 13 }}>
|
|
{language === "en" ? "Waiting for players …" : "Warte auf Spieler …"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isHost ? (
|
|
<>
|
|
<button
|
|
onClick={handleStartGame}
|
|
style={{ ...styles.primaryBtn, width: "100%", marginTop: 12 }}
|
|
disabled={starting || members.length < 2}
|
|
>
|
|
{starting ? (language === "en" ? "Starting game …" : "Spiel wird gestartet …") : `▶ ${t("startGame")}`}
|
|
</button>
|
|
<div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}>
|
|
{members.length < 2 ? t("atLeastTwoPlayers") : t("chipsOnStart")}
|
|
</div>
|
|
<button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, width: "100%", marginTop: 9, color: "#ffb3b3" }} disabled={cancelling}>
|
|
{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
|
|
{t("waitingForHost")}
|
|
</div>
|
|
)}
|
|
|
|
{startError && <div style={{ marginTop: 8, color: "#ffb3b3", fontSize: 12 }}>{startError}</div>}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div style={{ margin: "0 12px 12px", padding: "10px 12px", borderRadius: 13, border: `1px solid ${stylesTokens.panelBorder}`, background: finished ? "rgba(233,216,166,0.09)" : "rgba(124,255,182,0.07)", color: stylesTokens.textDim, fontSize: 12 }}>
|
|
{finished
|
|
? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
|
|
: `✓ ${t("started")} · ${chipCount} ${t("chipsCreated")}`}
|
|
</div>
|
|
{isHost && !finished && <button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, margin: "0 12px 12px", width: "calc(100% - 24px)", color: "#ffb3b3" }} disabled={cancelling}>{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}</button>}
|
|
</>
|
|
)}
|
|
|
|
{/* Spieler */}
|
|
{started && members?.length > 0 && (
|
|
<div style={{ padding: "0 12px 12px" }}>
|
|
<div
|
|
style={{
|
|
fontSize: 12,
|
|
opacity: 0.85,
|
|
color: stylesTokens.textDim,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<div>{t("players")}</div>
|
|
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
|
|
{members.length}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
marginTop: 8,
|
|
padding: 10,
|
|
borderRadius: 16,
|
|
border: `1px solid rgba(233,216,166,0.10)`,
|
|
background: "rgba(10,10,12,0.18)",
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
{members.map((m) => {
|
|
const isMe = !!(me?.id && String(me.id) === String(m.id));
|
|
const isHost = !!(hostUserId && String(hostUserId) === String(m.id));
|
|
const label = renderMemberName(m);
|
|
|
|
return (
|
|
<div key={m.id} style={pillStyle(isHost, isMe)} title={label}>
|
|
{isHost && <span style={{ color: stylesTokens.textGold }}>👑</span>}
|
|
<span>{label.replace(" 👑", "")}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div style={{ marginTop: 6, fontSize: 11, opacity: 0.7, color: stylesTokens.textDim }}>
|
|
👑 = Host
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|