From 7b7b23f52d72227f403f32db6201685e47ede09b Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 14:37:36 +0100 Subject: [PATCH 01/10] Add join notifications with bottom snack and vibration feedback Added functionality to detect new members joining games, displaying a snack message and providing optional vibration feedback. Managed state with refs to track members and reset baselines when switching games. Styled a bottom toast notification for better user feedback. --- frontend/src/App.jsx | 99 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fd762f8..19afbdf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { api } from "./api/client"; import { cycleTag } from "./utils/cycleTag"; @@ -7,6 +7,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 +70,30 @@ 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); + + 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 +116,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 +180,10 @@ export default function App() { // on game change useEffect(() => { + // reset join detection baseline when switching games + membersBaselineRef.current = false; + lastMemberIdsRef.current = new Set(); + (async () => { if (!gameId) return; try { @@ -263,12 +326,14 @@ 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); @@ -280,7 +345,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 @@ -532,6 +597,34 @@ export default function App() { loading={statsLoading} error={statsError} /> + + {/* Bottom snack for joins */} + {snack && ( +
+ {snack} +
+ )} ); } -- 2.49.1 From 85805531c2089b273ea5e37f37a336d2502c2f4f Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 14:46:36 +0100 Subject: [PATCH 02/10] Add current members list display in NewGameModal Introduced a UI element in NewGameModal to display the list of current members when available. Also used React Portal to render the bottom snack for joins directly in the document body. These updates enhance UI clarity and user feedback during game interactions. --- frontend/src/App.jsx | 56 +++++++++++++----------- frontend/src/components/NewGameModal.jsx | 42 ++++++++++++++++++ 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 19afbdf..3b54669 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { api } from "./api/client"; import { cycleTag } from "./utils/cycleTag"; @@ -581,6 +582,7 @@ export default function App() { currentCode={gameMeta?.code || ""} gameFinished={!!gameMeta?.winner_user_id} hasGame={!!gameId} + currentMembers={members} /> {/* Bottom snack for joins */} - {snack && ( -
- {snack} -
- )} + {snack && + createPortal( +
+ {snack} +
, + document.body + ) + } ); } diff --git a/frontend/src/components/NewGameModal.jsx b/frontend/src/components/NewGameModal.jsx index d77048e..4b01079 100644 --- a/frontend/src/components/NewGameModal.jsx +++ b/frontend/src/components/NewGameModal.jsx @@ -12,6 +12,7 @@ export default function NewGameModal({ currentCode = "", gameFinished = false, hasGame = false, + currentMembers = [], }) { // modes: running | choice | create | join const [mode, setMode] = useState("choice"); @@ -168,6 +169,47 @@ export default function NewGameModal({ )} + {currentMembers?.length > 0 && ( +
+
+ Aktuelle Spieler ({currentMembers.length}) +
+ +
+ {currentMembers.map((m) => { + const name = ((m.display_name || "").trim() || (m.email || "").trim() || "—"); + return ( +
+ {name} +
+ ); + })} +
+
+ )} + + {/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */} {mode === "choice" && ( <> -- 2.49.1 From d4e629b2118f8fe9c00d00d119a624d8ae674cad Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 14:53:11 +0100 Subject: [PATCH 03/10] Enhance member display in GamePickerCard and remove redundancy Added functionality to display members in GamePickerCard, replacing the previously redundant implementation in NewGameModal. This change centralizes member display logic, reducing code duplication and improving maintainability. --- frontend/src/App.jsx | 1 + frontend/src/components/GamePickerCard.jsx | 33 ++++++++++++++++- frontend/src/components/NewGameModal.jsx | 41 ---------------------- 3 files changed, 33 insertions(+), 42 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3b54669..ff3a337 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -514,6 +514,7 @@ export default function App() { gameId={gameId} setGameId={setGameId} onOpenHelp={() => setHelpOpen(true)} + members={members} /> {/* Sieger Badge: zwischen Spiel und Verdächtigte Person */} diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 45578c7..90c616f 100644 --- a/frontend/src/components/GamePickerCard.jsx +++ b/frontend/src/components/GamePickerCard.jsx @@ -2,7 +2,7 @@ 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 = [] }) { return (
@@ -36,6 +36,37 @@ export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp })
); })()} + + {members?.length > 0 && ( +
+
+ Spieler ({members.length}) +
+ +
+ {members.map((m) => { + const name = ((m.display_name || "").trim() || (m.email || "").trim() || "—"); + return ( +
+ {name} +
+ ); + })} +
+
+ )} +
); diff --git a/frontend/src/components/NewGameModal.jsx b/frontend/src/components/NewGameModal.jsx index 4b01079..04d291e 100644 --- a/frontend/src/components/NewGameModal.jsx +++ b/frontend/src/components/NewGameModal.jsx @@ -12,7 +12,6 @@ export default function NewGameModal({ currentCode = "", gameFinished = false, hasGame = false, - currentMembers = [], }) { // modes: running | choice | create | join const [mode, setMode] = useState("choice"); @@ -169,46 +168,6 @@ export default function NewGameModal({ )} - {currentMembers?.length > 0 && ( -
-
- Aktuelle Spieler ({currentMembers.length}) -
- -
- {currentMembers.map((m) => { - const name = ((m.display_name || "").trim() || (m.email || "").trim() || "—"); - return ( -
- {name} -
- ); - })} -
-
- )} - {/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */} {mode === "choice" && ( -- 2.49.1 From f555526e64a494e042a06623f97f088bacf55c2c Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 14:57:35 +0100 Subject: [PATCH 04/10] Add host and player identification in GamePickerCard This update introduces visual indicators to identify the host and the current user in the GamePickerCard component. Hosts are marked with a star, and the current user is labeled as "(du)". The design of the member pills has also been enhanced for better clarity and aesthetics. --- frontend/src/App.jsx | 2 + frontend/src/components/GamePickerCard.jsx | 144 ++++++++++++++++----- 2 files changed, 111 insertions(+), 35 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ff3a337..ba7ca9f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -515,6 +515,8 @@ export default function App() { setGameId={setGameId} onOpenHelp={() => setHelpOpen(true)} members={members} + me={me} + hostUserId={gameMeta?.host_user_id || ""} /> {/* Sieger Badge: zwischen Spiel und Verdächtigte Person */} diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 90c616f..3e03dab 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, members = [] }) { +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,47 +65,82 @@ export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp, m
- {/* 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}
- ); - })()} - {members?.length > 0 && ( -
-
- Spieler ({members.length}) -
- -
- {members.map((m) => { - const name = ((m.display_name || "").trim() || (m.email || "").trim() || "—"); - return ( -
- {name} -
- ); - })} + {/* Mini hint rechts (optional) */} +
+ teilen
)} + {/* 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   •   (du) = du +
+
+ )}
); -- 2.49.1 From 83893a0060eb4d9737a8c88e6b117418f971e420 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 14:59:58 +0100 Subject: [PATCH 05/10] Remove unused "teilen" hint from GamePickerCard. The small "teilen" hint was not being actively used and has been removed for cleaner code. This improves readability and maintains consistency in the component. --- frontend/src/components/GamePickerCard.jsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 3e03dab..8b71920 100644 --- a/frontend/src/components/GamePickerCard.jsx +++ b/frontend/src/components/GamePickerCard.jsx @@ -82,11 +82,6 @@ export default function GamePickerCard({
Code: {cur.code}
- - {/* Mini hint rechts (optional) */} -
- teilen -
)} -- 2.49.1 From aefb4234d6c84baa656895451aad9ec26e795b9e Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 15:01:50 +0100 Subject: [PATCH 06/10] Update host icon in GamePickerCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the star (⭐) icon with a crown (👑) to represent the host. Adjusted labels, tooltip styles, and descriptive text accordingly for improved clarity and consistency. --- frontend/src/components/GamePickerCard.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 8b71920..7df45a5 100644 --- a/frontend/src/components/GamePickerCard.jsx +++ b/frontend/src/components/GamePickerCard.jsx @@ -124,15 +124,15 @@ export default function GamePickerCard({ return (
- {isHost && } - {label.replace(" ⭐", "")} + {isHost && 👑} + {label.replace(" 👑", "")}
); })}
- ⭐ = Host   •   (du) = du + 👑 = Host
)} -- 2.49.1 From 56ef0760103d065b19a6a49132568ae0029ca72d Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 16:52:17 +0100 Subject: [PATCH 07/10] Remove star emoji for host users in GamePickerCard. The star emoji for identifying host users has been removed from the `suffix` logic. This simplifies the component and aligns with updated display requirements. --- frontend/src/components/GamePickerCard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 7df45a5..37fed88 100644 --- a/frontend/src/components/GamePickerCard.jsx +++ b/frontend/src/components/GamePickerCard.jsx @@ -18,7 +18,7 @@ export default function GamePickerCard({ const isMe = !!(me?.id && String(me.id) === String(m.id)); const isHost = !!(hostUserId && String(hostUserId) === String(m.id)); - const suffix = `${isHost ? " ⭐" : ""}${isMe ? " (du)" : ""}`; + const suffix = `${isHost ? " " : ""}${isMe ? " (du)" : ""}`; return base + suffix; }; -- 2.49.1 From 3a9da788e50e67b50734f66c89af5bbc0b7e5917 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 17:05:19 +0100 Subject: [PATCH 08/10] Add winner celebration feature with confetti effects This update introduces a winner celebration overlay displayed when a game's winner is announced. It includes confetti animations along with a congratulatory message, enhancing user experience. The feature resets appropriately during game transitions and logout to maintain correct behavior. --- frontend/package.json | 3 +- frontend/src/App.jsx | 63 ++++++- frontend/src/components/WinnerCelebration.jsx | 170 ++++++++++++++++++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/WinnerCelebration.jsx 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 ba7ca9f..634bd7b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,6 @@ 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"; @@ -79,6 +80,14 @@ export default function App() { 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); @@ -185,6 +194,12 @@ export default function App() { 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 { @@ -225,6 +240,32 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [me?.id, gameId]); + // ✅ Winner Celebration Trigger: + // - beim ersten Load eines Spiels NICHT feiern + // - feiern nur, wenn winner_user_id sich danach ändert (Host speichert) + useEffect(() => { + const wid = gameMeta?.winner_user_id ? String(gameMeta.winner_user_id) : ""; + if (!wid) return; + + if (!winnerBaselineRef.current) { + winnerBaselineRef.current = true; + lastWinnerIdRef.current = wid; + return; + } + + if (lastWinnerIdRef.current !== wid) { + lastWinnerIdRef.current = wid; + + 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", { @@ -243,6 +284,12 @@ export default function App() { setGameMeta(null); setMembers([]); setWinnerUserId(""); + + // reset winner celebration on logout + winnerBaselineRef.current = false; + lastWinnerIdRef.current = null; + setCelebrateOpen(false); + setCelebrateName(""); }; // ===== Password ===== @@ -338,6 +385,12 @@ export default function App() { 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() }), @@ -491,6 +544,13 @@ export default function App() { return (
+ {/* Winner Celebration Overlay */} + setCelebrateOpen(false)} + /> + , document.body - ) - } + )}
); } diff --git a/frontend/src/components/WinnerCelebration.jsx b/frontend/src/components/WinnerCelebration.jsx new file mode 100644 index 0000000..d69aecd --- /dev/null +++ b/frontend/src/components/WinnerCelebration.jsx @@ -0,0 +1,170 @@ +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; + + // 2 große Bursts + confetti({ + particleCount: 150, + spread: 95, + startVelocity: 38, + origin: { x: 0.12, y: 0.62 }, + zIndex: 999999, + }); + confetti({ + particleCount: 150, + spread: 95, + startVelocity: 38, + origin: { x: 0.88, y: 0.62 }, + zIndex: 999999, + }); + + // “Rain” über die Zeit + (function frame() { + confetti({ + particleCount: 6, + spread: 70, + startVelocity: 32, + origin: { x: Math.random(), y: Math.random() * 0.18 }, + scalar: 1.0, + zIndex: 999999, + }); + 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 = ( +
+
e.stopPropagation()} + style={{ + width: "min(560px, 92vw)", + borderRadius: 22, + padding: "16px 16px", + border: `1px solid ${stylesTokens.panelBorder}`, + background: stylesTokens.panelBg, + boxShadow: "0 22px 90px rgba(0,0,0,0.60)", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + position: "relative", + overflow: "hidden", + }} + > + {/* dezente “Gold Line” */} +
+ + {/* kleine “shine” Ecke oben rechts */} +
+ +
+
🏆
+ +
+ Spieler{" "} + + {winnerName || "Unbekannt"} + {" "} + hat die richtige Lösung! +
+ +
+ Fall gelöst. Respekt. ✨ +
+ +
+ +
+
+
+
+ ); + + return createPortal(node, document.body); +} -- 2.49.1 From 61c7ed6ffe5c2a60d4f4c22748528aec405b9cd7 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 17:09:43 +0100 Subject: [PATCH 09/10] Improve winner celebration logic in game meta updates Adjusted the logic to ensure celebrations trigger only when the winner ID changes and not on initial meta loads. Also added a reset check to prevent celebrations when the winner ID becomes empty. --- frontend/src/App.jsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 634bd7b..84b88dd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -240,22 +240,24 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [me?.id, gameId]); - // ✅ Winner Celebration Trigger: - // - beim ersten Load eines Spiels NICHT feiern - // - feiern nur, wenn winner_user_id sich danach ändert (Host speichert) useEffect(() => { + // wid kann auch "" sein (kein Sieger) const wid = gameMeta?.winner_user_id ? String(gameMeta.winner_user_id) : ""; - if (!wid) return; + // Baseline beim ersten Meta-Load setzen – egal ob Winner existiert oder nicht if (!winnerBaselineRef.current) { winnerBaselineRef.current = true; - lastWinnerIdRef.current = wid; + 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() || @@ -266,6 +268,7 @@ export default function App() { } }, [gameMeta?.winner_user_id, gameMeta?.winner_display_name, gameMeta?.winner_email]); + // ===== Auth actions ===== const doLogin = async () => { await api("/auth/login", { -- 2.49.1 From 770b2cb5316bf843e4a51b27e0520ba81111ade2 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 17:13:42 +0100 Subject: [PATCH 10/10] Enhance WinnerCelebration visuals and confetti effects Updated confetti burst and rain effects with brighter colors, improved motion, and increased visibility on dark overlays. Refined UI styling for better clarity, reduced dimensions for mobile-friendliness, and adjusted overlay opacity for enhanced contrast. Made layout and text updates for improved alignment and readability. --- frontend/src/components/WinnerCelebration.jsx | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/WinnerCelebration.jsx b/frontend/src/components/WinnerCelebration.jsx index d69aecd..ec2244c 100644 --- a/frontend/src/components/WinnerCelebration.jsx +++ b/frontend/src/components/WinnerCelebration.jsx @@ -18,31 +18,40 @@ export default function WinnerCelebration({ open, winnerName, onClose }) { 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: 150, + particleCount: 170, spread: 95, - startVelocity: 38, + startVelocity: 42, origin: { x: 0.12, y: 0.62 }, - zIndex: 999999, + zIndex: TOP_Z, + colors: bright, }); confetti({ - particleCount: 150, + particleCount: 170, spread: 95, - startVelocity: 38, + startVelocity: 42, origin: { x: 0.88, y: 0.62 }, - zIndex: 999999, + zIndex: TOP_Z, + colors: bright, }); // “Rain” über die Zeit (function frame() { confetti({ - particleCount: 6, - spread: 70, - startVelocity: 32, + particleCount: 8, + spread: 75, + startVelocity: 34, origin: { x: Math.random(), y: Math.random() * 0.18 }, - scalar: 1.0, - zIndex: 999999, + scalar: 1.05, + zIndex: TOP_Z, + colors: bright, }); if (Date.now() < end) requestAnimationFrame(frame); })(); @@ -73,24 +82,27 @@ export default function WinnerCelebration({ open, winnerName, onClose }) { position: "fixed", inset: 0, zIndex: 2147483646, - display: "grid", - placeItems: "center", - background: "rgba(0,0,0,0.58)", + display: "flex", + alignItems: "center", + justifyContent: "center", + // weniger dunkel -> Confetti wirkt heller + background: "rgba(0,0,0,0.42)", backdropFilter: "blur(10px)", WebkitBackdropFilter: "blur(10px)", - padding: 12, + padding: 14, }} onMouseDown={onClose} >
e.stopPropagation()} style={{ - width: "min(560px, 92vw)", - borderRadius: 22, - padding: "16px 16px", + // kleiner + mobile friendly + width: "min(420px, 90vw)", + borderRadius: 18, + padding: "14px 14px", border: `1px solid ${stylesTokens.panelBorder}`, background: stylesTokens.panelBg, - boxShadow: "0 22px 90px rgba(0,0,0,0.60)", + boxShadow: "0 18px 70px rgba(0,0,0,0.55)", backdropFilter: "blur(10px)", WebkitBackdropFilter: "blur(10px)", position: "relative", @@ -103,19 +115,19 @@ export default function WinnerCelebration({ open, winnerName, onClose }) { position: "absolute", inset: 0, background: `linear-gradient(90deg, transparent, ${stylesTokens.goldLine}, transparent)`, - opacity: 0.35, + opacity: 0.32, pointerEvents: "none", }} /> - {/* kleine “shine” Ecke oben rechts */} + {/* shine */}
-
-
🏆
+
+
🏆
Spieler{" "} @@ -141,16 +153,16 @@ export default function WinnerCelebration({ open, winnerName, onClose }) { hat die richtige Lösung!
-
+
Fall gelöst. Respekt. ✨
-
+