feat: Sessie IJsbreker - interactief workshop spel

Browser-based interactief spel voor workshops waarbij deelnemers fysiek
kiezen tussen twee stellingen die op een beamer worden getoond.

Features:
- Presentatie modus met visuele timer rondom scherm
- Timer animatie loopt synchroon rond in opgegeven tijd
- Geluidssignaal bij einde timer
- Overlay met stellingen na timer (grayed out)
- Keyboard shortcuts (spatiebalk voor volgende)
- Direct eindscherm bij laatste stelling

- Web-based stellingen editor
- Flask backend voor config management
- Real-time CRUD operaties op stellingen
- Kleurenpicker voor achtergronden
- Validatie en filtering van lege stellingen
- Volledig offline werkend

Tech stack:
- Frontend: Pure HTML/CSS/JavaScript
- Backend: Python Flask + flask-cors
- Config driven via JSON

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Frank Meeuwsen 2025-11-03 16:23:11 +01:00
commit 7b181a5f12
12 changed files with 1318 additions and 0 deletions

79
.claude/CLAUDE.md Normal file
View file

@ -0,0 +1,79 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Doel
Browser-based interactief workshop spel waarbij deelnemers fysiek kiezen tussen twee stellingen die op een beamer worden getoond. Volledig offline werkend, configureerbaar via JSON.
## Architectuur
**Simpel & Offline First:**
- Pure HTML/CSS/JavaScript (geen frameworks, geen build proces)
- Werkt door `index.html` direct te openen in browser
- Alle configuratie via `config.json` (inclusief kleuren, timing, stellingen)
**Kern Componenten:**
1. **config.json** - Single source of truth voor alle instellingen
- Timer duur, font grootte, kleuren, stellingen, button tekst
- Geladen via fetch API bij app start
2. **index.html** - Drie scherm states
- Statement screen (50/50 split met twee stellingen)
- Blank screen (met volgende knop)
- Finish screen (eindscherm)
3. **style.css** - Timer animatie via CSS
- 4 balkjes (top/right/bottom/left) animeren synchroon
- CSS custom properties voor dynamische timer duur
- Flexbox voor 50/50 split
4. **app.js** - State management en interactie
- Config laden en toepassen op DOM
- Screen state switching
- Web Audio API voor beep geluid
- Keyboard + click event handlers
## Flow
```
Start → Laad config → Toon stelling (timer start) →
Beep + blanco scherm → Knop/spatiebalk →
Volgende stelling → ... → Eindscherm
```
## Configuratie Aanpak
**Alles in config.json - niets hardcoded:**
- Kleuren: Hex codes voor links/rechts achtergrond
- Teksten: Button labels en finish tekst
- Timing: Timer duur in seconden
- Styling: Font grootte als CSS waarde
- Content: Array van stellingen (links/rechts paren)
CSS custom properties worden runtime gezet vanuit config via JavaScript.
## Interactie Pattern
Dual input voor "volgende":
- Click op button (beamer presentatie met muis)
- Spatiebalk (sneller tijdens presentatie)
Timer afloop triggert Web Audio API beep (800Hz sine wave, 300ms met fade out).
## Wijzigingen Maken
- Nieuwe features: Overweeg of het via config.json kan (behoud simpliciteit)
- Styling aanpassingen: Pas config opties aan waar mogelijk, geen hardcoded values
- Geluid wijzigen: Pas Web Audio parameters in `playBeep()` functie
- Extra scherm states: Volg bestaand pattern (hidden class toggles)
## Testing
Open `index.html` lokaal in browser. Test:
- Config wijzigingen worden direct toegepast
- Timer loopt correct af
- Beep speelt bij timer einde
- Spatiebalk werkt op blank screen
- Laatste stelling toont finish screen

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Python
venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
# Flask
instance/
.webassets-cache
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log

115
README.md Normal file
View file

@ -0,0 +1,115 @@
# Sessie IJsbreker
Een browser-based interactief spel voor workshops waarbij deelnemers fysiek kiezen tussen twee stellingen.
## Installatie
**Eerste keer setup:**
```bash
# Installeer Python dependencies
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
## Gebruik
### 1. Start de Flask server
```bash
# Activeer virtual environment (indien nog niet actief)
source venv/bin/activate
# Start de server
python3 server.py
```
De server draait nu op `http://localhost:8000`
### 2. Beheer Stellingen
Open in je browser: `http://localhost:8000/editor.html`
Hier kun je:
- ⚙️ Instellingen aanpassen (timer, kleuren, teksten)
- ✏️ Stellingen toevoegen, bewerken, verwijderen
- 💾 Opslaan naar config.json
- ⌨️ Keyboard shortcut: `Ctrl+S` of `Cmd+S` om op te slaan
### 3. Spel Draaien (Presentatie)
Open in je browser: `http://localhost:8000`
Sluit aan op beamer/groot scherm en:
1. Het spel toont automatisch de eerste stelling
2. Deelnemers lopen naar links of rechts
3. Na de timer: beep + overlay met knop
4. Druk op knop of **spatiebalk** voor volgende stelling
5. Laatste stelling toont direct eindscherm
## Configuratie
Alle instellingen staan in `config.json`:
```json
{
"timer": 30, // Timer duur in seconden
"fontSize": "3rem", // Tekst grootte (CSS waarde)
"buttonText": "Volgende Stelling",
"finishText": "Sessie Afgelopen!",
"colors": {
"left": "#3B82F6", // Achtergrondkleur links (hex)
"right": "#EF4444" // Achtergrondkleur rechts (hex)
},
"stellingen": [
{
"links": "Koffie",
"rechts": "Thee"
}
]
}
```
### Configuratie Opties
- **timer**: Aantal seconden per stelling (bijv. `30`, `45`, `60`)
- **fontSize**: CSS font grootte (bijv. `"3rem"`, `"48px"`, `"4vw"`)
- **buttonText**: Tekst op de 'volgende' knop
- **finishText**: Tekst op het eindscherm
- **colors.left**: Hex kleurcode voor linker stelling achtergrond
- **colors.right**: Hex kleurcode voor rechter stelling achtergrond
- **stellingen**: Array van stelling paren
## Interactie
- **Klik op knop**: Ga naar volgende stelling
- **Spatiebalk**: Ga naar volgende stelling (sneller tijdens presentatie)
## Features
**Presentatie:**
- ✅ Visuele timer rondom scherm (loopt synchroon rond)
- ✅ Geluidssignaal bij einde timer
- ✅ Overlay met stellingen na timer (grayed out)
- ✅ Keyboard shortcut (spatiebalk)
- ✅ Direct eindscherm bij laatste stelling
**Editor:**
- ✅ Web-based stellingen beheer
- ✅ Real-time preview van aantal stellingen
- ✅ Kleurenpicker voor achtergronden
- ✅ Drag-free toevoegen/verwijderen
- ✅ Keyboard shortcut (Ctrl/Cmd+S)
- ✅ Validatie van invoer
**Algemeen:**
- ✅ Volledig offline werkend
- ✅ Configureerbaar via JSON én webinterface
- ✅ Geen authenticatie nodig (lokaal gebruik)
## Technisch
- **Frontend:** Pure HTML/CSS/JavaScript
- **Backend:** Python Flask voor config management
- **Dependencies:** Flask, flask-cors

170
app.js Normal file
View file

@ -0,0 +1,170 @@
// Globale state
let config = null;
let currentIndex = 0;
let audioContext = null;
// DOM elementen
const timerBorder = document.querySelector('.timer-border');
const statementScreen = document.getElementById('statementScreen');
const overlay = document.getElementById('overlay');
const finishScreen = document.getElementById('finishScreen');
const leftStatement = document.getElementById('leftStatement');
const rightStatement = document.getElementById('rightStatement');
const leftText = document.getElementById('leftText');
const rightText = document.getElementById('rightText');
const nextButton = document.getElementById('nextButton');
const finishText = document.getElementById('finishText');
// Laad configuratie bij start
async function loadConfig() {
try {
const response = await fetch('config.json');
config = await response.json();
initializeApp();
} catch (error) {
console.error('Fout bij laden config:', error);
alert('Kan config.json niet laden. Zorg dat alle bestanden in dezelfde map staan.');
}
}
// Initialiseer app met config
function initializeApp() {
// Stel CSS variabelen in
document.documentElement.style.setProperty('--timer-duration', `${config.timer}s`);
// Pas font grootte toe
document.querySelectorAll('.statement p').forEach(el => {
el.style.fontSize = config.fontSize;
});
finishText.style.fontSize = config.fontSize;
// Pas kleuren toe
leftStatement.style.backgroundColor = config.colors.left;
rightStatement.style.backgroundColor = config.colors.right;
// Zet knop tekst
nextButton.textContent = config.buttonText;
finishText.textContent = config.finishText;
// Start met eerste stelling
showStatement(0);
}
// Toon stelling op index
function showStatement(index) {
currentIndex = index;
// Check of we klaar zijn
if (index >= config.stellingen.length) {
showFinish();
return;
}
const stelling = config.stellingen[index];
// Update teksten
leftText.textContent = stelling.links;
rightText.textContent = stelling.rechts;
// Toon stellingen scherm, verberg overlay en finish
statementScreen.classList.remove('hidden');
overlay.classList.add('hidden');
finishScreen.classList.add('hidden');
// Start timer
startTimer();
}
// Start timer animatie
function startTimer() {
// Reset timer
timerBorder.classList.remove('timer-active');
void timerBorder.offsetWidth; // Force reflow
// Start timer
timerBorder.classList.add('timer-active');
// Wacht tot timer klaar is
setTimeout(() => {
playBeep();
// Bij laatste stelling: toon direct finish scherm
if (currentIndex === config.stellingen.length - 1) {
showFinish();
} else {
showOverlay();
}
}, config.timer * 1000);
}
// Toon overlay met knop (stellingen blijven zichtbaar)
function showOverlay() {
overlay.classList.remove('hidden');
timerBorder.classList.remove('timer-active');
}
// Toon eind scherm
function showFinish() {
statementScreen.classList.add('hidden');
overlay.classList.add('hidden');
finishScreen.classList.remove('hidden');
timerBorder.classList.remove('timer-active');
}
// Initialiseer audio context (herbruikbaar)
function initAudioContext() {
if (!audioContext) {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
} catch (error) {
console.error('Fout bij initialiseren audio context:', error);
}
}
}
// Speel beep geluid via Web Audio API
function playBeep() {
try {
// Zorg dat audioContext bestaat
initAudioContext();
if (!audioContext) return;
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
// Configureer oscillator (beep geluid)
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(800, audioContext.currentTime); // 800 Hz
// Configureer volume (fade out)
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
// Verbind nodes
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Speel beep (300ms)
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
} catch (error) {
console.error('Fout bij afspelen geluid:', error);
}
}
// Event listeners
nextButton.addEventListener('click', () => {
showStatement(currentIndex + 1);
});
// Spatiebalk voor volgende stelling
document.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
if (!overlay.classList.contains('hidden')) {
showStatement(currentIndex + 1);
}
}
});
// Start de app
loadConfig();

48
config.json Normal file
View file

@ -0,0 +1,48 @@
{
"buttonText": "Volgende stelling",
"colors": {
"left": "#3b82f6",
"right": "#ef4444"
},
"finishText": "Dat was het! Having fun yet?",
"fontSize": "3rem",
"stellingen": [
{
"links": "Koffie",
"rechts": "Thee"
},
{
"links": "Friet",
"rechts": "Patat"
},
{
"links": "Liever email",
"rechts": "Liever telefoon"
},
{
"links": "Sneltoetsen",
"rechts": "Muisgebruik"
},
{
"links": "Overzichtelijke mappen",
"rechts": "Zoeken werkt sneller"
},
{
"links": "Ik vind eenvoudig de juiste informatie terug",
"rechts": "Ik ben constant alles kwijt"
},
{
"links": "Naamgeving van bestanden is een breinbreker",
"rechts": "Naamgeving is volslagen logisch"
},
{
"links": "Ik maak eigen notities op één plek",
"rechts": "Overal post-its en losse bestanden"
},
{
"links": "❤️ ❤️ ❤️ Meetingverslagen template ",
"rechts": "☠️☠️☠️ Wie bedenkt die templates?"
}
],
"timer": 2
}

273
editor.css Normal file
View file

@ -0,0 +1,273 @@
/* Reset en basis */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* Header */
header {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 1.8rem;
color: #2563EB;
}
.header-actions {
display: flex;
gap: 1rem;
}
/* Sections */
section {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
section h2 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
color: #333;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.count-badge {
background: #E5E7EB;
color: #4B5563;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
/* Settings grid */
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-item label {
font-weight: 600;
color: #4B5563;
font-size: 0.9rem;
}
.setting-item input[type="text"],
.setting-item input[type="number"] {
padding: 0.75rem;
border: 2px solid #E5E7EB;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.setting-item input[type="text"]:focus,
.setting-item input[type="number"]:focus {
outline: none;
border-color: #3B82F6;
}
.setting-item input[type="color"] {
height: 50px;
border: 2px solid #E5E7EB;
border-radius: 8px;
cursor: pointer;
}
/* Stellingen lijst */
.statements-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.statement-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: center;
padding: 1rem;
background: #F9FAFB;
border-radius: 8px;
border: 2px solid #E5E7EB;
transition: border-color 0.2s;
}
.statement-row:hover {
border-color: #D1D5DB;
}
.statement-input {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.statement-input label {
font-size: 0.75rem;
font-weight: 600;
color: #6B7280;
text-transform: uppercase;
}
.statement-input input {
padding: 0.75rem;
border: 2px solid #E5E7EB;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.statement-input input:focus {
outline: none;
border-color: #3B82F6;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #3B82F6;
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover {
background: #2563EB;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}
.btn-secondary {
background: #6B7280;
color: white;
}
.btn-secondary:hover {
background: #4B5563;
}
.btn-add {
background: #10B981;
color: white;
width: 100%;
margin-top: 1rem;
}
.btn-add:hover {
background: #059669;
}
.btn-remove {
background: #EF4444;
color: white;
padding: 0.5rem 1rem;
font-size: 1.2rem;
min-width: 44px;
}
.btn-remove:hover {
background: #DC2626;
}
/* Actions */
.actions-section {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
#saveConfig {
font-size: 1.2rem;
padding: 1rem 2rem;
}
/* Status message */
.status-message {
padding: 1rem;
border-radius: 8px;
font-weight: 600;
text-align: center;
min-height: 20px;
transition: all 0.3s;
}
.status-message.success {
background: #D1FAE5;
color: #065F46;
}
.status-message.error {
background: #FEE2E2;
color: #991B1B;
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.statement-row {
grid-template-columns: 1fr;
}
.settings-grid {
grid-template-columns: 1fr;
}
}

80
editor.html Normal file
View file

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stellingen Editor - IJsbreker</title>
<link rel="stylesheet" href="editor.css">
</head>
<body>
<div class="container">
<header>
<h1>🎯 Sessie IJsbreker - Stellingen Editor</h1>
<div class="header-actions">
<a href="/" class="btn btn-secondary">← Terug naar Spel</a>
</div>
</header>
<main>
<!-- Basis instellingen -->
<section class="settings-section">
<h2>⚙️ Instellingen</h2>
<div class="settings-grid">
<div class="setting-item">
<label for="timer">Timer (seconden):</label>
<input type="number" id="timer" min="5" max="300" value="30">
</div>
<div class="setting-item">
<label for="fontSize">Font grootte:</label>
<input type="text" id="fontSize" value="3rem" placeholder="3rem">
</div>
<div class="setting-item">
<label for="buttonText">Knop tekst:</label>
<input type="text" id="buttonText" value="Volgende Stelling">
</div>
<div class="setting-item">
<label for="finishText">Eind tekst:</label>
<input type="text" id="finishText" value="Sessie Afgelopen!">
</div>
<div class="setting-item">
<label for="colorLeft">Kleur links:</label>
<input type="color" id="colorLeft" value="#3B82F6">
<input type="text" id="colorLeftText" value="#3B82F6">
</div>
<div class="setting-item">
<label for="colorRight">Kleur rechts:</label>
<input type="color" id="colorRight" value="#EF4444">
<input type="text" id="colorRightText" value="#EF4444">
</div>
</div>
</section>
<!-- Stellingen -->
<section class="statements-section">
<div class="section-header">
<h2>📝 Stellingen</h2>
<span id="statementCount" class="count-badge">0 stellingen</span>
</div>
<div id="statementsList" class="statements-list">
<!-- Stellingen worden hier dynamisch toegevoegd -->
</div>
<button id="addStatement" class="btn btn-add">
+ Nieuwe Stelling
</button>
</section>
<!-- Opslaan -->
<section class="actions-section">
<button id="saveConfig" class="btn btn-primary">
💾 Opslaan naar Config
</button>
<div id="statusMessage" class="status-message"></div>
</section>
</main>
</div>
<script src="editor.js"></script>
</body>
</html>

237
editor.js Normal file
View file

@ -0,0 +1,237 @@
// Globale state
let config = null;
// DOM elementen
const timerInput = document.getElementById('timer');
const fontSizeInput = document.getElementById('fontSize');
const buttonTextInput = document.getElementById('buttonText');
const finishTextInput = document.getElementById('finishText');
const colorLeftInput = document.getElementById('colorLeft');
const colorLeftTextInput = document.getElementById('colorLeftText');
const colorRightInput = document.getElementById('colorRight');
const colorRightTextInput = document.getElementById('colorRightText');
const statementsList = document.getElementById('statementsList');
const addStatementBtn = document.getElementById('addStatement');
const saveConfigBtn = document.getElementById('saveConfig');
const statusMessage = document.getElementById('statusMessage');
const statementCount = document.getElementById('statementCount');
// Laad config bij start
async function loadConfig() {
try {
const response = await fetch('/api/config');
config = await response.json();
populateForm();
showStatus('Config geladen!', 'success');
} catch (error) {
console.error('Fout bij laden config:', error);
showStatus('Fout bij laden config', 'error');
}
}
// Vul formulier met config data
function populateForm() {
// Basis instellingen
timerInput.value = config.timer;
fontSizeInput.value = config.fontSize;
buttonTextInput.value = config.buttonText;
finishTextInput.value = config.finishText;
colorLeftInput.value = config.colors.left;
colorLeftTextInput.value = config.colors.left;
colorRightInput.value = config.colors.right;
colorRightTextInput.value = config.colors.right;
// Render stellingen
renderStatements();
}
// Render alle stellingen
function renderStatements() {
statementsList.innerHTML = '';
config.stellingen.forEach((stelling, index) => {
addStatementRow(stelling, index);
});
updateCount();
}
// Voeg een stelling rij toe aan de UI
function addStatementRow(stelling = { links: '', rechts: '' }, index) {
const row = document.createElement('div');
row.className = 'statement-row';
row.dataset.index = index;
row.innerHTML = `
<div class="statement-input">
<label>Stelling Links</label>
<input
type="text"
class="statement-left"
value="${stelling.links}"
placeholder="Bijv. Koffie"
data-index="${index}"
>
</div>
<div class="statement-input">
<label>Stelling Rechts</label>
<input
type="text"
class="statement-right"
value="${stelling.rechts}"
placeholder="Bijv. Thee"
data-index="${index}"
>
</div>
<button class="btn btn-remove" data-index="${index}"></button>
`;
statementsList.appendChild(row);
// Event listeners voor inputs
const leftInput = row.querySelector('.statement-left');
const rightInput = row.querySelector('.statement-right');
const removeBtn = row.querySelector('.btn-remove');
leftInput.addEventListener('input', (e) => {
config.stellingen[index].links = e.target.value;
updateCount();
});
rightInput.addEventListener('input', (e) => {
config.stellingen[index].rechts = e.target.value;
updateCount();
});
removeBtn.addEventListener('click', () => {
if (confirm('Weet je zeker dat je deze stelling wilt verwijderen?')) {
removeStatement(index);
}
});
}
// Verwijder stelling
function removeStatement(index) {
config.stellingen.splice(index, 1);
renderStatements();
showStatus('Stelling verwijderd', 'success');
}
// Voeg nieuwe lege stelling toe
function addNewStatement() {
config.stellingen.push({ links: '', rechts: '' });
renderStatements();
// Focus op eerste input van nieuwe rij
const newRow = statementsList.lastElementChild;
const firstInput = newRow.querySelector('.statement-left');
firstInput.focus();
showStatus('Nieuwe stelling toegevoegd', 'success');
}
// Update stelling count
function updateCount() {
const count = config.stellingen.filter(
s => s.links.trim() || s.rechts.trim()
).length;
statementCount.textContent = `${count} stelling${count !== 1 ? 'en' : ''}`;
}
// Sync kleur inputs (color picker <-> text)
function syncColorInputs() {
colorLeftInput.addEventListener('input', (e) => {
colorLeftTextInput.value = e.target.value;
});
colorLeftTextInput.addEventListener('input', (e) => {
if (e.target.value.match(/^#[0-9A-Fa-f]{6}$/)) {
colorLeftInput.value = e.target.value;
}
});
colorRightInput.addEventListener('input', (e) => {
colorRightTextInput.value = e.target.value;
});
colorRightTextInput.addEventListener('input', (e) => {
if (e.target.value.match(/^#[0-9A-Fa-f]{6}$/)) {
colorRightInput.value = e.target.value;
}
});
}
// Sla config op naar server
async function saveConfig() {
try {
// Update config met form values
config.timer = parseInt(timerInput.value);
config.fontSize = fontSizeInput.value;
config.buttonText = buttonTextInput.value;
config.finishText = finishTextInput.value;
config.colors.left = colorLeftInput.value;
config.colors.right = colorRightInput.value;
// Filter lege stellingen eruit
config.stellingen = config.stellingen.filter(
s => s.links.trim() || s.rechts.trim()
);
// Validatie
if (config.stellingen.length === 0) {
showStatus('⚠️ Je moet minimaal 1 stelling toevoegen', 'error');
return;
}
// POST naar server
const response = await fetch('/api/save-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
});
const result = await response.json();
if (result.success) {
showStatus('✅ Config succesvol opgeslagen!', 'success');
// Reload om lege stellingen te verwijderen
setTimeout(() => loadConfig(), 500);
} else {
showStatus(`❌ Fout: ${result.error}`, 'error');
}
} catch (error) {
console.error('Fout bij opslaan:', error);
showStatus('❌ Fout bij opslaan config', 'error');
}
}
// Toon status bericht
function showStatus(message, type = 'success') {
statusMessage.textContent = message;
statusMessage.className = `status-message ${type}`;
// Verberg na 3 seconden
setTimeout(() => {
statusMessage.textContent = '';
statusMessage.className = 'status-message';
}, 3000);
}
// Event listeners
addStatementBtn.addEventListener('click', addNewStatement);
saveConfigBtn.addEventListener('click', saveConfig);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + S = opslaan
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveConfig();
}
});
// Initialize
syncColorInputs();
loadConfig();

43
index.html Normal file
View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sessie IJsbreker</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Timer balk rondom scherm -->
<div class="timer-border">
<div class="timer-bar timer-top"></div>
<div class="timer-bar timer-right"></div>
<div class="timer-bar timer-bottom"></div>
<div class="timer-bar timer-left"></div>
</div>
<!-- Hoofdcontainer -->
<div id="app" class="container">
<!-- Stellingen scherm -->
<div id="statementScreen" class="statement-screen">
<div id="leftStatement" class="statement left-statement">
<p id="leftText"></p>
</div>
<div id="rightStatement" class="statement right-statement">
<p id="rightText"></p>
</div>
</div>
<!-- Overlay met knop (over stellingen heen) -->
<div id="overlay" class="overlay hidden">
<button id="nextButton" class="next-button"></button>
</div>
<!-- Eind scherm -->
<div id="finishScreen" class="finish-screen hidden">
<h1 id="finishText"></h1>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
Flask==3.0.0
flask-cors==4.0.0

67
server.py Normal file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
Flask server voor IJsbreker spel
Serveert statische bestanden en biedt API endpoints voor config management
"""
from flask import Flask, send_from_directory, request, jsonify
from flask_cors import CORS
import json
import os
app = Flask(__name__)
CORS(app) # Enable CORS voor alle routes
CONFIG_FILE = 'config.json'
# Serve static files
@app.route('/')
def serve_index():
return send_from_directory('.', 'index.html')
@app.route('/<path:path>')
def serve_static(path):
if os.path.exists(path):
return send_from_directory('.', path)
return "File not found", 404
# API endpoint om config te laden
@app.route('/api/config', methods=['GET'])
def get_config():
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
config = json.load(f)
return jsonify(config)
except Exception as e:
return jsonify({'error': str(e)}), 500
# API endpoint om config op te slaan
@app.route('/api/save-config', methods=['POST'])
def save_config():
try:
data = request.json
# Validatie: check of stellingen array bestaat
if 'stellingen' not in data:
return jsonify({'error': 'Geen stellingen gevonden'}), 400
# Filter lege stellingen eruit
data['stellingen'] = [
s for s in data['stellingen']
if s.get('links', '').strip() or s.get('rechts', '').strip()
]
# Schrijf naar config.json met mooie formatting
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return jsonify({'success': True, 'message': 'Config opgeslagen!'})
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
print("🎯 IJsbreker Server gestart!")
print("📝 Editor: http://localhost:8000/editor.html")
print("🎮 Spel: http://localhost:8000/")
print("\nDruk Ctrl+C om te stoppen")
app.run(debug=True, host='0.0.0.0', port=8000)

179
style.css Normal file
View file

@ -0,0 +1,179 @@
/* Reset en basis styling */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden;
background: #1a1a1a;
}
/* Container voor alle schermen */
.container {
width: 100vw;
height: 100vh;
position: relative;
}
/* Timer border animatie rondom scherm */
.timer-border {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 1000;
}
.timer-bar {
position: absolute;
background: white;
}
/* Top balk */
.timer-top {
top: 0;
left: 0;
height: 8px;
width: 0;
}
/* Right balk */
.timer-right {
top: 0;
right: 0;
width: 8px;
height: 0;
}
/* Bottom balk */
.timer-bottom {
bottom: 0;
right: 0;
height: 8px;
width: 0;
}
/* Left balk */
.timer-left {
bottom: 0;
left: 0;
width: 8px;
height: 0;
}
/* Timer animatie - elke balk is 1/4 van totale tijd */
.timer-active .timer-top {
animation: fillWidth calc(var(--timer-duration) / 4) linear forwards;
}
.timer-active .timer-right {
animation: fillHeight calc(var(--timer-duration) / 4) linear forwards;
animation-delay: calc(var(--timer-duration) / 4);
}
.timer-active .timer-bottom {
animation: fillWidth calc(var(--timer-duration) / 4) linear forwards;
animation-delay: calc(var(--timer-duration) / 2);
}
.timer-active .timer-left {
animation: fillHeight calc(var(--timer-duration) / 4) linear forwards;
animation-delay: calc(var(--timer-duration) * 3 / 4);
}
@keyframes fillWidth {
from { width: 0; }
to { width: 100vw; }
}
@keyframes fillHeight {
from { height: 0; }
to { height: 100vh; }
}
/* Stellingen scherm - 50/50 split */
.statement-screen {
display: flex;
width: 100%;
height: 100%;
}
.statement {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.statement p {
color: white;
font-weight: bold;
text-align: center;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
line-height: 1.4;
}
/* Overlay met knop (over stellingen heen) */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
z-index: 500;
}
.next-button {
padding: 1.5rem 3rem;
font-size: 1.5rem;
font-weight: bold;
color: white;
background: #3B82F6;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.next-button:hover {
background: #2563EB;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.6);
}
.next-button:active {
transform: translateY(0);
}
/* Eind scherm */
.finish-screen {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #1a1a1a;
}
.finish-screen h1 {
color: white;
font-weight: bold;
text-align: center;
padding: 2rem;
}
/* Helper classes */
.hidden {
display: none !important;
}