snapshot current state before gitea sync

This commit is contained in:
2026-02-18 10:50:24 +01:00
commit ae74101234
11 changed files with 854 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(ssh:*)",
"Bash(sips:*)",
"Bash(python3:*)",
"WebFetch(domain:manual.uberspace.de)",
"WebFetch(domain:lab.uberspace.de)",
"Bash(curl:*)",
"Bash(rsync:*)"
]
}
}

250
app.js Normal file
View File

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

BIN
apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

8
icon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Imposter">
<rect width="512" height="512" rx="120" fill="#ff7b54"/>
<circle cx="256" cy="210" r="110" fill="#fffaf0"/>
<rect x="140" y="300" width="232" height="120" rx="60" fill="#fffaf0"/>
<circle cx="230" cy="210" r="20" fill="#1a1a1a"/>
<circle cx="282" cy="210" r="20" fill="#1a1a1a"/>
<path d="M200 260c24 26 88 26 112 0" stroke="#1a1a1a" stroke-width="18" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

145
index.html Normal file
View File

@@ -0,0 +1,145 @@
<!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>

22
manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"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"
}
]
}

371
styles.css Normal file
View File

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

45
sw.js Normal file
View File

@@ -0,0 +1,45 @@
const CACHE_NAME = "imposter-static-v4";
const ASSETS = [
"./",
"./index.html",
"./styles.css",
"./app.js",
"./manifest.json",
"./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)),
);
});