refactor impstr to react/vite stack, update deploy target
migrate from vanilla TypeScript + Bun bundler to React 19, Vite, TanStack Router, Zustand, shadcn-style components, Tailwind v4, vite-plugin-pwa. game logic moves into a Zustand store, UI into React feature components with file-based routing. all 27 tests pass, biome lint clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
*.local
|
||||
|
||||
30
biome.json
Normal file
30
biome.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"useSemanticElements": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["dist/", "node_modules/", "src/routeTree.gen.ts", ".claude/"]
|
||||
}
|
||||
}
|
||||
23
index.html
Normal file
23
index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f8f3e7" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="icon" type="image/svg+xml" href="/impstr/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/impstr/apple-touch-icon.png" />
|
||||
<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=Space+Grotesk:wght@400;500;600;700&family=Unbounded:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Imposter</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
package.json
37
package.json
@@ -1,14 +1,37 @@
|
||||
{
|
||||
"name": "impstr",
|
||||
"version": "2026.02.26",
|
||||
"version": "2026.03.03",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun run build:js && bun run build:css && bun run build:assets",
|
||||
"build:js": "bun build src/client/main.ts --outfile dist/main.js --minify",
|
||||
"build:css": "tailwindcss -i src/client/input.css -o dist/styles.css --minify",
|
||||
"build:assets": "bun run scripts/copy-assets.ts"
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.114.0",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0"
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@tanstack/router-plugin": "^1.114.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.2",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "Imposter",
|
||||
"short_name": "Imposter",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8f3e7",
|
||||
"theme_color": "#f8f3e7",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
45
public/sw.js
45
public/sw.js
@@ -1,45 +0,0 @@
|
||||
const CACHE_NAME = "impstr-v1";
|
||||
const ASSETS = [
|
||||
"./",
|
||||
"./index.html",
|
||||
"./main.js",
|
||||
"./styles.css",
|
||||
"./manifest.webmanifest",
|
||||
"./icon-192.png",
|
||||
"./icon-512.png",
|
||||
"./apple-touch-icon.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data === "SKIP_WAITING") {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
event.respondWith(
|
||||
caches
|
||||
.match(event.request)
|
||||
.then((response) => response || fetch(event.request)),
|
||||
);
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { copyFileSync, mkdirSync } from "fs";
|
||||
|
||||
mkdirSync("dist", { recursive: true });
|
||||
|
||||
const assets: [string, string][] = [
|
||||
["src/index.html", "dist/index.html"],
|
||||
["public/sw.js", "dist/sw.js"],
|
||||
["public/manifest.webmanifest", "dist/manifest.webmanifest"],
|
||||
["public/icon-192.png", "dist/icon-192.png"],
|
||||
["public/icon-512.png", "dist/icon-512.png"],
|
||||
["public/apple-touch-icon.png", "dist/apple-touch-icon.png"],
|
||||
["public/icon.svg", "dist/icon.svg"],
|
||||
];
|
||||
|
||||
for (const [src, dest] of assets) {
|
||||
copyFileSync(src, dest);
|
||||
console.log(`Copied ${src} → ${dest}`);
|
||||
}
|
||||
14
src/app.tsx
Normal file
14
src/app.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
const router = createRouter({ routeTree, basepath: "/impstr" });
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "./main.ts";
|
||||
@source "../index.html";
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light;
|
||||
background-color: #f8f3e7;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #1a1a1a;
|
||||
background: radial-gradient(circle at top, #fff4dc, #f8f3e7);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: "Unbounded", "Trebuchet MS", sans-serif;
|
||||
margin: 0;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 5vw, 3.2rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.5rem, 3.5vw, 2rem);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(26, 26, 26, 0.2);
|
||||
font-size: 1rem;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.screen {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.screen.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fffaf0;
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 20px 45px rgba(26, 26, 26, 0.12);
|
||||
border: 1px solid rgba(26, 26, 26, 0.08);
|
||||
animation: fadeIn 0.35s ease;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
padding: 14px 20px;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: #f0e6d1;
|
||||
color: #1a1a1a;
|
||||
transition: box-shadow 0.2s ease;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
box-shadow: 0 10px 20px rgba(26, 26, 26, 0.15);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: #ff7b54;
|
||||
color: #fffaf0;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(26, 26, 26, 0.25);
|
||||
}
|
||||
|
||||
.flip-card {
|
||||
perspective: 600px;
|
||||
width: min(320px, 80vw);
|
||||
height: 180px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.flip-card__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.5s ease;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.flip-card.flipped .flip-card__inner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flip-card--instant .flip-card__inner {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.flip-card__front,
|
||||
.flip-card__back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: linear-gradient(135deg, #fff4dc, #ffe2c0);
|
||||
box-shadow: inset 0 0 0 1px rgba(26, 26, 26, 0.08);
|
||||
}
|
||||
|
||||
.flip-card__back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Outside any layer — wins over all Tailwind utilities */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
const WORDS: string[] = [
|
||||
"Kekse",
|
||||
"Schach",
|
||||
"Giraffe",
|
||||
"Spaghetti",
|
||||
"Rakete",
|
||||
"Blume",
|
||||
"Kino",
|
||||
"Drachen",
|
||||
"Buch",
|
||||
"Kaffee",
|
||||
"Berg",
|
||||
"Pizza",
|
||||
"Wolke",
|
||||
"Bus",
|
||||
"Zitrone",
|
||||
"Gitarre",
|
||||
"Meer",
|
||||
"Bananen",
|
||||
"Löwe",
|
||||
"Regen",
|
||||
];
|
||||
|
||||
const MIN_PLAYERS = 3;
|
||||
const STORAGE_KEY = "impstr-players";
|
||||
|
||||
interface State {
|
||||
players: string[];
|
||||
currentIndex: number;
|
||||
imposterIndex: number;
|
||||
secretWord: string;
|
||||
revealed: boolean;
|
||||
discussionIndex: number;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
players: [],
|
||||
currentIndex: 0,
|
||||
imposterIndex: -1,
|
||||
secretWord: "",
|
||||
revealed: false,
|
||||
discussionIndex: 0,
|
||||
};
|
||||
|
||||
function q<T extends HTMLElement>(id: string): T {
|
||||
return document.getElementById(id) as T;
|
||||
}
|
||||
|
||||
const el = {
|
||||
playerInput: q<HTMLInputElement>("player-input"),
|
||||
addPlayer: q<HTMLButtonElement>("add-player"),
|
||||
playerList: q<HTMLUListElement>("player-list"),
|
||||
startGame: q<HTMLButtonElement>("start-game"),
|
||||
clearPlayers: q<HTMLButtonElement>("clear-players"),
|
||||
setupHint: q<HTMLParagraphElement>("setup-hint"),
|
||||
setup: q<HTMLElement>("setup"),
|
||||
round: q<HTMLElement>("round"),
|
||||
discussion: q<HTMLElement>("discussion"),
|
||||
done: q<HTMLElement>("done"),
|
||||
roundCount: q<HTMLElement>("round-count"),
|
||||
restart: q<HTMLButtonElement>("restart"),
|
||||
currentPlayer: q<HTMLElement>("current-player"),
|
||||
handoff: q<HTMLButtonElement>("handoff"),
|
||||
revealCard: q<HTMLElement>("reveal-card"),
|
||||
revealWord: q<HTMLElement>("reveal-word"),
|
||||
discussionCount: q<HTMLElement>("discussion-count"),
|
||||
discussionPlayer: q<HTMLElement>("discussion-player"),
|
||||
discussionNext: q<HTMLButtonElement>("discussion-next"),
|
||||
discussionRestart: q<HTMLButtonElement>("discussion-restart"),
|
||||
imposterFound: q<HTMLButtonElement>("imposter-found"),
|
||||
playAgain: q<HTMLButtonElement>("play-again"),
|
||||
editPlayers: q<HTMLButtonElement>("edit-players"),
|
||||
};
|
||||
|
||||
function show(...els: HTMLElement[]): void {
|
||||
els.forEach((e) => e.classList.remove("hidden"));
|
||||
}
|
||||
|
||||
function hide(...els: HTMLElement[]): void {
|
||||
els.forEach((e) => e.classList.add("hidden"));
|
||||
}
|
||||
|
||||
function updatePlayerList(): void {
|
||||
el.playerList.innerHTML = "";
|
||||
state.players.forEach((player, index) => {
|
||||
const item = document.createElement("li");
|
||||
item.className =
|
||||
"flex items-center justify-between px-3 py-2.5 rounded-xl bg-white border border-[rgba(26,26,26,0.08)]";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = player;
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.textContent = "Entfernen";
|
||||
remove.className =
|
||||
"bg-transparent border-0 text-[#d95c38] font-semibold cursor-pointer";
|
||||
remove.addEventListener("click", () => {
|
||||
state.players.splice(index, 1);
|
||||
updatePlayerList();
|
||||
});
|
||||
item.append(name, remove);
|
||||
el.playerList.appendChild(item);
|
||||
});
|
||||
|
||||
const ready = state.players.length >= MIN_PLAYERS;
|
||||
el.startGame.disabled = !ready;
|
||||
el.setupHint.textContent = ready
|
||||
? `${state.players.length} Spieler bereit.`
|
||||
: `Mindestens ${MIN_PLAYERS} Spieler.`;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.players));
|
||||
}
|
||||
|
||||
function addPlayer(): void {
|
||||
const raw = el.playerInput.value.trim();
|
||||
if (!raw) return;
|
||||
state.players.push(raw);
|
||||
el.playerInput.value = "";
|
||||
updatePlayerList();
|
||||
el.playerInput.focus();
|
||||
}
|
||||
|
||||
function startRound(): void {
|
||||
state.currentIndex = 0;
|
||||
state.imposterIndex = Math.floor(Math.random() * state.players.length);
|
||||
state.secretWord = WORDS[Math.floor(Math.random() * WORDS.length)];
|
||||
state.revealed = false;
|
||||
|
||||
el.revealCard.classList.add("flip-card--instant");
|
||||
el.revealCard.classList.remove("flipped");
|
||||
|
||||
show(el.round);
|
||||
hide(el.setup, el.discussion, el.done);
|
||||
updateRoundView();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.revealCard.classList.remove("flip-card--instant");
|
||||
});
|
||||
}
|
||||
|
||||
function updateRoundView(): void {
|
||||
const total = state.players.length;
|
||||
const current = state.currentIndex + 1;
|
||||
el.roundCount.textContent = `Phase 1: Zuweisung \u2014 Spieler ${current}\u00a0/\u00a0${total}`;
|
||||
el.currentPlayer.textContent = state.players[state.currentIndex];
|
||||
el.revealWord.textContent =
|
||||
state.currentIndex === state.imposterIndex ? "IMPOSTER" : state.secretWord;
|
||||
el.revealCard.classList.remove("flipped");
|
||||
state.revealed = false;
|
||||
}
|
||||
|
||||
function toggleReveal(): void {
|
||||
el.revealCard.classList.toggle("flipped");
|
||||
state.revealed = !state.revealed;
|
||||
}
|
||||
|
||||
function handoffPlayer(): void {
|
||||
el.revealCard.classList.add("flip-card--instant");
|
||||
el.revealCard.classList.remove("flipped");
|
||||
state.currentIndex += 1;
|
||||
if (state.currentIndex >= state.players.length) {
|
||||
startDiscussion();
|
||||
return;
|
||||
}
|
||||
updateRoundView();
|
||||
requestAnimationFrame(() => {
|
||||
el.revealCard.classList.remove("flip-card--instant");
|
||||
});
|
||||
}
|
||||
|
||||
function startDiscussion(): void {
|
||||
state.discussionIndex = 0;
|
||||
show(el.discussion);
|
||||
hide(el.round, el.done);
|
||||
updateDiscussionView();
|
||||
}
|
||||
|
||||
function updateDiscussionView(): void {
|
||||
const total = state.players.length;
|
||||
const current = state.discussionIndex + 1;
|
||||
el.discussionCount.textContent = `Phase 2: Diskussion \u2014 Spieler ${current}\u00a0/\u00a0${total}`;
|
||||
el.discussionPlayer.textContent = state.players[state.discussionIndex];
|
||||
}
|
||||
|
||||
function nextDiscussion(): void {
|
||||
if (state.players.length === 0) return;
|
||||
state.discussionIndex = (state.discussionIndex + 1) % state.players.length;
|
||||
updateDiscussionView();
|
||||
}
|
||||
|
||||
function endDiscussion(): void {
|
||||
show(el.done);
|
||||
hide(el.discussion);
|
||||
}
|
||||
|
||||
function resetToSetup(): void {
|
||||
show(el.setup);
|
||||
hide(el.round, el.discussion, el.done);
|
||||
}
|
||||
|
||||
function clearPlayers(): void {
|
||||
state.players = [];
|
||||
updatePlayerList();
|
||||
}
|
||||
|
||||
function loadPlayers(): void {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
if (Array.isArray(parsed)) {
|
||||
state.players = parsed.filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
el.addPlayer.addEventListener("click", addPlayer);
|
||||
el.playerInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") addPlayer();
|
||||
});
|
||||
el.clearPlayers.addEventListener("click", clearPlayers);
|
||||
el.startGame.addEventListener("click", startRound);
|
||||
el.handoff.addEventListener("click", handoffPlayer);
|
||||
el.revealCard.addEventListener("click", toggleReveal);
|
||||
el.discussionNext.addEventListener("click", nextDiscussion);
|
||||
el.discussionRestart.addEventListener("click", startRound);
|
||||
el.imposterFound.addEventListener("click", endDiscussion);
|
||||
el.restart.addEventListener("click", startRound);
|
||||
el.playAgain.addEventListener("click", startRound);
|
||||
el.editPlayers.addEventListener("click", resetToSetup);
|
||||
|
||||
// Init
|
||||
loadPlayers();
|
||||
updatePlayerList();
|
||||
|
||||
// Service worker
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", async () => {
|
||||
const reg = await navigator.serviceWorker.register("./sw.js");
|
||||
|
||||
const showUpdate = () => {
|
||||
const banner = document.getElementById("update-banner");
|
||||
if (!banner) return;
|
||||
banner.classList.remove("hidden");
|
||||
document.getElementById("update-btn")?.addEventListener("click", () => {
|
||||
(reg.waiting as ServiceWorker | null)?.postMessage("SKIP_WAITING");
|
||||
});
|
||||
};
|
||||
|
||||
if (reg.waiting) showUpdate();
|
||||
|
||||
reg.addEventListener("updatefound", () => {
|
||||
const newSW = reg.installing;
|
||||
if (!newSW) return;
|
||||
newSW.addEventListener("statechange", () => {
|
||||
if (newSW.state === "installed" && navigator.serviceWorker.controller) {
|
||||
showUpdate();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
46
src/features/discussion/discussion-screen.tsx
Normal file
46
src/features/discussion/discussion-screen.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useGameStore } from "@/shared/stores/game-store";
|
||||
|
||||
export function DiscussionScreen() {
|
||||
const players = useGameStore((s) => s.players);
|
||||
const discussionIndex = useGameStore((s) => s.discussionIndex);
|
||||
const nextDiscussion = useGameStore((s) => s.nextDiscussion);
|
||||
const endDiscussion = useGameStore((s) => s.endDiscussion);
|
||||
const resetToSetup = useGameStore((s) => s.resetToSetup);
|
||||
|
||||
const currentPlayer = players[discussionIndex];
|
||||
const isLast = discussionIndex >= players.length - 1;
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6 animate-fade-in">
|
||||
<header className="flex w-full items-center justify-between">
|
||||
<span className="text-sm text-[#5b5b5b]">
|
||||
{discussionIndex + 1} / {players.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetToSetup}
|
||||
className="text-sm text-[#5b5b5b] hover:text-[#1a1a1a] transition-colors cursor-pointer"
|
||||
>
|
||||
Neue Runde
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<h2>Wer ist dran?</h2>
|
||||
|
||||
<Badge className="text-xl px-6 py-2.5">{currentPlayer}</Badge>
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<Button variant="primary" onClick={endDiscussion} className="flex-1">
|
||||
Imposter gefunden
|
||||
</Button>
|
||||
{!isLast && (
|
||||
<Button variant="ghost" onClick={nextDiscussion} className="flex-1">
|
||||
Weitergeben
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/features/done/done-screen.tsx
Normal file
24
src/features/done/done-screen.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useGameStore } from "@/shared/stores/game-store";
|
||||
|
||||
export function DoneScreen() {
|
||||
const startRound = useGameStore((s) => s.startRound);
|
||||
const resetToSetup = useGameStore((s) => s.resetToSetup);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6 animate-fade-in">
|
||||
<div className="w-full rounded-3xl bg-surface p-8 shadow-[0_2px_16px_rgba(0,0,0,0.06)] animate-fade-in">
|
||||
<h2 className="mb-6">Runde beendet!</h2>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button variant="ghost" onClick={resetToSetup} className="w-full">
|
||||
Spieler bearbeiten
|
||||
</Button>
|
||||
<Button variant="primary" onClick={startRound} className="w-full">
|
||||
Neue Runde
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/features/round/flip-card.test.tsx
Normal file
33
src/features/round/flip-card.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FlipCard } from "./flip-card";
|
||||
|
||||
describe("FlipCard", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders with front text when not flipped", () => {
|
||||
render(<FlipCard word="Kekse" flipped={false} onFlip={() => {}} />);
|
||||
expect(screen.getByText("Antippen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kekse")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has flipped class when flipped", () => {
|
||||
render(<FlipCard word="Kekse" flipped={true} onFlip={() => {}} />);
|
||||
const card = screen.getByRole("button");
|
||||
expect(card).toHaveClass("flipped");
|
||||
});
|
||||
|
||||
it("calls onFlip when clicked", () => {
|
||||
const onFlip = vi.fn();
|
||||
render(<FlipCard word="Kekse" flipped={false} onFlip={onFlip} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onFlip).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("displays IMPOSTER for the imposter", () => {
|
||||
render(<FlipCard word="IMPOSTER" flipped={true} onFlip={() => {}} />);
|
||||
expect(screen.getByText("IMPOSTER")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
42
src/features/round/flip-card.tsx
Normal file
42
src/features/round/flip-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface FlipCardProps {
|
||||
word: string;
|
||||
flipped: boolean;
|
||||
onFlip: () => void;
|
||||
}
|
||||
|
||||
export function FlipCard({ word, flipped, onFlip }: FlipCardProps) {
|
||||
const [instant, setInstant] = useState(false);
|
||||
|
||||
// When word changes (new player), instantly reset to front without animation.
|
||||
// word is intentionally a dependency — we want to trigger on player change.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: word triggers the reset
|
||||
useEffect(() => {
|
||||
setInstant(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setInstant(false);
|
||||
});
|
||||
});
|
||||
}, [word]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flip-card", flipped && "flipped", instant && "flip-card--instant")}
|
||||
onClick={onFlip}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") onFlip();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={flipped ? word : "Karte antippen zum Aufdecken"}
|
||||
>
|
||||
<div className="flip-card-inner">
|
||||
<div className="flip-card-front">Antippen</div>
|
||||
<div className="flip-card-back">{word}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/features/round/round-screen.tsx
Normal file
50
src/features/round/round-screen.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useGameStore } from "@/shared/stores/game-store";
|
||||
import { FlipCard } from "./flip-card";
|
||||
|
||||
export function RoundScreen() {
|
||||
const players = useGameStore((s) => s.players);
|
||||
const currentIndex = useGameStore((s) => s.currentIndex);
|
||||
const imposterIndex = useGameStore((s) => s.imposterIndex);
|
||||
const secretWord = useGameStore((s) => s.secretWord);
|
||||
const revealed = useGameStore((s) => s.revealed);
|
||||
const toggleReveal = useGameStore((s) => s.toggleReveal);
|
||||
const handoff = useGameStore((s) => s.handoff);
|
||||
const resetToSetup = useGameStore((s) => s.resetToSetup);
|
||||
|
||||
const currentPlayer = players[currentIndex];
|
||||
const isImposter = currentIndex === imposterIndex;
|
||||
const displayWord = isImposter ? "IMPOSTER" : secretWord;
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6 animate-fade-in">
|
||||
<header className="flex w-full items-center justify-between">
|
||||
<span className="text-sm text-[#5b5b5b]">
|
||||
{currentIndex + 1} / {players.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetToSetup}
|
||||
className="text-sm text-[#5b5b5b] hover:text-[#1a1a1a] transition-colors cursor-pointer"
|
||||
>
|
||||
Neue Runde
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Badge className="text-base px-4 py-1.5">{currentPlayer}</Badge>
|
||||
|
||||
<FlipCard word={displayWord} flipped={revealed} onFlip={toggleReveal} />
|
||||
|
||||
<p className="text-sm text-[#5b5b5b]">
|
||||
{revealed
|
||||
? "Merke dir dein Wort und gib weiter!"
|
||||
: "Tippe auf die Karte, um dein Wort zu sehen."}
|
||||
</p>
|
||||
|
||||
<Button variant="primary" onClick={handoff}>
|
||||
Weitergeben
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/features/setup/setup-screen.test.tsx
Normal file
68
src/features/setup/setup-screen.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useGameStore } from "@/shared/stores/game-store";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SetupScreen } from "./setup-screen";
|
||||
|
||||
describe("SetupScreen", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useGameStore.setState({
|
||||
players: [],
|
||||
currentIndex: 0,
|
||||
imposterIndex: -1,
|
||||
secretWord: "",
|
||||
revealed: false,
|
||||
discussionIndex: 0,
|
||||
phase: "setup",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the setup screen", () => {
|
||||
render(<SetupScreen />);
|
||||
expect(screen.getByText("Imposter")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Spieler hinzufügen…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a player via button click", () => {
|
||||
render(<SetupScreen />);
|
||||
const input = screen.getByPlaceholderText("Spieler hinzufügen…");
|
||||
fireEvent.change(input, { target: { value: "Alice" } });
|
||||
fireEvent.click(screen.getByText("+"));
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a player via Enter key", () => {
|
||||
render(<SetupScreen />);
|
||||
const input = screen.getByPlaceholderText("Spieler hinzufügen…");
|
||||
fireEvent.change(input, { target: { value: "Bob" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes a player", () => {
|
||||
useGameStore.setState({ players: ["Alice", "Bob"] });
|
||||
render(<SetupScreen />);
|
||||
const removeButtons = screen.getAllByText("×");
|
||||
fireEvent.click(removeButtons[0]);
|
||||
expect(screen.queryByText("Alice")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables start button with fewer than 3 players", () => {
|
||||
useGameStore.setState({ players: ["Alice", "Bob"] });
|
||||
render(<SetupScreen />);
|
||||
const startButton = screen.getByText("Spiel starten");
|
||||
expect(startButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables start button with 3+ players", () => {
|
||||
useGameStore.setState({ players: ["Alice", "Bob", "Charlie"] });
|
||||
render(<SetupScreen />);
|
||||
const startButton = screen.getByText("Spiel starten");
|
||||
expect(startButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
97
src/features/setup/setup-screen.tsx
Normal file
97
src/features/setup/setup-screen.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { MIN_PLAYERS, useGameStore } from "@/shared/stores/game-store";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function SetupScreen() {
|
||||
const players = useGameStore((s) => s.players);
|
||||
const addPlayer = useGameStore((s) => s.addPlayer);
|
||||
const removePlayer = useGameStore((s) => s.removePlayer);
|
||||
const clearPlayers = useGameStore((s) => s.clearPlayers);
|
||||
const loadPlayers = useGameStore((s) => s.loadPlayers);
|
||||
const startRound = useGameStore((s) => s.startRound);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlayers();
|
||||
}, [loadPlayers]);
|
||||
|
||||
const canStart = players.length >= MIN_PLAYERS;
|
||||
|
||||
function handleAdd() {
|
||||
if (!input.trim()) return;
|
||||
addPlayer(input);
|
||||
setInput("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
handleAdd();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6 animate-fade-in">
|
||||
<header className="flex flex-col gap-3">
|
||||
<Badge className="self-center">Partyspiel</Badge>
|
||||
<h1>Imposter</h1>
|
||||
<p className="text-[#5b5b5b]">Findet heraus, wer das geheime Wort nicht kennt!</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-3xl bg-surface p-6 shadow-[0_2px_16px_rgba(0,0,0,0.06)] animate-fade-in">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Spieler hinzufügen…"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button onClick={handleAdd} className="shrink-0">
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{players.length > 0 && (
|
||||
<ul className="mt-4 flex flex-col gap-2">
|
||||
{players.map((player, i) => (
|
||||
<li
|
||||
key={`${player}-${i}`}
|
||||
className="flex items-center justify-between rounded-xl bg-surface-dark px-4 py-2.5"
|
||||
>
|
||||
<span className="font-medium">{player}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePlayer(i)}
|
||||
className="text-[#5b5b5b] hover:text-[#1a1a1a] transition-colors cursor-pointer text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
{players.length > 0 && (
|
||||
<Button variant="ghost" onClick={clearPlayers} className="flex-1">
|
||||
Alle löschen
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={startRound} disabled={!canStart} className="flex-1">
|
||||
Spiel starten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canStart && players.length > 0 && (
|
||||
<p className="text-sm text-[#5b5b5b]">Mindestens {MIN_PLAYERS} Spieler benötigt</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/index.css
Normal file
101
src/index.css
Normal file
@@ -0,0 +1,101 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #ff7b54;
|
||||
--color-bg: #f8f3e7;
|
||||
--color-surface: #fffaf0;
|
||||
--color-surface-dark: #f0e6d1;
|
||||
|
||||
--font-heading: "Unbounded", sans-serif;
|
||||
--font-body: "Space Grotesk", sans-serif;
|
||||
|
||||
--animate-fade-in: fade-in 0.35s ease;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* flip-card — 3D CSS that can't be expressed as Tailwind utilities */
|
||||
.flip-card {
|
||||
perspective: 800px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
height: 180px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flip-card-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.5s ease;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.flip-card.flipped .flip-card-inner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flip-card--instant .flip-card-inner {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.flip-card-front,
|
||||
.flip-card-back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backface-visibility: hidden;
|
||||
border-radius: 16px;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
font-size: clamp(1.3rem, 4vw, 1.8rem);
|
||||
}
|
||||
|
||||
.flip-card-front {
|
||||
background: var(--color-surface-dark);
|
||||
color: #5b5b5b;
|
||||
}
|
||||
|
||||
.flip-card-back {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-bg);
|
||||
color: #1a1a1a;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 5vw, 3.2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.5rem, 3.5vw, 2rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
126
src/index.html
126
src/index.html
@@ -1,126 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f8f3e7" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>Imposter</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<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=Unbounded:wght@300;500;700&family=Space+Grotesk:wght@400;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<main class="w-full">
|
||||
|
||||
<!-- Setup -->
|
||||
<section id="setup" class="screen" style="justify-content:flex-start;padding-top:40px;">
|
||||
<header class="grid gap-3 w-full max-w-[560px]">
|
||||
<span class="inline-flex items-center self-start px-3 py-1.5 rounded-full bg-[#ff7b54] text-[#fffaf0] font-semibold text-sm tracking-wide">
|
||||
Handy weiterreichen
|
||||
</span>
|
||||
<h1>Imposter</h1>
|
||||
<p class="text-[#5b5b5b]">
|
||||
Trage Spieler ein, starte die Runde, und zeige jedem sein Wort oder
|
||||
“IMPOSTER”. Danach findet ihr heraus, wer das Wort nicht kennt.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card w-full max-w-[560px]">
|
||||
<h2>Spieler</h2>
|
||||
<div class="grid grid-cols-[1fr_auto] max-sm:grid-cols-1 gap-3 mt-4">
|
||||
<input
|
||||
id="player-input"
|
||||
type="text"
|
||||
placeholder="Name eingeben"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="add-player" class="btn">Hinzufügen</button>
|
||||
</div>
|
||||
<ul id="player-list" class="list-none p-0 m-0 mt-4 grid gap-2.5"></ul>
|
||||
<div class="flex max-sm:flex-col-reverse max-sm:items-stretch justify-between items-center gap-3 mt-5 flex-wrap">
|
||||
<button id="clear-players" class="btn btn--ghost">Liste leeren</button>
|
||||
<button id="start-game" class="btn btn--primary" disabled>Runde starten</button>
|
||||
</div>
|
||||
<p id="setup-hint" class="mt-3 text-[0.95rem] text-[#5b5b5b]">Mindestens 3 Spieler.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Round -->
|
||||
<section id="round" class="screen hidden">
|
||||
<div class="card w-full max-w-[560px] flex flex-col" style="min-height:480px;">
|
||||
<div class="flex max-sm:flex-col max-sm:items-stretch flex-wrap items-center justify-between gap-3">
|
||||
<div id="round-count" class="font-semibold">Phase 1: Zuweisung — Spieler 1 / 1</div>
|
||||
<button id="restart" class="btn btn--ghost">Neue Runde</button>
|
||||
</div>
|
||||
<div id="round-screen" class="flex flex-col items-center gap-4 mt-4 text-center flex-1">
|
||||
<div id="current-player" class="px-5 py-2.5 rounded-full bg-[#f0e6d1] font-semibold" style="font-size:clamp(1.2rem,3.8vw,1.8rem);"></div>
|
||||
<div id="reveal-card" class="flip-card">
|
||||
<div class="flip-card__inner">
|
||||
<div class="flip-card__front">
|
||||
<div class="text-[0.95rem] uppercase tracking-[0.08em] text-[#5b5b5b]">Antippen zum Aufdecken</div>
|
||||
</div>
|
||||
<div class="flip-card__back">
|
||||
<div class="text-[0.95rem] uppercase tracking-[0.08em] text-[#5b5b5b]">Dein Hinweis</div>
|
||||
<div id="reveal-word" class="mt-2.5 text-center" style="font-family:'Unbounded',sans-serif;font-size:clamp(1.8rem,6vw,3rem);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[0.95rem] text-[#5b5b5b]">
|
||||
Tippe auf die Karte, um dein Wort zu sehen. Gib das Handy danach weiter.
|
||||
</p>
|
||||
<div class="flex justify-end items-center gap-3 mt-auto w-full max-sm:flex-col-reverse max-sm:items-stretch flex-wrap">
|
||||
<button id="handoff" class="btn btn--primary" style="min-width:180px;">Weitergeben</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Discussion -->
|
||||
<section id="discussion" class="screen hidden">
|
||||
<div class="card w-full max-w-[560px] flex flex-col" style="min-height:480px;">
|
||||
<div class="flex max-sm:flex-col max-sm:items-stretch flex-wrap items-center justify-between gap-3">
|
||||
<div id="discussion-count" class="font-semibold">Phase 2: Diskussion — Spieler 1 / 1</div>
|
||||
<button id="discussion-restart" class="btn btn--ghost">Neue Runde</button>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-4 mt-4 text-center flex-1">
|
||||
<h2>Wer ist dran?</h2>
|
||||
<div id="discussion-player" class="px-5 py-2.5 rounded-full bg-[#f0e6d1] font-semibold text-center tracking-[0.01em]" style="font-size:clamp(1.8rem,5vw,2.8rem);"></div>
|
||||
<p class="text-[0.95rem] text-[#5b5b5b]">Gib das Handy weiter.</p>
|
||||
<div class="flex justify-between items-center gap-3 mt-auto w-full max-sm:flex-col-reverse max-sm:items-stretch flex-wrap">
|
||||
<button id="imposter-found" class="btn btn--ghost">Imposter gefunden</button>
|
||||
<button id="discussion-next" class="btn btn--primary" style="min-width:180px;">Weitergeben</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Done -->
|
||||
<section id="done" class="screen hidden">
|
||||
<div class="card w-full max-w-[560px]">
|
||||
<h2>Runde beendet</h2>
|
||||
<p class="mt-2 text-[#5b5b5b]">Optional: Neue Runde starten oder Spieler bearbeiten.</p>
|
||||
<div class="flex justify-between items-center gap-3 mt-5 max-sm:flex-col-reverse max-sm:items-stretch flex-wrap">
|
||||
<button id="edit-players" class="btn btn--ghost">Spieler bearbeiten</button>
|
||||
<button id="play-again" class="btn btn--primary">Neue Runde</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Update banner -->
|
||||
<div id="update-banner" class="hidden fixed bottom-0 left-0 right-0 flex items-center justify-center gap-4 px-6 py-4 bg-[#1a1a1a] text-[#fffaf0] font-semibold z-50">
|
||||
Neue Version verfügbar
|
||||
<button id="update-btn" class="btn btn--primary" style="min-height:40px;padding:8px 20px;">Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
src/main.tsx
Normal file
11
src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
import "./index.css";
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: root element is guaranteed by index.html
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
113
src/routeTree.gen.ts
Normal file
113
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as RoundRouteImport } from './routes/round'
|
||||
import { Route as DoneRouteImport } from './routes/done'
|
||||
import { Route as DiscussionRouteImport } from './routes/discussion'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const RoundRoute = RoundRouteImport.update({
|
||||
id: '/round',
|
||||
path: '/round',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DoneRoute = DoneRouteImport.update({
|
||||
id: '/done',
|
||||
path: '/done',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DiscussionRoute = DiscussionRouteImport.update({
|
||||
id: '/discussion',
|
||||
path: '/discussion',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/discussion': typeof DiscussionRoute
|
||||
'/done': typeof DoneRoute
|
||||
'/round': typeof RoundRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/discussion': typeof DiscussionRoute
|
||||
'/done': typeof DoneRoute
|
||||
'/round': typeof RoundRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/discussion': typeof DiscussionRoute
|
||||
'/done': typeof DoneRoute
|
||||
'/round': typeof RoundRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/discussion' | '/done' | '/round'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/discussion' | '/done' | '/round'
|
||||
id: '__root__' | '/' | '/discussion' | '/done' | '/round'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DiscussionRoute: typeof DiscussionRoute
|
||||
DoneRoute: typeof DoneRoute
|
||||
RoundRoute: typeof RoundRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/round': {
|
||||
id: '/round'
|
||||
path: '/round'
|
||||
fullPath: '/round'
|
||||
preLoaderRoute: typeof RoundRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/done': {
|
||||
id: '/done'
|
||||
path: '/done'
|
||||
fullPath: '/done'
|
||||
preLoaderRoute: typeof DoneRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/discussion': {
|
||||
id: '/discussion'
|
||||
path: '/discussion'
|
||||
fullPath: '/discussion'
|
||||
preLoaderRoute: typeof DiscussionRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DiscussionRoute: DiscussionRoute,
|
||||
DoneRoute: DoneRoute,
|
||||
RoundRoute: RoundRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
18
src/routes/__root.tsx
Normal file
18
src/routes/__root.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { UpdateBanner } from "../shared/components/update-banner";
|
||||
import { usePhaseNavigation } from "../shared/hooks/use-phase-navigation";
|
||||
|
||||
function RootLayout() {
|
||||
usePhaseNavigation();
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center px-5 py-10 text-center">
|
||||
<Outlet />
|
||||
<UpdateBanner />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
12
src/routes/discussion.tsx
Normal file
12
src/routes/discussion.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { DiscussionScreen } from "../features/discussion/discussion-screen";
|
||||
import { useGameStore } from "../shared/stores/game-store";
|
||||
|
||||
export const Route = createFileRoute("/discussion")({
|
||||
beforeLoad: () => {
|
||||
if (useGameStore.getState().phase !== "discussion") {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
component: DiscussionScreen,
|
||||
});
|
||||
12
src/routes/done.tsx
Normal file
12
src/routes/done.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { DoneScreen } from "../features/done/done-screen";
|
||||
import { useGameStore } from "../shared/stores/game-store";
|
||||
|
||||
export const Route = createFileRoute("/done")({
|
||||
beforeLoad: () => {
|
||||
if (useGameStore.getState().phase !== "done") {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
component: DoneScreen,
|
||||
});
|
||||
15
src/routes/index.tsx
Normal file
15
src/routes/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { SetupScreen } from "../features/setup/setup-screen";
|
||||
import { useGameStore } from "../shared/stores/game-store";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: () => {
|
||||
const phase = useGameStore.getState().phase;
|
||||
if (phase !== "setup") {
|
||||
throw redirect({
|
||||
to: `/${phase === "round" ? "round" : phase === "discussion" ? "discussion" : "done"}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
component: SetupScreen,
|
||||
});
|
||||
12
src/routes/round.tsx
Normal file
12
src/routes/round.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { RoundScreen } from "../features/round/round-screen";
|
||||
import { useGameStore } from "../shared/stores/game-store";
|
||||
|
||||
export const Route = createFileRoute("/round")({
|
||||
beforeLoad: () => {
|
||||
if (useGameStore.getState().phase !== "round") {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
component: RoundScreen,
|
||||
});
|
||||
14
src/shared/components/ui/badge.tsx
Normal file
14
src/shared/components/ui/badge.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full bg-surface-dark px-3 py-1 text-sm font-medium text-[#5b5b5b]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
src/shared/components/ui/button.tsx
Normal file
31
src/shared/components/ui/button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { type ButtonHTMLAttributes, forwardRef } from "react";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "primary" | "ghost";
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-full font-semibold text-base transition-all cursor-pointer",
|
||||
"min-h-[52px] px-8",
|
||||
"disabled:opacity-40 disabled:pointer-events-none",
|
||||
variant === "default" &&
|
||||
"bg-[#1a1a1a] text-white hover:shadow-[0_4px_16px_rgba(0,0,0,0.18)]",
|
||||
variant === "primary" &&
|
||||
"bg-primary text-white hover:shadow-[0_4px_16px_rgba(255,123,84,0.35)]",
|
||||
variant === "ghost" &&
|
||||
"bg-transparent border-2 border-[rgba(26,26,26,0.2)] text-[#1a1a1a] hover:border-[rgba(26,26,26,0.4)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
21
src/shared/components/ui/input.tsx
Normal file
21
src/shared/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { type InputHTMLAttributes, forwardRef } from "react";
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full rounded-2xl border-2 border-[rgba(26,26,26,0.08)] bg-surface px-5 py-3.5",
|
||||
"font-body text-base text-[#1a1a1a] placeholder:text-[#5b5b5b]",
|
||||
"outline-none transition-colors focus:border-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
24
src/shared/components/update-banner.tsx
Normal file
24
src/shared/components/update-banner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function UpdateBanner() {
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW();
|
||||
|
||||
if (!needRefresh) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 z-50 flex items-center justify-between gap-4 rounded-2xl bg-[#1a1a1a] px-5 py-3 text-white shadow-lg animate-fade-in">
|
||||
<span className="text-sm">Neue Version verfügbar!</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="min-h-[40px] px-5 text-sm"
|
||||
onClick={() => updateServiceWorker(true)}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/shared/hooks/use-phase-navigation.ts
Normal file
19
src/shared/hooks/use-phase-navigation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { type Phase, useGameStore } from "../stores/game-store";
|
||||
|
||||
const phaseToRoute: Record<Phase, string> = {
|
||||
setup: "/",
|
||||
round: "/round",
|
||||
discussion: "/discussion",
|
||||
done: "/done",
|
||||
};
|
||||
|
||||
export function usePhaseNavigation() {
|
||||
const phase = useGameStore((s) => s.phase);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
navigate({ to: phaseToRoute[phase] });
|
||||
}, [phase, navigate]);
|
||||
}
|
||||
6
src/shared/lib/utils.ts
Normal file
6
src/shared/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
191
src/shared/stores/game-store.test.ts
Normal file
191
src/shared/stores/game-store.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MIN_PLAYERS, useGameStore } from "./game-store";
|
||||
|
||||
function getState() {
|
||||
return useGameStore.getState();
|
||||
}
|
||||
|
||||
describe("game-store", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useGameStore.setState({
|
||||
players: [],
|
||||
currentIndex: 0,
|
||||
imposterIndex: -1,
|
||||
secretWord: "",
|
||||
revealed: false,
|
||||
discussionIndex: 0,
|
||||
phase: "setup",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("addPlayer", () => {
|
||||
it("adds a player and persists to localStorage", () => {
|
||||
getState().addPlayer("Alice");
|
||||
expect(getState().players).toEqual(["Alice"]);
|
||||
expect(JSON.parse(localStorage.getItem("impstr-players") ?? "[]")).toEqual(["Alice"]);
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
getState().addPlayer(" Bob ");
|
||||
expect(getState().players).toEqual(["Bob"]);
|
||||
});
|
||||
|
||||
it("ignores empty input", () => {
|
||||
getState().addPlayer("");
|
||||
getState().addPlayer(" ");
|
||||
expect(getState().players).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePlayer", () => {
|
||||
it("removes player by index", () => {
|
||||
getState().addPlayer("Alice");
|
||||
getState().addPlayer("Bob");
|
||||
getState().addPlayer("Charlie");
|
||||
getState().removePlayer(1);
|
||||
expect(getState().players).toEqual(["Alice", "Charlie"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearPlayers", () => {
|
||||
it("removes all players", () => {
|
||||
getState().addPlayer("Alice");
|
||||
getState().addPlayer("Bob");
|
||||
getState().clearPlayers();
|
||||
expect(getState().players).toEqual([]);
|
||||
expect(JSON.parse(localStorage.getItem("impstr-players") ?? "[]")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadPlayers", () => {
|
||||
it("loads players from localStorage", () => {
|
||||
localStorage.setItem("impstr-players", JSON.stringify(["Alice", "Bob"]));
|
||||
getState().loadPlayers();
|
||||
expect(getState().players).toEqual(["Alice", "Bob"]);
|
||||
});
|
||||
|
||||
it("handles corrupt data gracefully", () => {
|
||||
localStorage.setItem("impstr-players", "not json");
|
||||
getState().loadPlayers();
|
||||
expect(getState().players).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out non-string entries", () => {
|
||||
localStorage.setItem("impstr-players", JSON.stringify(["Alice", 42, null, "Bob"]));
|
||||
getState().loadPlayers();
|
||||
expect(getState().players).toEqual(["Alice", "Bob"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startRound", () => {
|
||||
it("does nothing with fewer than MIN_PLAYERS", () => {
|
||||
getState().addPlayer("Alice");
|
||||
getState().addPlayer("Bob");
|
||||
getState().startRound();
|
||||
expect(getState().phase).toBe("setup");
|
||||
});
|
||||
|
||||
it("transitions to round phase with enough players", () => {
|
||||
getState().addPlayer("Alice");
|
||||
getState().addPlayer("Bob");
|
||||
getState().addPlayer("Charlie");
|
||||
getState().startRound();
|
||||
|
||||
const s = getState();
|
||||
expect(s.phase).toBe("round");
|
||||
expect(s.currentIndex).toBe(0);
|
||||
expect(s.imposterIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(s.imposterIndex).toBeLessThan(3);
|
||||
expect(s.secretWord.length).toBeGreaterThan(0);
|
||||
expect(s.revealed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleReveal", () => {
|
||||
it("toggles the revealed state", () => {
|
||||
expect(getState().revealed).toBe(false);
|
||||
getState().toggleReveal();
|
||||
expect(getState().revealed).toBe(true);
|
||||
getState().toggleReveal();
|
||||
expect(getState().revealed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handoff", () => {
|
||||
beforeEach(() => {
|
||||
getState().addPlayer("Alice");
|
||||
getState().addPlayer("Bob");
|
||||
getState().addPlayer("Charlie");
|
||||
getState().startRound();
|
||||
});
|
||||
|
||||
it("advances to next player", () => {
|
||||
getState().handoff();
|
||||
expect(getState().currentIndex).toBe(1);
|
||||
expect(getState().revealed).toBe(false);
|
||||
});
|
||||
|
||||
it("transitions to discussion after last player", () => {
|
||||
getState().handoff(); // → Bob
|
||||
getState().handoff(); // → Charlie
|
||||
getState().handoff(); // → all seen → discussion
|
||||
expect(getState().phase).toBe("discussion");
|
||||
expect(getState().discussionIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextDiscussion", () => {
|
||||
beforeEach(() => {
|
||||
getState().addPlayer("Alice");
|
||||
getState().addPlayer("Bob");
|
||||
getState().addPlayer("Charlie");
|
||||
getState().startRound();
|
||||
getState().handoff();
|
||||
getState().handoff(); // → discussion
|
||||
});
|
||||
|
||||
it("advances discussion index", () => {
|
||||
getState().nextDiscussion();
|
||||
expect(getState().discussionIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("does not advance past last player", () => {
|
||||
getState().nextDiscussion(); // → 1
|
||||
getState().nextDiscussion(); // → 2 (last)
|
||||
getState().nextDiscussion(); // → still 2
|
||||
expect(getState().discussionIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("endDiscussion", () => {
|
||||
it("transitions to done phase", () => {
|
||||
getState().endDiscussion();
|
||||
expect(getState().phase).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetToSetup", () => {
|
||||
it("resets state to setup phase", () => {
|
||||
getState().addPlayer("Alice");
|
||||
getState().addPlayer("Bob");
|
||||
getState().addPlayer("Charlie");
|
||||
getState().startRound();
|
||||
getState().resetToSetup();
|
||||
|
||||
const s = getState();
|
||||
expect(s.phase).toBe("setup");
|
||||
expect(s.currentIndex).toBe(0);
|
||||
expect(s.imposterIndex).toBe(-1);
|
||||
expect(s.secretWord).toBe("");
|
||||
expect(s.revealed).toBe(false);
|
||||
expect(s.discussionIndex).toBe(0);
|
||||
// players should still be there
|
||||
expect(s.players).toEqual(["Alice", "Bob", "Charlie"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
src/shared/stores/game-store.ts
Normal file
155
src/shared/stores/game-store.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
const WORDS = [
|
||||
"Kekse",
|
||||
"Schach",
|
||||
"Giraffe",
|
||||
"Spaghetti",
|
||||
"Rakete",
|
||||
"Blume",
|
||||
"Kino",
|
||||
"Drachen",
|
||||
"Buch",
|
||||
"Kaffee",
|
||||
"Berg",
|
||||
"Pizza",
|
||||
"Wolke",
|
||||
"Bus",
|
||||
"Zitrone",
|
||||
"Gitarre",
|
||||
"Meer",
|
||||
"Bananen",
|
||||
"Löwe",
|
||||
"Regen",
|
||||
];
|
||||
|
||||
export const MIN_PLAYERS = 3;
|
||||
const STORAGE_KEY = "impstr-players";
|
||||
|
||||
export type Phase = "setup" | "round" | "discussion" | "done";
|
||||
|
||||
export interface GameState {
|
||||
players: string[];
|
||||
currentIndex: number;
|
||||
imposterIndex: number;
|
||||
secretWord: string;
|
||||
revealed: boolean;
|
||||
discussionIndex: number;
|
||||
phase: Phase;
|
||||
}
|
||||
|
||||
export interface GameActions {
|
||||
addPlayer: (name: string) => void;
|
||||
removePlayer: (index: number) => void;
|
||||
clearPlayers: () => void;
|
||||
loadPlayers: () => void;
|
||||
startRound: () => void;
|
||||
toggleReveal: () => void;
|
||||
handoff: () => void;
|
||||
nextDiscussion: () => void;
|
||||
endDiscussion: () => void;
|
||||
resetToSetup: () => void;
|
||||
}
|
||||
|
||||
function savePlayers(players: string[]) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(players));
|
||||
} catch {
|
||||
// storage full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameState & GameActions>((set, get) => ({
|
||||
players: [],
|
||||
currentIndex: 0,
|
||||
imposterIndex: -1,
|
||||
secretWord: "",
|
||||
revealed: false,
|
||||
discussionIndex: 0,
|
||||
phase: "setup",
|
||||
|
||||
addPlayer: (name) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const players = [...get().players, trimmed];
|
||||
savePlayers(players);
|
||||
set({ players });
|
||||
},
|
||||
|
||||
removePlayer: (index) => {
|
||||
const players = get().players.filter((_, i) => i !== index);
|
||||
savePlayers(players);
|
||||
set({ players });
|
||||
},
|
||||
|
||||
clearPlayers: () => {
|
||||
savePlayers([]);
|
||||
set({ players: [] });
|
||||
},
|
||||
|
||||
loadPlayers: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
set({ players: parsed.filter((p): p is string => typeof p === "string") });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// corrupt data — ignore
|
||||
}
|
||||
},
|
||||
|
||||
startRound: () => {
|
||||
const { players } = get();
|
||||
if (players.length < MIN_PLAYERS) return;
|
||||
const imposterIndex = Math.floor(Math.random() * players.length);
|
||||
const secretWord = WORDS[Math.floor(Math.random() * WORDS.length)];
|
||||
set({
|
||||
currentIndex: 0,
|
||||
imposterIndex,
|
||||
secretWord,
|
||||
revealed: false,
|
||||
discussionIndex: 0,
|
||||
phase: "round",
|
||||
});
|
||||
},
|
||||
|
||||
toggleReveal: () => {
|
||||
set((s) => ({ revealed: !s.revealed }));
|
||||
},
|
||||
|
||||
handoff: () => {
|
||||
const { currentIndex, players } = get();
|
||||
const next = currentIndex + 1;
|
||||
if (next < players.length) {
|
||||
set({ currentIndex: next, revealed: false });
|
||||
} else {
|
||||
set({ discussionIndex: 0, phase: "discussion" });
|
||||
}
|
||||
},
|
||||
|
||||
nextDiscussion: () => {
|
||||
const { discussionIndex, players } = get();
|
||||
const next = discussionIndex + 1;
|
||||
if (next < players.length) {
|
||||
set({ discussionIndex: next });
|
||||
}
|
||||
},
|
||||
|
||||
endDiscussion: () => {
|
||||
set({ phase: "done" });
|
||||
},
|
||||
|
||||
resetToSetup: () => {
|
||||
set({
|
||||
currentIndex: 0,
|
||||
imposterIndex: -1,
|
||||
secretWord: "",
|
||||
revealed: false,
|
||||
discussionIndex: 0,
|
||||
phase: "setup",
|
||||
});
|
||||
},
|
||||
}));
|
||||
23
src/test-setup.ts
Normal file
23
src/test-setup.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// Node.js 22+ has a built-in localStorage with limited API that conflicts with jsdom's.
|
||||
// Provide a full in-memory implementation for tests.
|
||||
const store = new Map<string, string>();
|
||||
const localStorageMock: Storage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (index: number) => [...store.keys()][index] ?? null,
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", { value: localStorageMock, writable: true });
|
||||
@@ -1,10 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"lib": ["ES2020", "DOM"]
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "vite-env.d.ts"]
|
||||
}
|
||||
|
||||
2
vite-env.d.ts
vendored
Normal file
2
vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
56
vite.config.ts
Normal file
56
vite.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
base: "/impstr/",
|
||||
plugins: [
|
||||
TanStackRouterVite(),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
manifest: {
|
||||
name: "Imposter",
|
||||
short_name: "Imposter",
|
||||
start_url: "/impstr/",
|
||||
display: "standalone",
|
||||
background_color: "#f8f3e7",
|
||||
theme_color: "#f8f3e7",
|
||||
icons: [
|
||||
{
|
||||
src: "icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,png,svg,woff2}"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["src/test-setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user