Prevent sheet editing after game completion and add game start celebration with enhanced splash screen animations
Added read-only enforcement for finished games by checking `winner_user_id` in backend PATCH endpoint and frontend sheet interactions. Implemented GameStartCelebration component with confetti burst, animated overlay card, and pulsing rune icon. Enhanced splash screen with orbiting border animation, sparkle effects, and title rise transition. Added haptic feedback (vibration) to sheet
This commit is contained in:
@@ -308,6 +308,9 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
|
|||||||
uid = require_user(req, db)
|
uid = require_user(req, db)
|
||||||
g = require_game_member(db, game_id, uid)
|
g = require_game_member(db, game_id, uid)
|
||||||
|
|
||||||
|
if g.winner_user_id:
|
||||||
|
raise HTTPException(403, "game finished; sheet is read-only")
|
||||||
|
|
||||||
status = data.get("status")
|
status = data.get("status")
|
||||||
note_tag = data.get("note_tag")
|
note_tag = data.get("note_tag")
|
||||||
chip = data.get("chip")
|
chip = data.get("chip")
|
||||||
|
|||||||
@@ -94,6 +94,36 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.splash-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 18% 28%;
|
||||||
|
border: 1px solid rgba(233, 216, 166, 0.24);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: rotate(-18deg);
|
||||||
|
animation: orbit 2.8s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-orbit {
|
||||||
|
position: relative;
|
||||||
|
width: 74px;
|
||||||
|
height: 42px;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-orbit::before,
|
||||||
|
.magic-orbit::after {
|
||||||
|
content: "✦";
|
||||||
|
position: absolute;
|
||||||
|
color: rgba(233, 216, 166, 0.92);
|
||||||
|
font-size: 16px;
|
||||||
|
animation: sparkle 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-orbit::before { left: 8px; top: 14px; }
|
||||||
|
.magic-orbit::after { right: 8px; top: 3px; animation-delay: .55s; }
|
||||||
|
|
||||||
.splash-card::before {
|
.splash-card::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -111,6 +141,7 @@
|
|||||||
color: rgba(245, 239, 220, 0.92);
|
color: rgba(245, 239, 220, 0.92);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
|
animation: titleRise 700ms ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splash-sub {
|
.splash-sub {
|
||||||
@@ -156,6 +187,21 @@
|
|||||||
0%, 100% { opacity: 0.35; transform: scaleX(0.92); }
|
0%, 100% { opacity: 0.35; transform: scaleX(0.92); }
|
||||||
50% { opacity: 0.85; transform: scaleX(1.02); }
|
50% { opacity: 0.85; transform: scaleX(1.02); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes orbit {
|
||||||
|
0%, 100% { transform: rotate(-18deg) scale(.94); opacity: .45; }
|
||||||
|
50% { transform: rotate(18deg) scale(1.06); opacity: .9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sparkle {
|
||||||
|
0%, 100% { transform: translateY(3px) scale(.65); opacity: .25; }
|
||||||
|
50% { transform: translateY(-4px) scale(1.2); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes titleRise {
|
||||||
|
from { opacity: 0; transform: translateY(8px); letter-spacing: .18em; }
|
||||||
|
to { opacity: 1; transform: translateY(0); letter-spacing: .06em; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Theme-Key sofort setzen -->
|
<!-- Theme-Key sofort setzen -->
|
||||||
@@ -170,6 +216,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app-splash" aria-hidden="true">
|
<div id="app-splash" aria-hidden="true">
|
||||||
<div class="splash-card">
|
<div class="splash-card">
|
||||||
|
<div class="magic-orbit" aria-hidden="true"></div>
|
||||||
<div class="splash-title">Zauber-Detektiv Notizbogen</div>
|
<div class="splash-title">Zauber-Detektiv Notizbogen</div>
|
||||||
<div class="splash-sub">Magie wird vorbereitet…</div>
|
<div class="splash-sub">Magie wird vorbereitet…</div>
|
||||||
<div class="loader" aria-label="Laden"></div>
|
<div class="loader" aria-label="Laden"></div>
|
||||||
|
|||||||
+41
-1
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import WinnerCelebration from "./components/WinnerCelebration";
|
import WinnerCelebration from "./components/WinnerCelebration";
|
||||||
|
import GameStartCelebration from "./components/GameStartCelebration";
|
||||||
|
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
import { cycleTag } from "./utils/cycleTag";
|
import { cycleTag } from "./utils/cycleTag";
|
||||||
@@ -91,10 +92,13 @@ export default function App() {
|
|||||||
// ===== Winner Celebration =====
|
// ===== Winner Celebration =====
|
||||||
const [celebrateOpen, setCelebrateOpen] = useState(false);
|
const [celebrateOpen, setCelebrateOpen] = useState(false);
|
||||||
const [celebrateName, setCelebrateName] = useState("");
|
const [celebrateName, setCelebrateName] = useState("");
|
||||||
|
const [startCelebrateOpen, setStartCelebrateOpen] = useState(false);
|
||||||
|
|
||||||
// baseline per game: beim ersten Meta-Load NICHT feiern
|
// baseline per game: beim ersten Meta-Load NICHT feiern
|
||||||
const winnerBaselineRef = useRef(false);
|
const winnerBaselineRef = useRef(false);
|
||||||
const lastWinnerIdRef = useRef(null);
|
const lastWinnerIdRef = useRef(null);
|
||||||
|
const gameStartBaselineRef = useRef(false);
|
||||||
|
const lastGameStartedRef = useRef(false);
|
||||||
|
|
||||||
const showSnack = (msg) => {
|
const showSnack = (msg) => {
|
||||||
setSnack(msg);
|
setSnack(msg);
|
||||||
@@ -212,8 +216,11 @@ export default function App() {
|
|||||||
// reset winner celebration baseline when switching games
|
// reset winner celebration baseline when switching games
|
||||||
winnerBaselineRef.current = false;
|
winnerBaselineRef.current = false;
|
||||||
lastWinnerIdRef.current = null;
|
lastWinnerIdRef.current = null;
|
||||||
|
gameStartBaselineRef.current = false;
|
||||||
|
lastGameStartedRef.current = false;
|
||||||
setCelebrateOpen(false);
|
setCelebrateOpen(false);
|
||||||
setCelebrateName("");
|
setCelebrateName("");
|
||||||
|
setStartCelebrateOpen(false);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
@@ -288,6 +295,23 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [gameMeta?.winner_user_id, gameMeta?.winner_display_name, gameMeta?.winner_email]);
|
}, [gameMeta?.winner_user_id, gameMeta?.winner_display_name, gameMeta?.winner_email]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameMeta) return;
|
||||||
|
|
||||||
|
const started = !!gameMeta.started;
|
||||||
|
if (!gameStartBaselineRef.current) {
|
||||||
|
gameStartBaselineRef.current = true;
|
||||||
|
lastGameStartedRef.current = started;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastGameStartedRef.current && started) {
|
||||||
|
setStartCelebrateOpen(true);
|
||||||
|
vibrate([25, 55, 25]);
|
||||||
|
}
|
||||||
|
lastGameStartedRef.current = started;
|
||||||
|
}, [gameMeta?.started]);
|
||||||
|
|
||||||
|
|
||||||
// ===== Auth actions =====
|
// ===== Auth actions =====
|
||||||
const doLogin = async () => {
|
const doLogin = async () => {
|
||||||
@@ -342,8 +366,11 @@ export default function App() {
|
|||||||
// reset winner celebration on logout
|
// reset winner celebration on logout
|
||||||
winnerBaselineRef.current = false;
|
winnerBaselineRef.current = false;
|
||||||
lastWinnerIdRef.current = null;
|
lastWinnerIdRef.current = null;
|
||||||
|
gameStartBaselineRef.current = false;
|
||||||
|
lastGameStartedRef.current = false;
|
||||||
setCelebrateOpen(false);
|
setCelebrateOpen(false);
|
||||||
setCelebrateName("");
|
setCelebrateName("");
|
||||||
|
setStartCelebrateOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Password =====
|
// ===== Password =====
|
||||||
@@ -452,8 +479,11 @@ export default function App() {
|
|||||||
// reset winner celebration baseline for the new game
|
// reset winner celebration baseline for the new game
|
||||||
winnerBaselineRef.current = false;
|
winnerBaselineRef.current = false;
|
||||||
lastWinnerIdRef.current = null;
|
lastWinnerIdRef.current = null;
|
||||||
|
gameStartBaselineRef.current = false;
|
||||||
|
lastGameStartedRef.current = false;
|
||||||
setCelebrateOpen(false);
|
setCelebrateOpen(false);
|
||||||
setCelebrateName("");
|
setCelebrateName("");
|
||||||
|
setStartCelebrateOpen(false);
|
||||||
|
|
||||||
const g = await api("/games", {
|
const g = await api("/games", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -498,6 +528,7 @@ export default function App() {
|
|||||||
|
|
||||||
// ===== Sheet actions =====
|
// ===== Sheet actions =====
|
||||||
const cycleStatus = async (entry) => {
|
const cycleStatus = async (entry) => {
|
||||||
|
if (gameMeta?.winner_user_id) return;
|
||||||
let next = 0;
|
let next = 0;
|
||||||
if (entry.status === 0) next = 2;
|
if (entry.status === 0) next = 2;
|
||||||
else if (entry.status === 2) next = 1;
|
else if (entry.status === 2) next = 1;
|
||||||
@@ -510,11 +541,13 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await reloadSheet();
|
await reloadSheet();
|
||||||
|
vibrate(10);
|
||||||
setPulseId(entry.entry_id);
|
setPulseId(entry.entry_id);
|
||||||
setTimeout(() => setPulseId(null), 220);
|
setTimeout(() => setPulseId(null), 220);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTag = async (entry) => {
|
const toggleTag = async (entry) => {
|
||||||
|
if (gameMeta?.winner_user_id) return;
|
||||||
const next = cycleTag(entry.note_tag);
|
const next = cycleTag(entry.note_tag);
|
||||||
|
|
||||||
if (next === "s") {
|
if (next === "s") {
|
||||||
@@ -535,10 +568,11 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await reloadSheet();
|
await reloadSheet();
|
||||||
|
vibrate(10);
|
||||||
};
|
};
|
||||||
|
|
||||||
const chooseChip = async (chip) => {
|
const chooseChip = async (chip) => {
|
||||||
if (!chipEntry) return;
|
if (!chipEntry || gameMeta?.winner_user_id) return;
|
||||||
|
|
||||||
const entry = chipEntry;
|
const entry = chipEntry;
|
||||||
setChipOpen(false);
|
setChipOpen(false);
|
||||||
@@ -553,6 +587,7 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await reloadSheet();
|
await reloadSheet();
|
||||||
|
vibrate(12);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -637,6 +672,10 @@ export default function App() {
|
|||||||
winnerName={celebrateName}
|
winnerName={celebrateName}
|
||||||
onClose={() => setCelebrateOpen(false)}
|
onClose={() => setCelebrateOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<GameStartCelebration
|
||||||
|
open={startCelebrateOpen}
|
||||||
|
onClose={() => setStartCelebrateOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={styles.bgFixed} aria-hidden="true">
|
<div style={styles.bgFixed} aria-hidden="true">
|
||||||
<div style={styles.bgMap} />
|
<div style={styles.bgMap} />
|
||||||
@@ -694,6 +733,7 @@ export default function App() {
|
|||||||
onCycleStatus={cycleStatus}
|
onCycleStatus={cycleStatus}
|
||||||
onToggleTag={toggleTag}
|
onToggleTag={toggleTag}
|
||||||
displayTag={displayTag}
|
displayTag={displayTag}
|
||||||
|
readOnly={!!gameMeta?.winner_user_id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import confetti from "canvas-confetti";
|
||||||
|
import { stylesTokens } from "../styles/theme";
|
||||||
|
|
||||||
|
export default function GameStartCelebration({ open, onClose }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const burst = () => {
|
||||||
|
confetti({
|
||||||
|
particleCount: 70,
|
||||||
|
spread: 78,
|
||||||
|
startVelocity: 32,
|
||||||
|
origin: { y: 0.62 },
|
||||||
|
colors: ["#e9d8a6", "#7cffa8", "#8fb6ff", "#ffffff"],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
burst();
|
||||||
|
const second = setTimeout(burst, 480);
|
||||||
|
const close = setTimeout(onClose, 3000);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(second);
|
||||||
|
clearTimeout(close);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="hp-start-overlay" onClick={onClose} role="dialog" aria-label="Spiel gestartet">
|
||||||
|
<div className="hp-start-card" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="hp-start-rune">✦</div>
|
||||||
|
<div className="hp-start-kicker">Die Ermittlungen beginnen</div>
|
||||||
|
<div className="hp-start-title">Spiel gestartet</div>
|
||||||
|
<div className="hp-start-subtitle">Möge der beste Detektiv gewinnen.</div>
|
||||||
|
<button onClick={onClose} style={{ marginTop: 18, color: stylesTokens.textGold }}>
|
||||||
|
Los geht's
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export default function SheetSection({
|
|||||||
onCycleStatus,
|
onCycleStatus,
|
||||||
onToggleTag,
|
onToggleTag,
|
||||||
displayTag,
|
displayTag,
|
||||||
|
readOnly = false,
|
||||||
}) {
|
}) {
|
||||||
const getRowBg = (status) => {
|
const getRowBg = (status) => {
|
||||||
if (status === 1) return stylesTokens.rowNoBg;
|
if (status === 1) return stylesTokens.rowNoBg;
|
||||||
@@ -70,14 +71,15 @@ export default function SheetSection({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={() => onCycleStatus(e)}
|
onClick={() => !readOnly && onCycleStatus(e)}
|
||||||
style={{
|
style={{
|
||||||
...styles.name,
|
...styles.name,
|
||||||
textDecoration: effectiveStatus === 1 ? "line-through" : "none",
|
textDecoration: effectiveStatus === 1 ? "line-through" : "none",
|
||||||
color: getNameColor(effectiveStatus),
|
color: getNameColor(effectiveStatus),
|
||||||
opacity: effectiveStatus === 1 ? 0.8 : 1,
|
opacity: effectiveStatus === 1 ? 0.8 : 1,
|
||||||
|
cursor: readOnly ? "default" : "pointer",
|
||||||
}}
|
}}
|
||||||
title="Klick: Grün → Rot → Grau → Leer"
|
title={readOnly ? "Spiel beendet – Notizzettel ist schreibgeschützt" : "Klick: Grün → Rot → Grau → Leer"}
|
||||||
>
|
>
|
||||||
{e.label}
|
{e.label}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,9 +97,10 @@ export default function SheetSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleTag(e)}
|
onClick={() => !readOnly && onToggleTag(e)}
|
||||||
style={styles.tagBtn}
|
style={styles.tagBtn}
|
||||||
title="— → i → m → s.(Chip) → —"
|
disabled={readOnly}
|
||||||
|
title={readOnly ? "Spiel beendet – Notizzettel ist schreibgeschützt" : "— → i → m → s.(Chip) → —"}
|
||||||
>
|
>
|
||||||
{displayTag(e)}
|
{displayTag(e)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -89,6 +89,20 @@ export function useHpGlobalStyles() {
|
|||||||
input:focus, select:focus { border-color: var(--hp-textGold) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--hp-textGold) 16%, transparent); }
|
input:focus, select:focus { border-color: var(--hp-textGold) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--hp-textGold) 16%, transparent); }
|
||||||
.hp-row { transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease; }
|
.hp-row { transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease; }
|
||||||
.hp-row:hover { box-shadow: inset 0 0 0 1px rgba(233,216,166,0.12); }
|
.hp-row:hover { box-shadow: inset 0 0 0 1px rgba(233,216,166,0.12); }
|
||||||
|
.hp-start-overlay { position: fixed; inset: 0; z-index: 2147483646; display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at center, rgba(233,216,166,0.12), rgba(4,4,7,0.88) 48%, rgba(0,0,0,0.96)); animation: hpStartFade 260ms ease-out; }
|
||||||
|
.hp-start-card { position: relative; width: min(420px, 100%); padding: 30px 22px 24px; border: 1px solid rgba(233,216,166,0.34); border-radius: 24px; text-align: center; background: linear-gradient(145deg, rgba(34,31,38,0.96), rgba(10,10,14,0.96)); box-shadow: 0 24px 90px rgba(0,0,0,0.72), 0 0 60px rgba(233,216,166,0.12); animation: hpStartCard 520ms cubic-bezier(.2,.8,.2,1); overflow: hidden; }
|
||||||
|
.hp-start-card::before, .hp-start-card::after { content: ""; position: absolute; left: 12%; right: 12%; height: 1px; background: linear-gradient(90deg, transparent, rgba(233,216,166,0.72), transparent); animation: hpStartLine 1.8s ease-in-out infinite; }
|
||||||
|
.hp-start-card::before { top: 12px; }
|
||||||
|
.hp-start-card::after { bottom: 12px; animation-delay: .45s; }
|
||||||
|
.hp-start-rune { color: var(--hp-textGold); font-size: 32px; line-height: 1; animation: hpStartRune 1.4s ease-in-out infinite; }
|
||||||
|
.hp-start-kicker { margin-top: 14px; color: var(--hp-textDim); font-size: 12px; letter-spacing: .16em; text-transform: uppercase; }
|
||||||
|
.hp-start-title { margin-top: 8px; color: var(--hp-textGold); font-family: "Cinzel Decorative", "IM Fell English", system-ui; font-size: 25px; font-weight: 900; }
|
||||||
|
.hp-start-subtitle { margin-top: 8px; color: var(--hp-textMain); font-size: 15px; }
|
||||||
|
.hp-start-card button { border: 1px solid rgba(233,216,166,0.26); border-radius: 12px; background: rgba(233,216,166,0.10); padding: 10px 16px; font-weight: 900; cursor: pointer; }
|
||||||
|
@keyframes hpStartFade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes hpStartCard { from { opacity: 0; transform: translateY(18px) scale(.92) rotateX(8deg); } to { opacity: 1; transform: translateY(0) scale(1) rotateX(0); } }
|
||||||
|
@keyframes hpStartRune { 0%, 100% { transform: rotate(0) scale(1); opacity: .7; } 50% { transform: rotate(180deg) scale(1.22); opacity: 1; } }
|
||||||
|
@keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } }
|
||||||
.hp-topbar-actions { margin-left: auto; }
|
.hp-topbar-actions { margin-left: auto; }
|
||||||
.hp-user-menu-wrap { min-width: 0; }
|
.hp-user-menu-wrap { min-width: 0; }
|
||||||
.hp-admin-user-row { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.5fr) 70px 76px 86px; }
|
.hp-admin-user-row { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.5fr) 70px 76px 86px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user