Compare commits

..

34 Commits

Author SHA1 Message Date
97ad77f2a4 Merge pull request 'dev' (#6) from dev into main
Reviewed-on: #6
2026-02-07 10:12:30 +00:00
62439d2d28 Enhance loading screen with animation and splash improvements
Updated the loading screen to include animated gold lines, a loader, and a spark effect. Adjusted splash visibility to ensure a minimum display time of 3 seconds and improved transitions for smoother visuals. Enhanced style and structure for better user experience during app initialization.
2026-02-06 18:52:16 +01:00
57cb9a57ef Replace preload mechanism with splash screen
The preload class was replaced by a more user-friendly splash screen design. This change ensures a smoother transition while loading assets and eliminates black background flashes. The splash overlay is automatically hidden and removed after the app is ready, providing a seamless loading experience.
2026-02-06 18:49:25 +01:00
e975d7aa25 Set up immediate theme application and manual SW updates
Moved theme application logic to occur immediately on app initialization to prevent UI flash. Added a check to wait for all fonts to load before making the app visible and adjusted Service Worker behavior to require manual updates instead of auto-reloading.
2026-02-06 18:47:13 +01:00
7c4754e506 "Enhance loading experience and optimize theme application
Added a fallback background and a preload lock for smoother loading transitions. Improved theme application by applying it prior to React rendering and removed theme flash. Adjusted Service Worker registration for better performance and reliability."
2026-02-06 18:44:44 +01:00
070057afb3 Set and persist theme preference in localStorage
Implemented logic to store and retrieve theme preferences using localStorage for both logged-in users and guests. This ensures the selected theme is applied immediately on load, preventing theme flash issues. Adjusted initialization to apply the correct theme at app startup.
2026-02-06 18:41:45 +01:00
6434256dfb Set dynamic theme color for PWA and Android status bar.
Added logic to update the theme-color meta tag dynamically based on the active theme. This improves the visual consistency of the application, particularly for PWA and Android users. Default theme color has also been updated in the configuration.
2026-02-06 18:39:04 +01:00
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
9ff5d0291f Merge pull request 'dev' (#4) from dev into main
Reviewed-on: #4
2026-02-06 13:36:47 +00:00
3cbb4ce89a Enhance new game modal with running game state handling
Introduced functionality to detect and handle ongoing games in the new game modal. The modal now switches between "running" and "choice" modes based on game state, improving clarity for users. Added a dedicated section to display the game code when a game is active.
2026-02-06 14:17:39 +01:00
6b9d4d1295 Add live refresh for game members and metadata
Implemented a useEffect to periodically refresh game member and winner metadata every 2.5 seconds. This ensures new joiners are visible without requiring a page reload, balancing performance and usability.
2026-02-06 14:07:26 +01:00
730b9ed552 Adjust transparency levels for "rowMaybeBg" and "rowEmptyBg".
Reduced the opacity in "rowMaybeBg" and "rowEmptyBg" styles across all themes to improve visual clarity and match the design requirements. These changes ensure a more subtle background appearance in various UI states.
2026-02-06 14:00:32 +01:00
0c983f7e44 Add winner display name support in game metadata
Updated backend to include winner's display name in the game metadata API and frontend to display it alongside the email. This enhances clarity by showing a more user-friendly identifier.
2026-02-06 13:52:19 +01:00
b4b5c7903a Remove redundant email display logic in WinnerBadge component.
Simplified the WinnerBadge component by removing the conditional logic for optionally displaying the email when the display name is available. Updated WinnerCard to use display name as the primary label fallback for members, ensuring cleaner and consistent rendering.
2026-02-06 13:46:02 +01:00
45722e057f Refactor modal overlay styles for consistency and responsiveness
Replaced top/left/right/bottom with a single "inset" property for cleaner and more concise code. Updated padding to account for safe areas dynamically and ensured box-sizing includes padding. Improved responsiveness by using percentages instead of viewport units for width and height.
2026-02-06 13:40:10 +01:00
dc98eeb41c Fix email display in WinnerBadge component
Replaced the incorrect `me.display_name` with `winner.email` to ensure the correct email is shown when `showEmail` is true. This resolves the display issue for winner information.
2026-02-06 13:36:51 +01:00
bdc6824e18 Remove unused displayName variable from WinnerBadge.jsx
The `displayName` variable was declared but not used effectively in the component. This cleanup improves code readability and eliminates unnecessary declarations. Updated the code to directly use `me.display_name`.
2026-02-06 13:35:31 +01:00
1473100498 Add theme_key to user payload in /me endpoint
The /me endpoint now includes the theme_key in the response payload. This ensures the frontend can access the user's theme preference directly.
2026-02-06 13:31:18 +01:00
745b661709 Fix email display logic in WinnerBadge component
Replaced the conditional display of the winner's email with a more robust logic to show either the trimmed display name or the email. This ensures better handling of cases where the display name is unavailable or matches the email.
2026-02-06 13:24:38 +01:00
fa89987f39 Update components to display email instead of display_name
Replaced `display_name` with `email` in `WinnerBadge` and `WinnerCard` components. This ensures email addresses are shown consistently when rendering winner-related information.
2026-02-06 13:22:23 +01:00
59e224b4ca Add user stats feature with API and modal integration
Introduced an endpoint to fetch user stats and integrated it with a new StatsModal component in the frontend. Users can now view game statistics, including played games, wins, losses, and win rates, accessible from the user menu.
2026-02-06 13:14:27 +01:00
bfb1df8e59 Enhance styling consistency and alignment in Admin Panel
Centered elements in the Admin Panel using `justifyItems: "center"`. Adjusted input padding, font size, and primary button styling for improved layout and usability.
2026-02-06 12:56:59 +01:00
b830428251 Refactor AdminPanel: Move function definition above useEffect.
This change reorders the `AdminPanel` function definition to appear before the `useEffect` hook, enhancing readability and maintaining consistent organization. No functionality is altered.
2026-02-06 12:51:20 +01:00
52ace41ac4 Refactor modal logic and implement ModalPortal component
Moved modal rendering logic to a new `ModalPortal` component to improve reusability and separation of concerns. Adjusted styles for better UI consistency, including improved backdrop and modal behavior. Enhanced accessibility by handling escape key events and blocking background scrolling when the modal is open.
2026-02-06 12:43:24 +01:00
18 changed files with 1203 additions and 147 deletions

View File

@@ -40,9 +40,44 @@ def me(req: Request, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == uid).first() user = db.query(User).filter(User.id == uid).first()
if not user: if not user:
raise HTTPException(status_code=401, detail="not logged in") raise HTTPException(status_code=401, detail="not logged in")
return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name} return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name, "theme_key": user.theme_key}
@router.get("/me/stats")
def my_stats(req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
# "played" = games where user is member AND winner is set (finished games)
from sqlalchemy import func
from ..models import Game, GameMember
played = (
db.query(func.count(Game.id))
.join(GameMember, GameMember.game_id == Game.id)
.filter(GameMember.user_id == uid, Game.winner_user_id != None)
.scalar()
or 0
)
wins = (
db.query(func.count(Game.id))
.join(GameMember, GameMember.game_id == Game.id)
.filter(GameMember.user_id == uid, Game.winner_user_id == uid)
.scalar()
or 0
)
losses = max(int(played) - int(wins), 0)
winrate = (float(wins) / float(played) * 100.0) if played else 0.0
return {
"played": int(played),
"wins": int(wins),
"losses": int(losses),
"winrate": round(winrate, 1),
}
@router.patch("/password") @router.patch("/password")
def set_password(data: dict, req: Request, db: Session = Depends(get_db)): def set_password(data: dict, req: Request, db: Session = Depends(get_db)):

View File

@@ -123,9 +123,13 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
g = require_game_member(db, game_id, uid) g = require_game_member(db, game_id, uid)
winner_email = None winner_email = None
winner_display_name = None
if g.winner_user_id: if g.winner_user_id:
wu = db.query(User).filter(User.id == g.winner_user_id).first() wu = db.query(User).filter(User.id == g.winner_user_id).first()
winner_email = wu.email if wu else None if wu:
winner_email = wu.email
winner_display_name = wu.display_name
return { return {
"id": g.id, "id": g.id,
@@ -134,6 +138,7 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
"host_user_id": g.host_user_id, "host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"winner_display_name": winner_display_name,
} }
@@ -150,7 +155,7 @@ def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
.order_by(User.email.asc()) .order_by(User.email.asc())
.all() .all()
) )
return [{"id": u.id, "email": u.email} for u in members] return [{"id": u.id, "email": u.email, "display_name": u.display_name} for u in members]
@router.patch("/{game_id}/winner") @router.patch("/{game_id}/winner")

View File

@@ -3,12 +3,180 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<title>Cluedo Sheet</title> <title>Cluedo Sheet</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=IM+Fell+English:ital@0;1&display=swap" rel="stylesheet"> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=IM+Fell+English:ital@0;1&display=swap"
rel="stylesheet"
/>
<style>
html,
body {
margin: 0;
height: 100%;
background: radial-gradient(
ellipse at top,
rgba(30, 30, 30, 0.95),
#000
);
}
/* Splash Overlay */
#app-splash {
position: fixed;
inset: 0;
z-index: 2147483647;
display: grid;
place-items: center;
background: radial-gradient(
ellipse at top,
rgba(30, 30, 30, 0.95),
#000
);
overflow: hidden;
opacity: 1;
transition: opacity 260ms ease;
}
#app-splash.hide {
opacity: 0;
pointer-events: none;
}
/* gold animated lines */
#app-splash::before,
#app-splash::after {
content: "";
position: absolute;
inset: -40%;
background:
linear-gradient(
90deg,
transparent,
rgba(233, 216, 166, 0.18),
transparent
);
transform: rotate(12deg);
animation: goldSweep 1.6s linear infinite;
opacity: 0.55;
filter: blur(0.2px);
pointer-events: none;
}
#app-splash::after {
transform: rotate(-12deg);
animation-duration: 2.2s;
opacity: 0.35;
}
@keyframes goldSweep {
0% { transform: translateX(-25%) rotate(12deg); }
100% { transform: translateX(25%) rotate(12deg); }
}
/* glassy card */
.splash-card {
position: relative;
width: min(520px, 90vw);
border-radius: 22px;
padding: 18px 16px;
background: rgba(20, 20, 22, 0.55);
border: 1px solid rgba(233, 216, 166, 0.16);
box-shadow: 0 18px 70px rgba(0,0,0,0.55);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
text-align: center;
overflow: hidden;
}
.splash-card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(233,216,166,0.16), transparent);
opacity: 0.45;
pointer-events: none;
}
.splash-title {
position: relative;
font-family: "Cinzel Decorative", serif;
font-weight: 700;
letter-spacing: 0.06em;
color: rgba(245, 239, 220, 0.92);
font-size: 18px;
line-height: 1.25;
}
.splash-sub {
position: relative;
margin-top: 6px;
font-family: "IM Fell English", serif;
font-style: italic;
color: rgba(233, 216, 166, 0.78);
font-size: 13px;
}
/* Loader */
.loader {
position: relative;
margin: 14px auto 0;
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid rgba(233, 216, 166, 0.18);
border-top-color: rgba(233, 216, 166, 0.92);
animation: spin 0.85s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* tiny spark at bottom */
.spark {
position: relative;
margin: 14px auto 0;
width: 160px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(233,216,166,0.65), transparent);
opacity: 0.6;
filter: blur(0.2px);
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.35; transform: scaleX(0.92); }
50% { opacity: 0.85; transform: scaleX(1.02); }
}
</style>
<!-- Theme-Key sofort setzen -->
<script>
try {
const k = localStorage.getItem("hpTheme:guest") || "default";
document.documentElement.setAttribute("data-theme", k);
} catch {}
</script>
</head> </head>
<body> <body>
<div id="app-splash" aria-hidden="true">
<div class="splash-card">
<div class="splash-title">Zauber-Detektiv Notizbogen</div>
<div class="splash-sub">Magie wird vorbereitet…</div>
<div class="loader" aria-label="Laden"></div>
<div class="spark"></div>
</div>
</div>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>

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";
@@ -20,6 +23,7 @@ import DesignModal from "./components/DesignModal";
import WinnerCard from "./components/WinnerCard"; import WinnerCard from "./components/WinnerCard";
import WinnerBadge from "./components/WinnerBadge"; import WinnerBadge from "./components/WinnerBadge";
import NewGameModal from "./components/NewGameModal"; import NewGameModal from "./components/NewGameModal";
import StatsModal from "./components/StatsModal";
export default function App() { export default function App() {
useHpGlobalStyles(); useHpGlobalStyles();
@@ -62,6 +66,44 @@ export default function App() {
// New Game Modal // New Game Modal
const [newGameOpen, setNewGameOpen] = useState(false); const [newGameOpen, setNewGameOpen] = useState(false);
// ===== Stats Modal =====
const [statsOpen, setStatsOpen] = useState(false);
const [stats, setStats] = useState(null);
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 load = async () => {
const m = await api("/auth/me"); const m = await api("/auth/me");
setMe(m); setMe(m);
@@ -84,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
@@ -114,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 {
@@ -124,6 +210,65 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]); }, [gameId]);
// ✅ Live refresh (Members/Meta) damit neue Joiner ohne Reload sichtbar sind
// Für 56 Spieler reicht 2.5s völlig, ist "live genug" und schont Backend.
useEffect(() => {
if (!me || !gameId) return;
let alive = true;
const tick = async () => {
try {
await loadGameMeta(); // refresh members + winner meta
} catch {
// ignore
}
};
// sofort einmal ziehen
tick();
const id = setInterval(() => {
if (!alive) return;
tick();
}, 2500);
return () => {
alive = false;
clearInterval(id);
};
// 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 ===== // ===== Auth actions =====
const doLogin = async () => { const doLogin = async () => {
await api("/auth/login", { await api("/auth/login", {
@@ -142,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 =====
@@ -191,6 +342,14 @@ export default function App() {
setThemeKey(key); setThemeKey(key);
applyTheme(key); applyTheme(key);
// ✅ sofort für nächsten Start merken (verhindert Flash)
try {
localStorage.setItem(`hpTheme:${(me?.email || "guest").toLowerCase()}`, key);
localStorage.setItem("hpTheme:guest", key); // fallback, falls noch nicht eingeloggt
} catch {
// ignore
}
try { try {
await api("/auth/theme", { await api("/auth/theme", {
method: "PATCH", method: "PATCH",
@@ -201,8 +360,49 @@ export default function App() {
} }
}; };
// ===== Stats (always fresh on open) =====
const openStatsModal = async () => {
setUserMenuOpen(false);
setStatsOpen(true);
setStatsError("");
setStatsLoading(true);
try {
const s = await api("/auth/me/stats");
setStats(s);
} catch (e) {
setStats(null);
setStatsError("❌ Fehler: " + (e?.message || "unknown"));
} finally {
setStatsLoading(false);
}
};
const closeStatsModal = () => {
setStatsOpen(false);
setStatsError("");
};
// ===== New game flow ===== // ===== New game flow =====
const createGame = async () => { const createGame = async () => {
// ✅ 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", { 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() }),
@@ -210,9 +410,10 @@ export default function App() {
const gs = await api("/games"); const gs = await api("/games");
setGames(gs); setGames(gs);
// ✅ auf neues Game wechseln (triggert reloadSheet/loadGameMeta via effect)
setGameId(g.id); setGameId(g.id);
// meta/members will load via gameId effect
return g; // includes code return g; // includes code
}; };
@@ -355,6 +556,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>
@@ -366,6 +574,7 @@ export default function App() {
setUserMenuOpen={setUserMenuOpen} setUserMenuOpen={setUserMenuOpen}
openPwModal={openPwModal} openPwModal={openPwModal}
openDesignModal={openDesignModal} openDesignModal={openDesignModal}
openStatsModal={openStatsModal}
doLogout={doLogout} doLogout={doLogout}
onOpenNewGame={() => setNewGameOpen(true)} onOpenNewGame={() => setNewGameOpen(true)}
/> />
@@ -377,10 +586,18 @@ 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 */}
<WinnerBadge winnerEmail={gameMeta?.winner_email || ""} /> <WinnerBadge
winner={{
display_name: gameMeta?.winner_display_name || "",
email: gameMeta?.winner_email || "",
}}
/>
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} /> <HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
@@ -437,6 +654,10 @@ export default function App() {
onClose={() => setNewGameOpen(false)} onClose={() => setNewGameOpen(false)}
onCreate={createGame} onCreate={createGame}
onJoin={joinGame} onJoin={joinGame}
currentCode={gameMeta?.code || ""}
gameFinished={!!gameMeta?.winner_user_id}
hasGame={!!gameId}
currentMembers={members}
/> />
<ChipModal <ChipModal
@@ -444,6 +665,44 @@ export default function App() {
closeChipModalToDash={closeChipModalToDash} closeChipModalToDash={closeChipModalToDash}
chooseChip={chooseChip} chooseChip={chooseChip}
/> />
<StatsModal
open={statsOpen}
onClose={closeStatsModal}
me={me}
stats={stats}
loading={statsLoading}
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,6 +2,7 @@ import React, { useEffect, useState } from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { createPortal } from "react-dom";
export default function AdminPanel() { export default function AdminPanel() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@@ -13,6 +14,17 @@ export default function AdminPanel() {
const [role, setRole] = useState("user"); const [role, setRole] = useState("user");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const loadUsers = async () => { const loadUsers = async () => {
const u = await api("/admin/users"); const u = await api("/admin/users");
setUsers(u); setUsers(u);
@@ -112,7 +124,8 @@ export default function AdminPanel() {
))} ))}
</div> </div>
{open && ( {open &&
createPortal(
<div style={styles.modalOverlay} onMouseDown={closeModal}> <div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}> <div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
@@ -124,7 +137,14 @@ export default function AdminPanel() {
</button> </button>
</div> </div>
<div style={{ marginTop: 12, display: "grid", gap: 8 }}> <div
style={{
marginTop: 12,
display: "grid",
gap: 8,
justifyItems: "center", // <<< zentriert alles
}}
>
<input <input
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
@@ -175,8 +195,10 @@ export default function AdminPanel() {
</div> </div>
</div> </div>
</div> </div>
</div> </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

@@ -0,0 +1,40 @@
import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import { styles } from "../styles/styles";
export default function ModalPortal({ open, onClose, children }) {
useEffect(() => {
if (!open) return;
const onKeyDown = (e) => {
if (e.key === "Escape") onClose?.();
};
// Scroll der Seite sperren
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
style={styles.modalOverlay}
onMouseDown={(e) => {
// Klick außerhalb schließt
if (e.target === e.currentTarget) onClose?.();
}}
>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
);
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
@@ -7,8 +7,14 @@ export default function NewGameModal({
onClose, onClose,
onCreate, onCreate,
onJoin, onJoin,
// ✅ neu:
currentCode = "",
gameFinished = false,
hasGame = false,
}) { }) {
const [mode, setMode] = useState("choice"); // choice | create | join // modes: running | choice | create | join
const [mode, setMode] = useState("choice");
const [joinCode, setJoinCode] = useState(""); const [joinCode, setJoinCode] = useState("");
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [created, setCreated] = useState(null); // { code } const [created, setCreated] = useState(null); // { code }
@@ -16,6 +22,23 @@ export default function NewGameModal({
const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]); const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]);
// ✅ wichtig: beim Öffnen entscheidet der Modus anhand "läuft vs beendet"
useEffect(() => {
if (!open) return;
setErr("");
setToast("");
setJoinCode("");
setCreated(null);
// Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen
if (hasGame && !gameFinished) {
setMode("running");
} else {
setMode("choice");
}
}, [open, hasGame, gameFinished]);
if (!open) return null; if (!open) return null;
const showToast = (msg) => { const showToast = (msg) => {
@@ -44,15 +67,19 @@ export default function NewGameModal({
} }
}; };
const copyCode = async () => { const copyText = async (text, okMsg = "✅ Code kopiert") => {
try { try {
await navigator.clipboard.writeText(created?.code || ""); await navigator.clipboard.writeText(text || "");
showToast("✅ Code kopiert"); showToast(okMsg);
} catch { } catch {
showToast("❌ Copy nicht möglich"); showToast("❌ Copy nicht möglich");
} }
}; };
const codeToShow =
(created?.code || "").trim() ||
(currentCode || "").trim();
return ( return (
<div style={styles.modalOverlay} onMouseDown={onClose}> <div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}> <div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
@@ -85,6 +112,64 @@ export default function NewGameModal({
)} )}
<div style={{ marginTop: 12, display: "grid", gap: 10 }}> <div style={{ marginTop: 12, display: "grid", gap: 10 }}>
{/* ✅ RUNNING: Nur Code anzeigen, keine Choice */}
{mode === "running" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Das Spiel läuft noch. Hier ist der <b>Join-Code</b>:
</div>
<div
style={{
display: "grid",
gap: 8,
padding: 12,
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
textAlign: "center",
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
</div>
<div
style={{
fontSize: 28,
fontWeight: 1100,
letterSpacing: 2,
color: stylesTokens.textGold,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
}}
>
{codeToShow || "—"}
</div>
<button
onClick={() => copyText(codeToShow)}
style={styles.primaryBtn}
disabled={!codeToShow}
title={!codeToShow ? "Kein Code verfügbar" : "Code kopieren"}
>
Code kopieren
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Sobald ein Sieger gesetzt wurde, kannst du hier ein neues Spiel erstellen oder beitreten.
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
</button>
</div>
</>
)}
{/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */}
{mode === "choice" && ( {mode === "choice" && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
@@ -159,7 +244,7 @@ export default function NewGameModal({
{created.code} {created.code}
</div> </div>
<button onClick={copyCode} style={styles.primaryBtn}> <button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
Code kopieren Code kopieren
</button> </button>
</div> </div>

View File

@@ -0,0 +1,101 @@
import React from "react";
import { createPortal } from "react-dom";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
function Tile({ label, value, sub }) {
return (
<div
style={{
borderRadius: 16,
border: `1px solid rgba(233,216,166,0.16)`,
background: "rgba(10,10,12,0.55)",
padding: 12,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
}}
>
<div
style={{
fontSize: 11,
opacity: 0.8,
color: stylesTokens.textDim,
letterSpacing: 0.6,
textTransform: "uppercase",
}}
>
{label}
</div>
<div
style={{
marginTop: 6,
fontWeight: 1000,
fontSize: 26,
lineHeight: "30px",
color: stylesTokens.textGold,
}}
>
{value}
</div>
{sub ? (
<div style={{ marginTop: 2, fontSize: 12, opacity: 0.85, color: stylesTokens.textDim }}>
{sub}
</div>
) : null}
</div>
);
}
export default function StatsModal({ open, onClose, me, stats, loading, error }) {
if (!open) return null;
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
return createPortal(
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Statistik</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName}
</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12 }}>
{loading ? (
<div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}>
Lade Statistik
</div>
) : error ? (
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
}}
>
<Tile label="Gespielte Spiele" value={stats?.played ?? 0} />
<Tile label="Siege" value={stats?.wins ?? 0} />
<Tile label="Verluste" value={stats?.losses ?? 0} />
<Tile label="Siegerate" value={`${stats?.winrate ?? 0}%`} sub="nur beendete Spiele" />
</div>
)}
</div>
<div style={{ marginTop: 12, fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Gespielt zählt nur Spiele mit gesetztem Sieger.
</div>
</div>
</div>,
document.body
);
}

View File

@@ -8,19 +8,16 @@ export default function TopBar({
setUserMenuOpen, setUserMenuOpen,
openPwModal, openPwModal,
openDesignModal, openDesignModal,
openStatsModal,
doLogout, doLogout,
onOpenNewGame, onOpenNewGame,
}) { }) {
const displayName = me const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
? ((me.display_name || "").trim() || me.email)
: "";
return ( return (
<div style={styles.topBar}> <div style={styles.topBar}>
<div> <div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}> <div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
Notizbogen
</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName} {displayName}
</div> </div>
@@ -52,6 +49,18 @@ export default function TopBar({
{me?.email || ""} {me?.email || ""}
</div> </div>
<button
onClick={() => {
setUserMenuOpen(false);
openStatsModal?.();
}}
style={styles.userDropdownItem}
>
Statistik
</button>
<div style={styles.userDropdownDivider} />
<button onClick={openPwModal} style={styles.userDropdownItem}> <button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen Passwort setzen
</button> </button>

View File

@@ -4,8 +4,7 @@ import { stylesTokens } from "../styles/theme";
/** /**
* Props: * Props:
* - winner: { display_name?: string, email?: string } | null * - winner: { display_name?: string, email?: string } | null
* (oder als Fallback:) * - winnerEmail: string | null (legacy fallback)
* - winnerEmail: string | null
*/ */
export default function WinnerBadge({ winner, winnerEmail }) { export default function WinnerBadge({ winner, winnerEmail }) {
const name = const name =
@@ -15,13 +14,6 @@ export default function WinnerBadge({ winner, winnerEmail }) {
if (!name) return null; if (!name) return null;
// Optional: wenn display_name vorhanden ist, Email klein anzeigen
const showEmail =
winner &&
(winner?.email || "").trim() &&
(winner?.display_name || "").trim() &&
winner.email.trim().toLowerCase() !== winner.display_name.trim().toLowerCase();
return ( return (
<div <div
style={{ style={{
@@ -41,18 +33,10 @@ export default function WinnerBadge({ winner, winnerEmail }) {
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontSize: 18 }}>🏆</div> <div style={{ fontSize: 18 }}>🏆</div>
<div style={{ display: "grid", gap: 2 }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}> <div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
Sieger: Sieger:
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span> <span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
</div> </div>
{showEmail && (
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{winner.displayName}
</div>
)}
</div>
</div> </div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>

View File

@@ -23,11 +23,14 @@ export default function WinnerCard({
style={{ ...styles.input, flex: 1 }} style={{ ...styles.input, flex: 1 }}
> >
<option value=""> kein Sieger </option> <option value=""> kein Sieger </option>
{members.map((m) => ( {members.map((m) => {
const dn = ((m.display_name || "").trim() || (m.email || "").trim());
return (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.email} {dn}
</option> </option>
))} );
})}
</select> </select>
<button onClick={onSave} style={styles.primaryBtn}> <button onClick={onSave} style={styles.primaryBtn}>

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);
}

View File

@@ -1,14 +1,51 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
createRoot(document.getElementById("root")).render(<App />); async function bootstrap() {
registerSW({ immediate: true }); // Theme sofort setzen
const updateSW = registerSW({ try {
const key = localStorage.getItem("hpTheme:guest") || DEFAULT_THEME_KEY;
applyTheme(key);
} catch {
applyTheme(DEFAULT_THEME_KEY);
}
// Fonts abwarten (verhindert Layout-Sprung)
try {
if (document.fonts?.ready) {
await document.fonts.ready;
}
} catch {}
// App rendern
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
// Splash mind. 3 Sekunden anzeigen (3000ms)
const MIN_SPLASH_MS = 3000;
const tStart = performance.now();
const hideSplash = () => {
const splash = document.getElementById("app-splash");
if (!splash) return;
splash.classList.add("hide");
setTimeout(() => splash.remove(), 320);
};
const elapsed = performance.now() - tStart;
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
setTimeout(hideSplash, remaining);
// Service Worker ohne Auto-Reload-Flash
registerSW({
immediate: true, immediate: true,
onNeedRefresh() { onNeedRefresh() {
updateSW(true); // sofort neue Version aktivieren console.info("Neue Version verfügbar");
window.location.reload(); // optional: später Toast "Update verfügbar"
}, },
}); });
}
bootstrap();

View File

@@ -122,13 +122,13 @@ export const styles = {
input: { input: {
width: "100%", width: "100%",
padding: 10, padding: "10px 12px",
borderRadius: 12, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.55)", background: "rgba(10,10,12,0.55)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 16, fontSize: 15,
}, },
primaryBtn: { primaryBtn: {
@@ -188,32 +188,27 @@ export const styles = {
// Modal // Modal
modalOverlay: { modalOverlay: {
position: "fixed", position: "fixed",
inset: 0, inset: 0, // statt top/left/right/bottom
background: "rgba(0,0,0,0.78)", // stärker abdunkeln width: "100%", // ✅ NICHT 100vw
backdropFilter: "blur(6px)", // Hintergrund weich (macht viel aus) height: "100%", // ✅ NICHT 100vh
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
padding: 16, padding: "calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))",
zIndex: 9999, boxSizing: "border-box", // wichtig bei padding
animation: "fadeIn 160ms ease-out", zIndex: 2147483647,
overflowY: "auto", // falls Viewport zu klein background: "rgba(0,0,0,0.72)",
overflowY: "auto",
}, },
modalCard: { modalCard: {
width: "100%", width: "min(560px, 100%)",
maxWidth: 560,
borderRadius: 18, borderRadius: 18,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(180deg, rgba(20,20,24,0.95), rgba(12,12,14,0.92))", background: "rgba(12,12,14,0.96)",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
padding: 14, padding: 14,
backdropFilter: "blur(8px)", maxHeight: "calc(100vh - 32px)",
animation: "popIn 160ms ease-out",
color: stylesTokens.textMain,
// neu: damit es nie “kaputt” aussieht
maxHeight: "calc(100dvh - 32px)",
overflow: "auto", overflow: "auto",
}, },
modalHeader: { modalHeader: {

View File

@@ -65,7 +65,7 @@ export const THEMES = {
rowOkText: "#ffd2a8", rowOkText: "#ffd2a8",
rowOkBorder: "rgba(255,184,107,0.55)", rowOkBorder: "rgba(255,184,107,0.55)",
rowMaybeBg: "rgba(140, 140, 140, 0.12)", rowMaybeBg: "rgba(140, 140, 140, 0.04)",
rowMaybeText: "rgba(255,210,170,0.85)", rowMaybeText: "rgba(255,210,170,0.85)",
rowMaybeBorder: "rgba(255,184,107,0.22)", rowMaybeBorder: "rgba(255,184,107,0.22)",
@@ -103,11 +103,11 @@ export const THEMES = {
rowOkText: "rgba(190,255,220,0.92)", rowOkText: "rgba(190,255,220,0.92)",
rowOkBorder: "rgba(124,255,182,0.55)", rowOkBorder: "rgba(124,255,182,0.55)",
rowMaybeBg: "rgba(120, 255, 190, 0.10)", rowMaybeBg: "rgba(120, 255, 190, 0.02)",
rowMaybeText: "rgba(175,240,210,0.85)", rowMaybeText: "rgba(175,240,210,0.85)",
rowMaybeBorder: "rgba(120,255,190,0.22)", rowMaybeBorder: "rgba(120,255,190,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)", rowEmptyBg: "rgba(255,255,255,0.04)",
rowEmptyText: "rgba(175,240,210,0.75)", rowEmptyText: "rgba(175,240,210,0.75)",
rowEmptyBorder: "rgba(0,0,0,0)", rowEmptyBorder: "rgba(0,0,0,0)",
@@ -141,7 +141,7 @@ export const THEMES = {
rowOkText: "rgba(210,230,255,0.92)", rowOkText: "rgba(210,230,255,0.92)",
rowOkBorder: "rgba(143,182,255,0.55)", rowOkBorder: "rgba(143,182,255,0.55)",
rowMaybeBg: "rgba(140, 180, 255, 0.10)", rowMaybeBg: "rgba(140, 180, 255, 0.04)",
rowMaybeText: "rgba(180,205,255,0.85)", rowMaybeText: "rgba(180,205,255,0.85)",
rowMaybeBorder: "rgba(143,182,255,0.22)", rowMaybeBorder: "rgba(143,182,255,0.22)",
@@ -179,7 +179,7 @@ export const THEMES = {
rowOkText: "rgba(255,240,190,0.92)", rowOkText: "rgba(255,240,190,0.92)",
rowOkBorder: "rgba(255,226,122,0.55)", rowOkBorder: "rgba(255,226,122,0.55)",
rowMaybeBg: "rgba(255, 226, 122, 0.10)", rowMaybeBg: "rgba(255, 226, 122, 0.04)",
rowMaybeText: "rgba(255,240,180,0.85)", rowMaybeText: "rgba(255,240,180,0.85)",
rowMaybeBorder: "rgba(255,226,122,0.22)", rowMaybeBorder: "rgba(255,226,122,0.22)",
@@ -195,6 +195,32 @@ export const THEMES = {
export const DEFAULT_THEME_KEY = "default"; export const DEFAULT_THEME_KEY = "default";
export function setThemeColorMeta(color) {
try {
const safe = typeof color === "string" ? color.trim() : "";
if (!safe) return;
// only allow solid colors (hex, rgb, hsl); ignore urls/gradients/rgba overlays
const looksSolid =
safe.startsWith("#") ||
safe.startsWith("rgb(") ||
safe.startsWith("hsl(") ||
safe.startsWith("oklch(");
if (!looksSolid) return;
let meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "theme-color");
document.head.appendChild(meta);
}
meta.setAttribute("content", safe);
} catch {
// ignore
}
}
export function applyTheme(themeKey) { export function applyTheme(themeKey) {
const t = THEMES[themeKey] || THEMES[DEFAULT_THEME_KEY]; const t = THEMES[themeKey] || THEMES[DEFAULT_THEME_KEY];
const root = document.documentElement; const root = document.documentElement;
@@ -202,6 +228,10 @@ export function applyTheme(themeKey) {
for (const [k, v] of Object.entries(t.tokens)) { for (const [k, v] of Object.entries(t.tokens)) {
root.style.setProperty(`--hp-${k}`, v); root.style.setProperty(`--hp-${k}`, v);
} }
// ✅ PWA/Android Statusbar dynamisch an Theme anpassen
// Nimmt (falls vorhanden) statusBarColor, sonst pageBg
setThemeColorMeta(t.tokens.statusBarColor || t.tokens.pageBg || "#000000");
} }
export function themeStorageKey(email) { export function themeStorageKey(email) {

View File

@@ -20,7 +20,7 @@ export default defineConfig({
scope: "/", scope: "/",
display: "standalone", display: "standalone",
background_color: "#1c140d", background_color: "#1c140d",
theme_color: "#caa45a", theme_color: "#000000",
icons: [ icons: [
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" } { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }
] ]