rewrite impstr in typescript + tailwind, deploy as static pwa

This commit is contained in:
2026-02-26 09:12:28 +01:00
parent 3ccc3c931f
commit 99a81345b6
20 changed files with 788 additions and 769 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
dist/
node_modules/
.DS_Store

250
app.js
View File

@@ -1,250 +0,0 @@
const words = [
"Kekse",
"Schach",
"Giraffe",
"Spaghetti",
"Rakete",
"Blume",
"Kino",
"Drachen",
"Buch",
"Kaffee",
"Berg",
"Pizza",
"Wolke",
"Bus",
"Zitrone",
"Gitarre",
"Meer",
"Bananen",
"Loewe",
"Regen",
];
const state = {
players: [],
currentIndex: 0,
imposterIndex: -1,
secretWord: "",
revealed: false,
discussionIndex: 0,
};
const elements = {
playerInput: document.querySelector("#player-input"),
addPlayer: document.querySelector("#add-player"),
playerList: document.querySelector("#player-list"),
startGame: document.querySelector("#start-game"),
clearPlayers: document.querySelector("#clear-players"),
setupHint: document.querySelector("#setup-hint"),
setup: document.querySelector("#setup"),
round: document.querySelector("#round"),
discussion: document.querySelector("#discussion"),
done: document.querySelector("#done"),
roundCount: document.querySelector("#round-count"),
restart: document.querySelector("#restart"),
currentPlayer: document.querySelector("#current-player"),
roundScreen: document.querySelector("#round-screen"),
handoff: document.querySelector("#handoff"),
revealCard: document.querySelector("#reveal-card"),
revealWord: document.querySelector("#reveal-word"),
discussionCount: document.querySelector("#discussion-count"),
discussionPlayer: document.querySelector("#discussion-player"),
discussionNext: document.querySelector("#discussion-next"),
discussionRestart: document.querySelector("#discussion-restart"),
imposterFound: document.querySelector("#imposter-found"),
playAgain: document.querySelector("#play-again"),
editPlayers: document.querySelector("#edit-players"),
};
const MIN_PLAYERS = 3;
const STORAGE_KEY = "imposterPlayers";
const updatePlayerList = () => {
elements.playerList.innerHTML = "";
state.players.forEach((player, index) => {
const item = document.createElement("li");
const name = document.createElement("span");
name.textContent = player;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Entfernen";
remove.addEventListener("click", () => {
state.players.splice(index, 1);
updatePlayerList();
});
item.append(name, remove);
elements.playerList.appendChild(item);
});
const ready = state.players.length >= MIN_PLAYERS;
elements.startGame.disabled = !ready;
elements.setupHint.textContent = ready
? `${state.players.length} Spieler bereit.`
: `Mindestens ${MIN_PLAYERS} Spieler.`;
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.players));
};
const addPlayer = () => {
const raw = elements.playerInput.value.trim();
if (!raw) {
return;
}
state.players.push(raw);
elements.playerInput.value = "";
updatePlayerList();
elements.playerInput.focus();
};
const startRound = () => {
state.currentIndex = 0;
state.imposterIndex = Math.floor(Math.random() * state.players.length);
state.secretWord = words[Math.floor(Math.random() * words.length)];
state.revealed = false;
elements.revealCard.classList.add("flip-card--instant");
elements.setup.classList.add("hidden");
elements.discussion.classList.add("hidden");
elements.done.classList.add("hidden");
elements.round.classList.remove("hidden");
updateRoundView();
requestAnimationFrame(() => {
elements.revealCard.classList.remove("flip-card--instant");
});
};
const updateRoundView = () => {
const total = state.players.length;
const current = state.currentIndex + 1;
elements.roundCount.textContent = `Phase 1: Zuweisung \u2014 Spieler ${current} / ${total}`;
elements.currentPlayer.textContent = state.players[state.currentIndex];
elements.roundScreen.classList.remove("hidden");
const isImposter = state.currentIndex === state.imposterIndex;
elements.revealWord.textContent = isImposter ? "IMPOSTER" : state.secretWord;
elements.revealCard.classList.remove("flipped");
state.revealed = false;
};
const toggleReveal = () => {
elements.revealCard.classList.toggle("flipped");
state.revealed = !state.revealed;
};
const handoffPlayer = () => {
elements.revealCard.classList.add("flip-card--instant");
elements.revealCard.classList.remove("flipped");
state.currentIndex += 1;
if (state.currentIndex >= state.players.length) {
startDiscussion();
return;
}
updateRoundView();
requestAnimationFrame(() => {
elements.revealCard.classList.remove("flip-card--instant");
});
};
const startDiscussion = () => {
state.discussionIndex = 0;
elements.round.classList.add("hidden");
elements.done.classList.add("hidden");
elements.discussion.classList.remove("hidden");
updateDiscussionView();
};
const updateDiscussionView = () => {
const total = state.players.length;
const current = state.discussionIndex + 1;
elements.discussionCount.textContent = `Phase 2: Runde \u2014 Spieler ${current} / ${total}`;
elements.discussionPlayer.textContent = state.players[state.discussionIndex];
};
const nextDiscussion = () => {
if (state.players.length === 0) {
return;
}
state.discussionIndex = (state.discussionIndex + 1) % state.players.length;
updateDiscussionView();
};
const endDiscussion = () => {
elements.discussion.classList.add("hidden");
elements.done.classList.remove("hidden");
};
const resetToSetup = () => {
elements.round.classList.add("hidden");
elements.discussion.classList.add("hidden");
elements.done.classList.add("hidden");
elements.setup.classList.remove("hidden");
};
const clearPlayers = () => {
state.players = [];
updatePlayerList();
};
const loadPlayers = () => {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) {
return;
}
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
state.players = parsed.filter((name) => typeof name === "string");
}
} catch (error) {
localStorage.removeItem(STORAGE_KEY);
}
};
elements.addPlayer.addEventListener("click", addPlayer);
elements.playerInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
addPlayer();
}
});
elements.clearPlayers.addEventListener("click", clearPlayers);
elements.startGame.addEventListener("click", startRound);
elements.handoff.addEventListener("click", handoffPlayer);
elements.revealCard.addEventListener("click", toggleReveal);
elements.discussionNext.addEventListener("click", nextDiscussion);
elements.discussionRestart.addEventListener("click", startRound);
elements.imposterFound.addEventListener("click", endDiscussion);
elements.restart.addEventListener("click", startRound);
elements.playAgain.addEventListener("click", startRound);
elements.editPlayers.addEventListener("click", resetToSetup);
loadPlayers();
updatePlayerList();
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
const reg = await navigator.serviceWorker.register("./sw.js");
const showUpdate = () => {
document.querySelector("#update-banner").classList.remove("hidden");
document.querySelector("#update-btn").addEventListener("click", () => {
reg.waiting.postMessage("SKIP_WAITING");
});
};
if (reg.waiting) {
showUpdate();
}
reg.addEventListener("updatefound", () => {
const newSW = reg.installing;
newSW.addEventListener("statechange", () => {
if (newSW.state === "installed" && navigator.serviceWorker.controller) {
showUpdate();
}
});
});
navigator.serviceWorker.addEventListener("controllerchange", () => {
window.location.reload();
});
});
}

145
bun.lock Normal file
View File

@@ -0,0 +1,145 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "impstr",
"devDependencies": {
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
},
},
},
"packages": {
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@tailwindcss/cli": ["@tailwindcss/cli@4.2.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.1" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}

19
deploy.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
REMOTE_HOST="serve"
REMOTE_DIR="/var/www/virtual/serve/html/impstr"
echo "==> Installing local dependencies..."
bun install
echo "==> Building..."
bun run build
echo "==> Syncing to ${REMOTE_HOST}:${REMOTE_DIR} ..."
ssh "${REMOTE_HOST}" "mkdir -p ${REMOTE_DIR}"
rsync -avz --delete \
--exclude='.DS_Store' \
dist/ "${REMOTE_HOST}:${REMOTE_DIR}/"
echo "Done. Live at https://serve.uber.space/impstr/"

View File

@@ -1,145 +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.json" />
<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="app">
<section class="screen" id="setup">
<header class="hero">
<div class="hero__tag">Handy weiterreichen</div>
<h1>Imposter</h1>
<p>
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">
<h2>Spieler</h2>
<div class="input-row">
<input
id="player-input"
type="text"
placeholder="Name eingeben"
autocomplete="off"
/>
<button id="add-player" class="btn">Hinzufuegen</button>
</div>
<ul id="player-list" class="player-list"></ul>
<div class="actions">
<button id="clear-players" class="btn btn--ghost">
Liste leeren
</button>
<button id="start-game" class="btn btn--primary">
Runde starten
</button>
</div>
<p id="setup-hint" class="hint">Mindestens 3 Spieler.</p>
</div>
</section>
<section class="screen hidden" id="round">
<div class="card">
<div class="round-header">
<div class="round-count" id="round-count">Spieler 1 / 1</div>
<button id="restart" class="btn btn--ghost">Neue Runde</button>
</div>
<div id="round-screen" class="pass-screen">
<h2>Phase 1: Zuweisung</h2>
<div class="player-chip" id="current-player">Spieler</div>
<div class="flip-card" id="reveal-card">
<div class="flip-card__inner">
<div class="flip-card__front">
<div class="reveal-label">Antippen zum Aufdecken</div>
</div>
<div class="flip-card__back">
<div class="reveal-label">Dein Hinweis</div>
<div class="reveal-word" id="reveal-word">WORT</div>
</div>
</div>
</div>
<p class="hint">
Tippe auf die Karte, um dein Wort zu sehen. Gib das Handy
danach weiter.
</p>
<div class="actions actions--end">
<button id="handoff" class="btn btn--primary btn--big">
Weitergeben
</button>
</div>
</div>
</div>
</section>
<section class="screen hidden" id="discussion">
<div class="card">
<div class="round-header">
<div class="round-count" id="discussion-count">
Diskussion 1 / 1
</div>
<button id="discussion-restart" class="btn btn--ghost">
Neue Runde
</button>
</div>
<div class="pass-screen">
<h2>Wer ist dran?</h2>
<div
class="player-chip player-chip--large"
id="discussion-player"
>
Spieler
</div>
<p class="hint">Gib das Handy weiter.</p>
<div class="actions">
<button id="imposter-found" class="btn btn--ghost">
Imposter gefunden
</button>
<button
id="discussion-next"
class="btn btn--primary btn--big"
>
Weitergeben
</button>
</div>
</div>
</div>
</section>
<section class="screen hidden" id="done">
<div class="card">
<h2>Runde beendet</h2>
<p>Optional: Neue Runde starten oder Spieler bearbeiten.</p>
<div class="actions">
<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>
<div class="update-banner hidden" id="update-banner">
Neue Version verfügbar
<button id="update-btn" class="btn btn--primary">Aktualisieren</button>
</div>
<script src="app.js"></script>
</body>
</html>

2
mise.toml Normal file
View File

@@ -0,0 +1,2 @@
[tools]
bun = "1.3.0"

14
package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "impstr",
"version": "2026.02.26",
"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"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

View File

@@ -1,10 +1,10 @@
const CACHE_NAME = "imposter-static-v4";
const CACHE_NAME = "impstr-v1";
const ASSETS = [
"./",
"./index.html",
"./main.js",
"./styles.css",
"./app.js",
"./manifest.json",
"./manifest.webmanifest",
"./icon-192.png",
"./icon-512.png",
"./apple-touch-icon.png",

18
scripts/copy-assets.ts Normal file
View File

@@ -0,0 +1,18 @@
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}`);
}

179
src/client/input.css Normal file
View File

@@ -0,0 +1,179 @@
@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;
}

269
src/client/main.ts Normal file
View File

@@ -0,0 +1,269 @@
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();
});
});
}

126
src/index.html Normal file
View File

@@ -0,0 +1,126 @@
<!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
&ldquo;IMPOSTER&rdquo;. 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>

View File

@@ -1,371 +0,0 @@
:root {
color-scheme: light;
--bg: #f8f3e7;
--bg-alt: #f0e6d1;
--ink: #1a1a1a;
--accent: #ff7b54;
--accent-dark: #d95c38;
--muted: #5b5b5b;
--card: #fffaf0;
--shadow: 0 20px 45px rgba(26, 26, 26, 0.12);
}
html {
background-color: var(--bg);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Space Grotesk", "Helvetica Neue", Arial, sans-serif;
font-size: 16px;
color: var(--ink);
background-color: var(--bg);
background: radial-gradient(circle at top, #fff4dc, var(--bg));
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.app {
width: 100%;
}
.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 > .card {
width: min(560px, 100%);
}
#round > .card,
#discussion > .card,
#done > .card {
min-height: 480px;
display: flex;
flex-direction: column;
}
#setup {
justify-content: flex-start;
padding-top: 40px;
}
.hero {
display: grid;
gap: 12px;
width: min(560px, 100%);
}
.hero__tag {
display: inline-flex;
align-items: center;
justify-self: start;
padding: 6px 12px;
border-radius: 999px;
background: var(--accent);
color: #fffaf0;
font-weight: 600;
letter-spacing: 0.02em;
}
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;
color: var(--muted);
line-height: 1.5;
font-size: 1rem;
}
.card {
background: var(--card);
border-radius: 24px;
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid rgba(26, 26, 26, 0.08);
}
.input-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
margin-top: 16px;
}
input {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(26, 26, 26, 0.2);
font-size: 1rem;
background: #fff;
}
.btn {
border: none;
padding: 14px 20px;
border-radius: 999px;
font-weight: 600;
cursor: pointer;
background: var(--bg-alt);
color: var(--ink);
transition: box-shadow 0.2s ease;
font-family: "Space Grotesk", sans-serif;
min-width: 0;
min-height: 52px;
}
.btn:hover {
box-shadow: 0 10px 20px rgba(26, 26, 26, 0.15);
}
.btn--primary {
background: var(--accent);
color: #fffaf0;
}
.btn--ghost {
background: transparent;
border: 1px dashed rgba(26, 26, 26, 0.25);
}
.btn--big {
padding: 14px 20px;
font-size: 1rem;
}
.player-list {
list-style: none;
padding: 0;
margin: 16px 0 0;
display: grid;
gap: 10px;
}
.player-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(26, 26, 26, 0.08);
}
.player-list button {
border: none;
background: transparent;
color: var(--accent-dark);
font-weight: 600;
cursor: pointer;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
width: 100%;
margin-top: 20px;
flex-wrap: wrap;
}
.actions--end {
justify-content: flex-end;
}
.round-header {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.hint {
margin-top: 12px;
font-size: 0.95rem;
}
.round-count {
font-weight: 600;
}
.pass-screen {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-top: 16px;
text-align: center;
flex: 1;
}
.pass-screen .actions {
margin-top: auto;
}
.player-chip {
padding: 10px 20px;
border-radius: 999px;
background: var(--bg-alt);
font-weight: 600;
font-size: clamp(1.2rem, 3.8vw, 1.8rem);
text-align: center;
}
.player-chip--large {
font-size: clamp(1.8rem, 5vw, 2.8rem);
letter-spacing: 0.01em;
}
.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);
}
.reveal-label {
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.reveal-word {
font-family: "Unbounded", sans-serif;
font-size: clamp(1.8rem, 6vw, 3rem);
margin-top: 10px;
text-align: center;
}
.footer {
padding: 16px 0 32px;
color: var(--muted);
font-size: 0.9rem;
}
.hidden {
display: none !important;
}
@media (max-width: 640px) {
.input-row {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.round-header {
flex-direction: column;
align-items: stretch;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fadeIn 0.35s ease;
}
.pass-screen .btn {
min-width: 180px;
}
.update-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 24px;
background: var(--ink);
color: #fffaf0;
font-weight: 600;
z-index: 100;
}
.update-banner .btn {
min-height: 40px;
padding: 8px 20px;
}

10
tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"lib": ["ES2020", "DOM"]
},
"include": ["src"]
}