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:
commit
7b181a5f12
12 changed files with 1318 additions and 0 deletions
79
.claude/CLAUDE.md
Normal file
79
.claude/CLAUDE.md
Normal 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
25
.gitignore
vendored
Normal 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
115
README.md
Normal 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
170
app.js
Normal 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
48
config.json
Normal 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
273
editor.css
Normal 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
80
editor.html
Normal 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
237
editor.js
Normal 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
43
index.html
Normal 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
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Flask==3.0.0
|
||||
flask-cors==4.0.0
|
||||
67
server.py
Normal file
67
server.py
Normal 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
179
style.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue