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/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
.DS_Store
|
.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",
|
"name": "impstr",
|
||||||
"version": "2026.02.26",
|
"version": "2026.03.03",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run build:js && bun run build:css && bun run build:assets",
|
"dev": "vite",
|
||||||
"build:js": "bun build src/client/main.ts --outfile dist/main.js --minify",
|
"build": "vite build",
|
||||||
"build:css": "tailwindcss -i src/client/input.css -o dist/styles.css --minify",
|
"preview": "vite preview",
|
||||||
"build:assets": "bun run scripts/copy-assets.ts"
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"tailwindcss": "^4.0.0"
|
"@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": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"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