Compare commits

...

11 Commits

Author SHA1 Message Date
bdf18c2aea Merge pull request 'Migrate development brnach into production brunch' (#5) from dev into main
Reviewed-on: #5
2026-02-06 17:30:14 +00:00
770b2cb531 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.
2026-02-06 17:13:42 +01:00
61c7ed6ffe 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.
2026-02-06 17:09:43 +01:00
3a9da788e5 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.
2026-02-06 17:05:19 +01:00
56ef076010 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.
2026-02-06 16:52:17 +01:00
aefb4234d6 Update host icon in GamePickerCard
Replaced the star () icon with a crown (👑) to represent the host. Adjusted labels, tooltip styles, and descriptive text accordingly for improved clarity and consistency.
2026-02-06 15:01:50 +01:00
83893a0060 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.
2026-02-06 14:59:58 +01:00
f555526e64 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.
2026-02-06 14:57:35 +01:00
d4e629b211 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.
2026-02-06 14:53:11 +01:00
85805531c2 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.
2026-02-06 14:46:36 +01:00
7b7b23f52d 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.
2026-02-06 14:37:36 +01:00
5 changed files with 459 additions and 13 deletions

View File

@@ -9,7 +9,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"canvas-confetti": "^1.9.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",

View File

@@ -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 { api } from "./api/client";
import { cycleTag } from "./utils/cycleTag"; import { cycleTag } from "./utils/cycleTag";
@@ -7,6 +9,7 @@ import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
import { styles } from "./styles/styles"; import { styles } from "./styles/styles";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { stylesTokens } from "./styles/theme";
import AdminPanel from "./components/AdminPanel"; import AdminPanel from "./components/AdminPanel";
import LoginPage from "./components/LoginPage"; import LoginPage from "./components/LoginPage";
@@ -69,6 +72,38 @@ export default function App() {
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
const [statsError, setStatsError] = useState(""); 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 load = async () => {
const m = await api("/auth/me"); const m = await api("/auth/me");
setMe(m); setMe(m);
@@ -91,12 +126,46 @@ export default function App() {
const loadGameMeta = async () => { const loadGameMeta = async () => {
if (!gameId) return; if (!gameId) return;
const meta = await api(`/games/${gameId}`); const meta = await api(`/games/${gameId}`);
setGameMeta(meta); setGameMeta(meta);
setWinnerUserId(meta?.winner_user_id || ""); setWinnerUserId(meta?.winner_user_id || "");
const mem = await api(`/games/${gameId}/members`); const mem = await api(`/games/${gameId}/members`);
setMembers(mem); 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 // Dropdown outside click
@@ -121,6 +190,16 @@ export default function App() {
// on game change // on game change
useEffect(() => { 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 () => { (async () => {
if (!gameId) return; if (!gameId) return;
try { try {
@@ -161,6 +240,35 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id, gameId]); }, [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 ===== // ===== Auth actions =====
const doLogin = async () => { const doLogin = async () => {
await api("/auth/login", { await api("/auth/login", {
@@ -179,6 +287,12 @@ export default function App() {
setGameMeta(null); setGameMeta(null);
setMembers([]); setMembers([]);
setWinnerUserId(""); setWinnerUserId("");
// reset winner celebration on logout
winnerBaselineRef.current = false;
lastWinnerIdRef.current = null;
setCelebrateOpen(false);
setCelebrateName("");
}; };
// ===== Password ===== // ===== Password =====
@@ -263,15 +377,23 @@ export default function App() {
// ===== New game flow ===== // ===== New game flow =====
const createGame = async () => { 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); setSheet(null);
setGameMeta(null); setGameMeta(null);
setMembers([]); setMembers([]);
setWinnerUserId(""); setWinnerUserId("");
setPulseId(null); setPulseId(null);
// auch Chip-Modal-State resetten
setChipOpen(false); setChipOpen(false);
setChipEntry(null); setChipEntry(null);
// reset winner celebration baseline for the new game
winnerBaselineRef.current = false;
lastWinnerIdRef.current = null;
setCelebrateOpen(false);
setCelebrateName("");
const g = await api("/games", { const g = await api("/games", {
method: "POST", method: "POST",
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
@@ -280,7 +402,7 @@ export default function App() {
const gs = await api("/games"); const gs = await api("/games");
setGames(gs); setGames(gs);
// ✅ auf neues Spiel wechseln (triggered dann reloadSheet/loadGameMeta via effect) // ✅ auf neues Game wechseln (triggert reloadSheet/loadGameMeta via effect)
setGameId(g.id); setGameId(g.id);
return g; // includes code return g; // includes code
@@ -425,6 +547,13 @@ export default function App() {
return ( return (
<div style={styles.page}> <div style={styles.page}>
{/* Winner Celebration Overlay */}
<WinnerCelebration
open={celebrateOpen}
winnerName={celebrateName}
onClose={() => setCelebrateOpen(false)}
/>
<div style={styles.bgFixed} aria-hidden="true"> <div style={styles.bgFixed} aria-hidden="true">
<div style={styles.bgMap} /> <div style={styles.bgMap} />
</div> </div>
@@ -448,6 +577,9 @@ export default function App() {
gameId={gameId} gameId={gameId}
setGameId={setGameId} setGameId={setGameId}
onOpenHelp={() => setHelpOpen(true)} onOpenHelp={() => setHelpOpen(true)}
members={members}
me={me}
hostUserId={gameMeta?.host_user_id || ""}
/> />
{/* Sieger Badge: zwischen Spiel und Verdächtigte Person */} {/* Sieger Badge: zwischen Spiel und Verdächtigte Person */}
@@ -516,6 +648,7 @@ export default function App() {
currentCode={gameMeta?.code || ""} currentCode={gameMeta?.code || ""}
gameFinished={!!gameMeta?.winner_user_id} gameFinished={!!gameMeta?.winner_user_id}
hasGame={!!gameId} hasGame={!!gameId}
currentMembers={members}
/> />
<ChipModal <ChipModal
@@ -532,6 +665,35 @@ export default function App() {
loading={statsLoading} loading={statsLoading}
error={statsError} error={statsError}
/> />
{/* Bottom snack for joins */}
{snack &&
createPortal(
<div
style={{
position: "fixed",
left: "50%",
bottom: 14,
transform: "translateX(-50%)",
maxWidth: "92vw",
padding: "10px 12px",
borderRadius: 14,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
color: stylesTokens.textMain,
boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
backdropFilter: "blur(6px)",
fontWeight: 900,
fontSize: 13,
textAlign: "center",
zIndex: 2147483647,
pointerEvents: "none",
}}
>
{snack}
</div>,
document.body
)}
</div> </div>
); );
} }

View File

@@ -2,7 +2,46 @@ import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; 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 ( return (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
@@ -26,16 +65,77 @@ export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp })
</button> </button>
</div> </div>
{/* kleine Code Zeile unter dem Picker (optional nice) */} {/* Code Zeile */}
{(() => { {cur?.code && (
const cur = games.find((x) => x.id === gameId); <div
if (!cur?.code) return null; style={{
return ( padding: "0 12px 10px",
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}> 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> Code: <b style={{ color: stylesTokens.textGold }}>{cur.code}</b>
</div> </div>
); </div>
})()} )}
{/* Spieler */}
{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>Spieler</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>
</div> </div>
); );

View File

@@ -168,6 +168,7 @@ export default function NewGameModal({
</> </>
)} )}
{/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */} {/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */}
{mode === "choice" && ( {mode === "choice" && (
<> <>

View File

@@ -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 damits 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 = (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
zIndex: 2147483646,
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: 14,
}}
onMouseDown={onClose}
>
<div
onMouseDown={(e) => 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” */}
<div
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(90deg, transparent, ${stylesTokens.goldLine}, transparent)`,
opacity: 0.32,
pointerEvents: "none",
}}
/>
{/* shine */}
<div
style={{
position: "absolute",
top: -70,
right: -120,
width: 240,
height: 200,
background: `radial-gradient(circle at 30% 30%, ${stylesTokens.goldLine}, transparent 60%)`,
opacity: 0.12,
transform: "rotate(12deg)",
pointerEvents: "none",
}}
/>
<div style={{ position: "relative", display: "grid", gap: 8 }}>
<div style={{ fontSize: 30, lineHeight: 1 }}>🏆</div>
<div
style={{
fontSize: 16,
fontWeight: 900,
color: stylesTokens.textMain,
lineHeight: 1.25,
}}
>
Spieler{" "}
<span style={{ color: stylesTokens.textGold }}>
{winnerName || "Unbekannt"}
</span>{" "}
hat die richtige Lösung!
</div>
<div style={{ color: stylesTokens.textDim, opacity: 0.95, fontSize: 13 }}>
Fall gelöst. Respekt.
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 6 }}>
<button
onClick={onClose}
style={{
padding: "9px 11px",
borderRadius: 12,
border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
}}
>
OK
</button>
</div>
</div>
</div>
</div>
);
return createPortal(node, document.body);
}