diff --git a/frontend/package.json b/frontend/package.json index 7c16f33..b05b867 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "canvas-confetti": "^1.9.3" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fd762f8..84b88dd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,6 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import WinnerCelebration from "./components/WinnerCelebration"; import { api } from "./api/client"; import { cycleTag } from "./utils/cycleTag"; @@ -7,6 +9,7 @@ import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage"; import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { styles } from "./styles/styles"; import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; +import { stylesTokens } from "./styles/theme"; import AdminPanel from "./components/AdminPanel"; import LoginPage from "./components/LoginPage"; @@ -69,6 +72,38 @@ export default function App() { const [statsLoading, setStatsLoading] = useState(false); const [statsError, setStatsError] = useState(""); + // ===== Join Snack (bottom toast) ===== + const [snack, setSnack] = useState(""); + const snackTimerRef = useRef(null); + + // track members to detect joins + const lastMemberIdsRef = useRef(new Set()); + const membersBaselineRef = useRef(false); + + // ===== Winner Celebration ===== + const [celebrateOpen, setCelebrateOpen] = useState(false); + const [celebrateName, setCelebrateName] = useState(""); + + // baseline per game: beim ersten Meta-Load NICHT feiern + const winnerBaselineRef = useRef(false); + const lastWinnerIdRef = useRef(null); + + const showSnack = (msg) => { + setSnack(msg); + if (snackTimerRef.current) clearTimeout(snackTimerRef.current); + snackTimerRef.current = setTimeout(() => setSnack(""), 1800); + }; + + const vibrate = (pattern) => { + try { + if (typeof navigator !== "undefined" && "vibrate" in navigator) { + navigator.vibrate(pattern); + } + } catch { + // ignore + } + }; + const load = async () => { const m = await api("/auth/me"); setMe(m); @@ -91,12 +126,46 @@ export default function App() { const loadGameMeta = async () => { if (!gameId) return; + const meta = await api(`/games/${gameId}`); setGameMeta(meta); setWinnerUserId(meta?.winner_user_id || ""); const mem = await api(`/games/${gameId}/members`); setMembers(mem); + + // ✅ detect new members (join notifications for everyone) + try { + const prev = lastMemberIdsRef.current; + const nowIds = new Set((mem || []).map((m) => String(m.id))); + + if (!membersBaselineRef.current) { + // first load for this game -> set baseline, no notification + membersBaselineRef.current = true; + lastMemberIdsRef.current = nowIds; + return; + } + + const added = (mem || []).filter((m) => !prev.has(String(m.id))); + + if (added.length > 0) { + const names = added + .map((m) => ((m.display_name || "").trim() || (m.email || "").trim() || "Jemand")) + .slice(0, 3); + + const msg = + added.length === 1 + ? `✨ ${names[0]} ist beigetreten` + : `✨ ${names.join(", ")} ${added.length > 3 ? `(+${added.length - 3}) ` : ""}sind beigetreten`; + + showSnack(msg); + vibrate(25); // dezent & kurz + } + + lastMemberIdsRef.current = nowIds; + } catch { + // ignore snack errors + } }; // Dropdown outside click @@ -121,6 +190,16 @@ export default function App() { // on game change useEffect(() => { + // reset join detection baseline when switching games + membersBaselineRef.current = false; + lastMemberIdsRef.current = new Set(); + + // reset winner celebration baseline when switching games + winnerBaselineRef.current = false; + lastWinnerIdRef.current = null; + setCelebrateOpen(false); + setCelebrateName(""); + (async () => { if (!gameId) return; try { @@ -161,6 +240,35 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [me?.id, gameId]); + useEffect(() => { + // wid kann auch "" sein (kein Sieger) + const wid = gameMeta?.winner_user_id ? String(gameMeta.winner_user_id) : ""; + + // Baseline beim ersten Meta-Load setzen – egal ob Winner existiert oder nicht + if (!winnerBaselineRef.current) { + winnerBaselineRef.current = true; + lastWinnerIdRef.current = wid; // kann "" sein + return; + } + + // Nur reagieren, wenn sich wid ändert + if (lastWinnerIdRef.current !== wid) { + lastWinnerIdRef.current = wid; + + // wenn wid leer wird (reset), nicht feiern + if (!wid) return; + + const name = + (gameMeta?.winner_display_name || "").trim() || + (gameMeta?.winner_email || "").trim() || + "Jemand"; + + setCelebrateName(name); + setCelebrateOpen(true); + } + }, [gameMeta?.winner_user_id, gameMeta?.winner_display_name, gameMeta?.winner_email]); + + // ===== Auth actions ===== const doLogin = async () => { await api("/auth/login", { @@ -179,6 +287,12 @@ export default function App() { setGameMeta(null); setMembers([]); setWinnerUserId(""); + + // reset winner celebration on logout + winnerBaselineRef.current = false; + lastWinnerIdRef.current = null; + setCelebrateOpen(false); + setCelebrateName(""); }; // ===== Password ===== @@ -263,15 +377,23 @@ export default function App() { // ===== New game flow ===== const createGame = async () => { - // ✅ wichtig: alten Game-State weg, damit nix "hängen" bleibt + // ✅ alten Game-State komplett loswerden, damit nix am alten Spiel "hängen bleibt" setSheet(null); setGameMeta(null); setMembers([]); setWinnerUserId(""); setPulseId(null); + + // auch Chip-Modal-State resetten setChipOpen(false); setChipEntry(null); + // reset winner celebration baseline for the new game + winnerBaselineRef.current = false; + lastWinnerIdRef.current = null; + setCelebrateOpen(false); + setCelebrateName(""); + const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), @@ -280,7 +402,7 @@ export default function App() { const gs = await api("/games"); setGames(gs); - // ✅ auf neues Spiel wechseln (triggered dann reloadSheet/loadGameMeta via effect) + // ✅ auf neues Game wechseln (triggert reloadSheet/loadGameMeta via effect) setGameId(g.id); return g; // includes code @@ -425,6 +547,13 @@ export default function App() { return (
+ {/* Winner Celebration Overlay */} + setCelebrateOpen(false)} + /> + ); } diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 45578c7..37fed88 100644 --- a/frontend/src/components/GamePickerCard.jsx +++ b/frontend/src/components/GamePickerCard.jsx @@ -2,7 +2,46 @@ import React from "react"; import { styles } from "../styles/styles"; import { stylesTokens } from "../styles/theme"; -export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp }) { +export default function GamePickerCard({ + games, + gameId, + setGameId, + onOpenHelp, + members = [], + me, + hostUserId, +}) { + const cur = games.find((x) => x.id === gameId); + + 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", + }); + return (
@@ -26,16 +65,77 @@ export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp })
- {/* kleine Code Zeile unter dem Picker (optional nice) */} - {(() => { - const cur = games.find((x) => x.id === gameId); - if (!cur?.code) return null; - return ( -
+ {/* Code Zeile */} + {cur?.code && ( +
+
Code: {cur.code}
- ); - })()} +
+ )} + + {/* Spieler */} + {members?.length > 0 && ( +
+
+
Spieler
+
+ {members.length} +
+
+ +
+ {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 ( +
+ {isHost && 👑} + {label.replace(" 👑", "")} +
+ ); + })} +
+ +
+ 👑 = Host +
+
+ )}
); diff --git a/frontend/src/components/NewGameModal.jsx b/frontend/src/components/NewGameModal.jsx index d77048e..04d291e 100644 --- a/frontend/src/components/NewGameModal.jsx +++ b/frontend/src/components/NewGameModal.jsx @@ -168,6 +168,7 @@ export default function NewGameModal({ )} + {/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */} {mode === "choice" && ( <> diff --git a/frontend/src/components/WinnerCelebration.jsx b/frontend/src/components/WinnerCelebration.jsx new file mode 100644 index 0000000..ec2244c --- /dev/null +++ b/frontend/src/components/WinnerCelebration.jsx @@ -0,0 +1,182 @@ +import React, { useEffect } from "react"; +import { createPortal } from "react-dom"; +import confetti from "canvas-confetti"; +import { stylesTokens } from "../styles/theme"; + +export default function WinnerCelebration({ open, winnerName, onClose }) { + useEffect(() => { + if (!open) return; + + // Scroll lock + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const reduceMotion = + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + if (!reduceMotion) { + const end = Date.now() + 4500; + + // WICHTIG: über dem Overlay rendern + const TOP_Z = 2147483647; + + // hellere Farben damit’s auch auf dark overlay knallt + const bright = ["#ffffff", "#ffd166", "#06d6a0", "#4cc9f0", "#f72585"]; + + // 2 große Bursts + confetti({ + particleCount: 170, + spread: 95, + startVelocity: 42, + origin: { x: 0.12, y: 0.62 }, + zIndex: TOP_Z, + colors: bright, + }); + confetti({ + particleCount: 170, + spread: 95, + startVelocity: 42, + origin: { x: 0.88, y: 0.62 }, + zIndex: TOP_Z, + colors: bright, + }); + + // “Rain” über die Zeit + (function frame() { + confetti({ + particleCount: 8, + spread: 75, + startVelocity: 34, + origin: { x: Math.random(), y: Math.random() * 0.18 }, + scalar: 1.05, + zIndex: TOP_Z, + colors: bright, + }); + if (Date.now() < end) requestAnimationFrame(frame); + })(); + } + + const t = setTimeout(() => onClose?.(), 5500); + + return () => { + clearTimeout(t); + document.body.style.overflow = prevOverflow; + }; + }, [open, onClose]); + + useEffect(() => { + if (!open) return; + const onKey = (e) => e.key === "Escape" && onClose?.(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + const node = ( +
Confetti wirkt heller + background: "rgba(0,0,0,0.42)", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + padding: 14, + }} + onMouseDown={onClose} + > +
e.stopPropagation()} + style={{ + // kleiner + mobile friendly + width: "min(420px, 90vw)", + borderRadius: 18, + padding: "14px 14px", + border: `1px solid ${stylesTokens.panelBorder}`, + background: stylesTokens.panelBg, + boxShadow: "0 18px 70px rgba(0,0,0,0.55)", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + position: "relative", + overflow: "hidden", + }} + > + {/* dezente “Gold Line” */} +
+ + {/* shine */} +
+ +
+
🏆
+ +
+ Spieler{" "} + + {winnerName || "Unbekannt"} + {" "} + hat die richtige Lösung! +
+ +
+ Fall gelöst. Respekt. ✨ +
+ +
+ +
+
+
+
+ ); + + return createPortal(node, document.body); +}