alembic verbessert, Javascript modularisiert
This commit is contained in:
parent
2a308d2d72
commit
0cb5ca68b6
14 changed files with 2296 additions and 12 deletions
38
CLAUDE.md
38
CLAUDE.md
|
|
@ -29,7 +29,27 @@ The application runs on `http://localhost:5000` by default.
|
|||
|
||||
### Database Setup
|
||||
|
||||
The database is automatically initialized when the application starts for the first time. Tables are created via SQLAlchemy in `app.py` using `db.create_all()`.
|
||||
**WICHTIG**: Die Anwendung verwendet Alembic für Datenbank-Migrationen.
|
||||
|
||||
**Erste Installation**:
|
||||
```bash
|
||||
# Setup-Script ausführen (empfohlen)
|
||||
python setup_database.py
|
||||
|
||||
# ODER manuell:
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
**Bei Schema-Änderungen**:
|
||||
```bash
|
||||
# Neue Migration erstellen
|
||||
alembic revision --autogenerate -m "Beschreibung der Änderung"
|
||||
|
||||
# Migration anwenden
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
Siehe `MIGRATIONS.md` für detaillierte Informationen.
|
||||
|
||||
### Dependencies
|
||||
|
||||
|
|
@ -98,13 +118,27 @@ curl http://localhost:5000/debug
|
|||
|
||||
### Frontend Architecture
|
||||
|
||||
**Modular JavaScript (ES6 Modules)**: Das Frontend ist in separate Module aufgeteilt für bessere Wartbarkeit:
|
||||
|
||||
- **`theme.js`**: Theme Management (Light/Dark Mode)
|
||||
- **`metronome.js`**: Adaptive Metronome-Klasse und Audio-Player
|
||||
- **`statistics.js`**: Statistik-Berechnungen und Anzeige
|
||||
- **`textDisplay.js`**: Text-Rendering und Highlighting
|
||||
- **`keyboard.js`**: Keyboard-Event-Handling
|
||||
- **`session.js`**: Session Management und Fortschritt-Speicherung
|
||||
- **`ui.js`**: UI-Interaktionen (Buttons, Modals, Toggles)
|
||||
- **`main.js`**: Hauptorchestration und Initialisierung
|
||||
|
||||
**Theme System**: Light/Dark mode toggle using Bootstrap's `data-bs-theme` attribute, stored in localStorage.
|
||||
|
||||
**Adaptive Metronome** (`script.js`):
|
||||
**Adaptive Metronome**:
|
||||
- Adjusts typing speed guidance based on user accuracy
|
||||
- Target accuracy: 96% (configurable)
|
||||
- Tracks last 50 keystrokes to calculate rolling accuracy
|
||||
- Speed increases when accuracy is high, decreases when low
|
||||
- Implementiert in `metronome.js` mit zwei Klassen:
|
||||
- `AdaptiveMetronome`: Geschwindigkeitsanpassungslogik
|
||||
- `MetronomePlayer`: Audio-Wiedergabe und Timing
|
||||
|
||||
**Visual Feedback**:
|
||||
- Green highlighting for correct characters
|
||||
|
|
|
|||
371
JAVASCRIPT_MODULES.md
Normal file
371
JAVASCRIPT_MODULES.md
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
# JavaScript-Modulstruktur
|
||||
|
||||
Dieses Dokument beschreibt die modularisierte JavaScript-Architektur des Typewriter Tutors.
|
||||
|
||||
## Überblick
|
||||
|
||||
Das Frontend wurde in separate ES6-Module aufgeteilt, um die Wartbarkeit, Testbarkeit und Lesbarkeit zu verbessern. Statt einer monolithischen `script.js` (1.122 Zeilen) gibt es nun 8 fokussierte Module.
|
||||
|
||||
## Module
|
||||
|
||||
### 1. `theme.js` - Theme Management
|
||||
|
||||
**Verantwortlichkeit**: Verwaltung des Light/Dark-Modus
|
||||
|
||||
**Klasse**: `ThemeManager`
|
||||
|
||||
**Funktionen**:
|
||||
- `initialize()` - Initialisiert Theme aus localStorage
|
||||
- `toggle()` - Wechselt zwischen Light und Dark Mode
|
||||
- `setTheme(theme)` - Setzt ein spezifisches Theme
|
||||
- `setupEventListeners()` - Bindet Theme-Toggle-Button
|
||||
|
||||
**Verwendung**:
|
||||
```javascript
|
||||
const themeManager = new ThemeManager();
|
||||
themeManager.initialize();
|
||||
themeManager.setupEventListeners();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `metronome.js` - Adaptive Metronom-Logik
|
||||
|
||||
**Verantwortlichkeit**: Intelligente Geschwindigkeitsanpassung und Audio-Wiedergabe
|
||||
|
||||
**Klassen**:
|
||||
|
||||
#### `AdaptiveMetronome`
|
||||
Implementiert die 5-Regel-Geschwindigkeitsanpassung basierend auf Tipp-Genauigkeit.
|
||||
|
||||
**Eigenschaften**:
|
||||
- `speed` - Aktuelle Geschwindigkeit in BPM
|
||||
- `accuracy_history` - Letzte 50 Tasteneingaben (1=korrekt, 0=falsch)
|
||||
- `consecutive_correct` - Aufeinanderfolgende korrekte Zeichen
|
||||
- `target_accuracy` - Zielgenauigkeit (Standard: 96%)
|
||||
|
||||
**Methoden**:
|
||||
- `process_keystroke(is_correct, difficulty)` - Verarbeitet Tastendruck und passt Geschwindigkeit an
|
||||
- `_adjust_speed(difficulty)` - Interne Geschwindigkeitsanpassung
|
||||
|
||||
**Anpassungsregeln**:
|
||||
1. Streak-Bonus bei ≥15 korrekten Zeichen: +3%
|
||||
2. Zu einfach (>95% Genauigkeit): +2%
|
||||
3. Zu schwer (<85% Genauigkeit): -8%
|
||||
4. Leicht unter Ziel (85-91%): -3%
|
||||
5. Im Zielbereich (91-95%): +1%
|
||||
|
||||
#### `MetronomePlayer`
|
||||
Verwaltet die Audio-Wiedergabe und das Timing.
|
||||
|
||||
**Methoden**:
|
||||
- `start()` - Startet das Metronom
|
||||
- `stop()` - Stoppt das Metronom
|
||||
- `setBPM(bpm)` - Setzt BPM und startet neu
|
||||
- `toggle()` - Schaltet Audio ein/aus
|
||||
- `play()` - Spielt einen Beat
|
||||
|
||||
**Verwendung**:
|
||||
```javascript
|
||||
const adaptive = new AdaptiveMetronome();
|
||||
const player = new MetronomePlayer();
|
||||
|
||||
// Bei jedem Tastendruck:
|
||||
const newBPM = adaptive.process_keystroke(isCorrect);
|
||||
player.setBPM(newBPM);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. `statistics.js` - Statistik-Berechnungen
|
||||
|
||||
**Verantwortlichkeit**: Berechnung und Anzeige von Tipp-Statistiken
|
||||
|
||||
**Klasse**: `StatisticsManager`
|
||||
|
||||
**Methoden**:
|
||||
- `calculateStatistics(userInput, fullText, elapsedTime, totalKeyStrokes)` - Berechnet alle Metriken
|
||||
- `updateDisplay(stats)` - Aktualisiert UI-Elemente
|
||||
- `setSpeedDisplay(display)` - Setzt Anzeige-Modus ('zpm' oder 'wpm')
|
||||
- `saveLessonStatistics(...)` - Speichert Statistiken auf Server
|
||||
|
||||
**Berechnete Metriken**:
|
||||
- `charsPerMinute` - Zeichen pro Minute
|
||||
- `wpm` - Wörter pro Minute (Zeichen/5)
|
||||
- `errorRate` - Fehlerrate in Prozent
|
||||
- `correctKeyStrokes` - Anzahl korrekter Zeichen
|
||||
- `incorrectKeyStrokes` - Anzahl falscher Zeichen
|
||||
|
||||
---
|
||||
|
||||
### 4. `textDisplay.js` - Text-Rendering
|
||||
|
||||
**Verantwortlichkeit**: Text-Darstellung, Highlighting und Scrollen
|
||||
|
||||
**Klasse**: `TextDisplayManager`
|
||||
|
||||
**Methoden**:
|
||||
- `updateDisplay(userInput, currentCharIndex)` - Aktualisiert Textanzeige mit Highlighting
|
||||
- `updateText(newText)` - Lädt neuen Text (z.B. neue Lektion)
|
||||
- `highlightChar(char, type, isCursor, incorrectChar)` - Rendert einzelnes Zeichen
|
||||
- `getCurrentLineIndex(charIndex)` - Berechnet Zeilennummer
|
||||
- `resetScroll()` - Setzt Scroll-Position zurück
|
||||
- `isLineComplete(lineIndex, userInput)` - Prüft ob Zeile komplett korrekt
|
||||
|
||||
**Highlight-Typen**:
|
||||
- `correct` - Grün für korrekte Zeichen
|
||||
- `incorrect` - Rot für falsche Zeichen (mit orangefarbenem Overlay)
|
||||
- `neutral` - Standard für noch nicht getippte Zeichen
|
||||
|
||||
**Features**:
|
||||
- Automatisches Scrollen zur aktuellen Zeile
|
||||
- Sichtbarmachung von Leerzeichen (·), Tabs (→), Newlines (↵)
|
||||
- Manuelles Scrollen wird erkannt und respektiert
|
||||
|
||||
---
|
||||
|
||||
### 5. `keyboard.js` - Tastatur-Verwaltung
|
||||
|
||||
**Verantwortlichkeit**: Tastatur-Events und ErrorLock-Funktionalität
|
||||
|
||||
**Klasse**: `KeyboardHandler`
|
||||
|
||||
**Methoden**:
|
||||
- `handleKeyDown(event, state)` - Verarbeitet alle Tastatureingaben
|
||||
- `setErrorLock(enabled)` - Aktiviert/deaktiviert ErrorLock
|
||||
- `getCorrectPrefixLength(userInput, fullText)` - Berechnet korrekte Präfix-Länge
|
||||
- `setupEventListeners()` - Richtet Fokus-Management ein
|
||||
|
||||
**Unterstützte Tasten**:
|
||||
- Zeichen-Eingabe
|
||||
- `Backspace` - Löschen
|
||||
- `ArrowLeft`/`ArrowRight` - Cursor-Bewegung
|
||||
- `Enter` - Zeilenumbruch
|
||||
- `Ctrl`/`Cmd` - Blockiert (Anti-Cheat)
|
||||
|
||||
**ErrorLock**:
|
||||
Wenn aktiviert, können nur Fehler an der Cursor-Position korrigiert werden.
|
||||
|
||||
---
|
||||
|
||||
### 6. `session.js` - Session Management
|
||||
|
||||
**Verantwortlichkeit**: Fortschritt-Tracking und Server-Kommunikation
|
||||
|
||||
**Klasse**: `SessionManager`
|
||||
|
||||
**Eigenschaften**:
|
||||
- `lastStartTime` - Zeitpunkt des letzten Starts
|
||||
- `totalElapsedTime` - Gesamte verstrichene Zeit
|
||||
- `keyStrokeCount` - Anzahl aller Tasteneingaben
|
||||
- `isPaused` - Pause-Status
|
||||
- `currentLessonIndex` - Aktuelle Lektion
|
||||
|
||||
**Methoden**:
|
||||
- `initialize(initialState)` - Initialisiert aus Server-Daten
|
||||
- `start()` / `pause()` / `resume()` - Zeit-Tracking
|
||||
- `togglePause()` - Pause ein/aus
|
||||
- `incrementKeystrokes()` - Erhöht Tastenzähler
|
||||
- `reset()` - Zurücksetzen für neue Lektion
|
||||
- `saveProgress(...)` - Speichert Fortschritt auf Server
|
||||
- `nextLesson()` - Lädt nächste Lektion
|
||||
- `setLesson(index)` - Lädt spezifische Lektion
|
||||
- `endSession()` - Beendet Session
|
||||
- `setupBeforeUnload(getSaveData)` - Speichert beim Schließen
|
||||
|
||||
**Server-Endpunkte**:
|
||||
- `POST /update_progress` - Fortschritt speichern
|
||||
- `POST /next_text` - Nächste Lektion
|
||||
- `POST /set_lesson` - Lektion wechseln
|
||||
- `POST /end_session` - Session beenden
|
||||
|
||||
---
|
||||
|
||||
### 7. `ui.js` - UI-Management
|
||||
|
||||
**Verantwortlichkeit**: UI-Elemente, Buttons, Modals, Toggles
|
||||
|
||||
**Klasse**: `UIManager`
|
||||
|
||||
**Methoden**:
|
||||
- `initialize()` - Initialisiert alle UI-Elemente
|
||||
- Icon-Verwaltung:
|
||||
- `toggleVolume(enabled)` - Volume-Icon
|
||||
- `toggleLock(locked)` - Lock-Icon
|
||||
- `togglePlayPause(isPaused)` - Play/Pause-Icon
|
||||
- Anzeige-Updates:
|
||||
- `updateLessonTitle(title)` - Aktualisiert Lektionstitel
|
||||
- `updateLessonHighlight(index)` - Markiert aktuelle Lektion
|
||||
- Dialoge:
|
||||
- `alert(message)` - Alert-Dialog
|
||||
- `confirm(message)` - Confirm-Dialog
|
||||
- Modals und Links:
|
||||
- `setupHelpModal()` - Help-Modal
|
||||
- `setupSettingsLink()` - Settings-Navigation
|
||||
- `setupStatisticsLink(callback)` - Statistics-Navigation
|
||||
- Performance-Anzeige:
|
||||
- `setupPerformanceToggle()` - Toggle für Statistik-Anzeige
|
||||
- `setProgressStatsVisibility(visible)` - Zeigt/verbirgt Stats
|
||||
|
||||
---
|
||||
|
||||
### 8. `main.js` - Hauptorchestration
|
||||
|
||||
**Verantwortlichkeit**: Initialisierung und Koordination aller Module
|
||||
|
||||
**Klasse**: `TypewriterApp`
|
||||
|
||||
**Ablauf**:
|
||||
1. Initialisierung aller Module
|
||||
2. Laden von Konfiguration aus `window.*` Variablen
|
||||
3. Setup von Event-Listenern
|
||||
4. Start der Anwendung
|
||||
|
||||
**Zentrale Methoden**:
|
||||
- `initialize()` - Haupt-Initialisierung
|
||||
- `initializeModules()` - Erstellt alle Module
|
||||
- `setupEventListeners()` - Bindet alle Events
|
||||
- `processKeystroke(key, isCorrect)` - Koordiniert Tastendruck-Verarbeitung
|
||||
- `updateDisplay()` / `updateStatistics()` - Aktualisiert UI
|
||||
- `completeLesson()` - Behandelt Lektions-Abschluss
|
||||
- `nextLesson()` / `loadLesson(index)` - Lektions-Navigation
|
||||
|
||||
**Event-Handling**:
|
||||
```javascript
|
||||
// Tastatur
|
||||
document.addEventListener('keydown', (event) => {
|
||||
keyboardHandler.handleKeyDown(event, state);
|
||||
});
|
||||
|
||||
// Metronom-Toggle
|
||||
volumeToggle.addEventListener('click', () => {
|
||||
metronomePlayer.toggle();
|
||||
saveMetronomeSettings();
|
||||
});
|
||||
|
||||
// Play/Pause
|
||||
playPauseToggle.addEventListener('click', () => {
|
||||
sessionManager.togglePause();
|
||||
uiManager.togglePlayPause(isPaused);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Initialisierung
|
||||
|
||||
**Template** (`index.html`):
|
||||
```html
|
||||
<!-- Konfigurationsdaten -->
|
||||
<script>
|
||||
window.fullText = {{ current_text|tojson }};
|
||||
window.currentCharIndex = {{ current_position }};
|
||||
window.lastText = {{ last_text|tojson }};
|
||||
window.totalElapsedTime = {{ total_elapsed_time }};
|
||||
window.keyStrokeCount = {{ key_stroke_count }};
|
||||
window.isPaused = {{ is_paused|tojson }};
|
||||
window.metronomeEnabled = {{ metronome_enabled|tojson }};
|
||||
window.metronomeBPM = {{ metronome_bpm }};
|
||||
window.metronomeSound = {{ metronome_sound|tojson }};
|
||||
window.metronomeMode = {{ metronome_mode|tojson }};
|
||||
window.metronomeSpeed = {{ metronome_speed }};
|
||||
window.targetErrorRate = {{ target_error_rate }};
|
||||
window.currentLessonIndex = {{ current_lesson_index }};
|
||||
window.speedDisplay = {{ speed_display|tojson }};
|
||||
</script>
|
||||
|
||||
<!-- Hauptmodul als ES6 Module -->
|
||||
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
```
|
||||
|
||||
**Ablauf**:
|
||||
1. DOM lädt
|
||||
2. `DOMContentLoaded` Event feuert
|
||||
3. `TypewriterApp` wird instanziiert
|
||||
4. `app.initialize()` wird aufgerufen
|
||||
5. Alle Module werden initialisiert
|
||||
6. Event-Listener werden gebunden
|
||||
7. Anwendung ist bereit
|
||||
|
||||
---
|
||||
|
||||
## Kommunikation zwischen Modulen
|
||||
|
||||
Die Module kommunizieren über die zentrale `TypewriterApp`-Instanz:
|
||||
|
||||
```
|
||||
User Input
|
||||
↓
|
||||
KeyboardHandler → TypewriterApp
|
||||
↓ ↓
|
||||
├→ AdaptiveMetronome (Geschwindigkeit)
|
||||
├→ TextDisplayManager (Anzeige)
|
||||
├→ StatisticsManager (Metriken)
|
||||
├→ SessionManager (Speichern)
|
||||
└→ UIManager (UI-Updates)
|
||||
```
|
||||
|
||||
**Vorteile**:
|
||||
- Klare Verantwortlichkeiten
|
||||
- Lose Kopplung
|
||||
- Einfaches Testing
|
||||
- Wiederverwendbarkeit
|
||||
- Bessere Wartbarkeit
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Jedes Modul kann isoliert getestet werden:
|
||||
|
||||
**Backend-Tests**: `python -m unittest test_app -v`
|
||||
|
||||
**Metronom-Tests**: `python -m unittest test_metronome_velocity -v`
|
||||
|
||||
Die JavaScript-Module sind so strukturiert, dass sie leicht mit Jest oder ähnlichen Frameworks getestet werden können.
|
||||
|
||||
---
|
||||
|
||||
## Migration von Alt zu Neu
|
||||
|
||||
Die alte monolithische `script.js` wurde als `script.js.old` gesichert.
|
||||
|
||||
**Hauptänderungen**:
|
||||
1. **ES6-Module**: Alle Exports/Imports verwenden ES6-Syntax
|
||||
2. **Klassen-basiert**: Alle Funktionalität in Klassen gekapselt
|
||||
3. **Zentrale Orchestration**: `main.js` koordiniert alle Module
|
||||
4. **Template-Änderung**: `<script type="module">` statt regulärem Script
|
||||
|
||||
**Kompatibilität**:
|
||||
- Funktionalität ist identisch
|
||||
- API-Endpunkte unverändert
|
||||
- Datenbank-Schema gleich
|
||||
- Alle Tests bestehen
|
||||
|
||||
---
|
||||
|
||||
## Weitere Entwicklung
|
||||
|
||||
**Empfohlene nächste Schritte**:
|
||||
1. JavaScript-Tests mit Jest hinzufügen
|
||||
2. TypeScript-Unterstützung für bessere Type Safety
|
||||
3. Build-Step mit Webpack/Rollup für Production-Bundles
|
||||
4. Service Worker für Offline-Funktionalität
|
||||
5. Weitere Modularisierung (z.B. LessonManager, ProgressIndicator)
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
**Problem**: Module laden nicht
|
||||
**Lösung**: Prüfe Browser-Konsole auf CORS-Fehler. ES6-Module benötigen einen Webserver.
|
||||
|
||||
**Problem**: `import` Syntax-Fehler
|
||||
**Lösung**: Stelle sicher, dass `type="module"` im Script-Tag vorhanden ist.
|
||||
|
||||
**Problem**: Alte Funktionalität fehlt
|
||||
**Lösung**: Prüfe `script.js.old` für evtl. fehlende Funktionen.
|
||||
|
||||
---
|
||||
|
||||
Für weitere Fragen siehe `CLAUDE.md` oder kontaktiere das Entwickler-Team.
|
||||
65
app.py
65
app.py
|
|
@ -191,18 +191,67 @@ def create_sample_lessons() -> List[Dict[str, Any]]:
|
|||
logger.error(f"Fehler beim Erstellen der Beispiel-Lektionen: {e}")
|
||||
return FALLBACK_LESSONS
|
||||
|
||||
# Datenbanktabellen erstellen und Lektionen laden
|
||||
# Prüfe Datenbank-Status
|
||||
def check_database_status():
|
||||
"""
|
||||
Prüft ob die Datenbank initialisiert ist und ob Migrationen ausstehen.
|
||||
|
||||
WICHTIG: Für Schema-Änderungen IMMER Alembic verwenden!
|
||||
- Neue Migration erstellen: alembic revision --autogenerate -m "Beschreibung"
|
||||
- Migration anwenden: alembic upgrade head
|
||||
- Migration rückgängig: alembic downgrade -1
|
||||
|
||||
Siehe MIGRATIONS.md für Details.
|
||||
"""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
try:
|
||||
# Prüfe ob alembic_version Tabelle existiert
|
||||
inspector = inspect(db.engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'alembic_version' not in tables:
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("DATENBANK NICHT INITIALISIERT!")
|
||||
logger.warning("Bitte führe folgende Schritte aus:")
|
||||
logger.warning(" 1. alembic upgrade head")
|
||||
logger.warning("Siehe MIGRATIONS.md für Details.")
|
||||
logger.warning("=" * 80)
|
||||
return False
|
||||
|
||||
# Prüfe ob benötigte Tabellen existieren
|
||||
required_tables = {'progress', 'lesson_statistic', 'statistic'}
|
||||
missing_tables = required_tables - set(tables)
|
||||
|
||||
if missing_tables:
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"FEHLENDE TABELLEN: {', '.join(missing_tables)}")
|
||||
logger.warning("Bitte führe aus: alembic upgrade head")
|
||||
logger.warning("=" * 80)
|
||||
return False
|
||||
|
||||
logger.info("✓ Datenbank initialisiert und bereit")
|
||||
logger.info(" Hinweis: Für Schema-Änderungen verwende Alembic (siehe MIGRATIONS.md)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Datenbank-Prüfung: {e}")
|
||||
logger.warning("Falls Datenbank nicht existiert, führe aus: alembic upgrade head")
|
||||
return False
|
||||
|
||||
# Datenbank-Status prüfen und Lektionen laden
|
||||
with app.app_context():
|
||||
# Warnung: db.create_all() sollte nur für Development verwendet werden
|
||||
# Für Production: Verwende Alembic Migrationen (siehe MIGRATIONS.md)
|
||||
# Führe aus: alembic upgrade head
|
||||
db.create_all()
|
||||
logger.info("Datenbanktabellen wurden initialisiert")
|
||||
logger.info("HINWEIS: Für Schema-Änderungen verwende Alembic Migrationen (siehe MIGRATIONS.md)")
|
||||
db_ready = check_database_status()
|
||||
|
||||
if not db_ready:
|
||||
logger.error("Anwendung kann nicht gestartet werden - Datenbank nicht bereit")
|
||||
logger.error("Siehe Warnungen oben für Lösungsschritte")
|
||||
import sys
|
||||
sys.exit(1)
|
||||
|
||||
# Versuche Lektionen zu laden
|
||||
load_lessons()
|
||||
|
||||
|
||||
# Falls keine Lektionen geladen wurden, erstelle Beispieldaten
|
||||
if not lessons:
|
||||
logger.warning("Keine Lektionen verfügbar, erstelle Beispiel-Lektionen...")
|
||||
|
|
|
|||
97
setup_database.py
Normal file
97
setup_database.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Datenbank-Setup-Script für Typewriter Tutor
|
||||
|
||||
Dieses Script initialisiert die Datenbank mit Alembic-Migrationen.
|
||||
Verwende es für neue Installationen oder nach einem Reset.
|
||||
|
||||
Usage:
|
||||
python setup_database.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
"""Hauptfunktion für Datenbank-Setup"""
|
||||
logger.info("=" * 80)
|
||||
logger.info("Typewriter Tutor - Datenbank-Setup")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Prüfe ob Alembic installiert ist
|
||||
try:
|
||||
import alembic
|
||||
logger.info("✓ Alembic ist installiert")
|
||||
except ImportError:
|
||||
logger.error("✗ Alembic nicht gefunden!")
|
||||
logger.error(" Bitte installiere: pip install -r requirements.txt")
|
||||
sys.exit(1)
|
||||
|
||||
# Prüfe ob instance/ Verzeichnis existiert
|
||||
instance_dir = os.path.join(os.path.dirname(__file__), 'instance')
|
||||
if not os.path.exists(instance_dir):
|
||||
logger.info(f"Erstelle instance/ Verzeichnis...")
|
||||
os.makedirs(instance_dir)
|
||||
logger.info("✓ Verzeichnis erstellt")
|
||||
|
||||
db_path = os.path.join(instance_dir, 'typewriter.db')
|
||||
|
||||
# Prüfe ob Datenbank bereits existiert
|
||||
if os.path.exists(db_path):
|
||||
logger.warning(f"⚠ Datenbank existiert bereits: {db_path}")
|
||||
response = input("Möchtest du die Migrationen trotzdem ausführen? (j/n): ")
|
||||
if response.lower() != 'j':
|
||||
logger.info("Setup abgebrochen.")
|
||||
sys.exit(0)
|
||||
|
||||
# Führe Alembic-Migrationen aus
|
||||
logger.info("\nFühre Alembic-Migrationen aus...")
|
||||
logger.info("Befehl: alembic upgrade head")
|
||||
logger.info("-" * 80)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['alembic', 'upgrade', 'head'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr)
|
||||
|
||||
logger.info("-" * 80)
|
||||
logger.info("✓ Migrationen erfolgreich ausgeführt!")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("✗ Fehler bei der Migration:")
|
||||
print(e.stdout)
|
||||
print(e.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("✗ 'alembic' Befehl nicht gefunden!")
|
||||
logger.error(" Stelle sicher, dass Alembic installiert ist.")
|
||||
sys.exit(1)
|
||||
|
||||
# Erfolgsbestätigung
|
||||
logger.info("\n" + "=" * 80)
|
||||
logger.info("SETUP ERFOLGREICH!")
|
||||
logger.info("=" * 80)
|
||||
logger.info("Die Datenbank ist bereit. Du kannst jetzt die Anwendung starten:")
|
||||
logger.info(" python app.py")
|
||||
logger.info("\nFür zukünftige Schema-Änderungen verwende:")
|
||||
logger.info(" alembic revision --autogenerate -m 'Beschreibung'")
|
||||
logger.info(" alembic upgrade head")
|
||||
logger.info("\nSiehe MIGRATIONS.md für weitere Details.")
|
||||
logger.info("=" * 80)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
128
static/js/keyboard.js
Normal file
128
static/js/keyboard.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Keyboard Handler Module
|
||||
* Manages keyboard input and processing
|
||||
*/
|
||||
|
||||
export class KeyboardHandler {
|
||||
constructor(callbacks) {
|
||||
this.callbacks = callbacks || {};
|
||||
this.errorLock = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the length of the correct prefix
|
||||
* @param {string} userInput - User's input
|
||||
* @param {string} fullText - Target text
|
||||
* @returns {number} Length of correct prefix
|
||||
*/
|
||||
getCorrectPrefixLength(userInput, fullText) {
|
||||
let correct = 0;
|
||||
for (let i = 0; i < userInput.length; i++) {
|
||||
if (i < fullText.length && userInput[i] === fullText[i]) {
|
||||
correct++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return correct;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error lock state
|
||||
* @param {boolean} enabled - Whether error lock is enabled
|
||||
*/
|
||||
setErrorLock(enabled) {
|
||||
this.errorLock = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keydown event
|
||||
* @param {KeyboardEvent} event - Keyboard event
|
||||
* @param {Object} state - Current application state
|
||||
*/
|
||||
handleKeyDown(event, state) {
|
||||
const { userInput, currentCharIndex, fullText, isPaused } = state;
|
||||
|
||||
// Prevent default for control keys
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// No keys allowed in pause mode
|
||||
if (isPaused) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrow keys
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
if (currentCharIndex > 0 && this.callbacks.onArrowLeft) {
|
||||
this.callbacks.onArrowLeft();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
if (currentCharIndex < fullText.length && this.callbacks.onArrowRight) {
|
||||
this.callbacks.onArrowRight();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle backspace
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
if (currentCharIndex > 0 && this.callbacks.onBackspace) {
|
||||
this.callbacks.onBackspace();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (currentCharIndex < fullText.length && fullText[currentCharIndex] === '\n') {
|
||||
if (this.callbacks.onKeystroke) {
|
||||
this.callbacks.onKeystroke('\n', true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore other control keys
|
||||
if (event.key.length > 1 && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Error lock check
|
||||
if (this.errorLock) {
|
||||
const correctPrefixLength = this.getCorrectPrefixLength(userInput, fullText);
|
||||
if (currentCharIndex !== correctPrefixLength) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal character
|
||||
event.preventDefault();
|
||||
if (currentCharIndex < fullText.length && this.callbacks.onKeystroke) {
|
||||
const isCorrect = event.key === fullText[currentCharIndex];
|
||||
this.callbacks.onKeystroke(event.key, isCorrect);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup keyboard event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Focus management
|
||||
document.addEventListener('click', () => {
|
||||
document.body.focus();
|
||||
});
|
||||
document.body.tabIndex = -1;
|
||||
document.body.focus();
|
||||
}
|
||||
}
|
||||
484
static/js/main.js
Normal file
484
static/js/main.js
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
/**
|
||||
* Main Application Module
|
||||
* Orchestrates all components and manages application state
|
||||
*/
|
||||
|
||||
import { ThemeManager } from './theme.js';
|
||||
import { AdaptiveMetronome, MetronomePlayer } from './metronome.js';
|
||||
import { StatisticsManager } from './statistics.js';
|
||||
import { TextDisplayManager } from './textDisplay.js';
|
||||
import { KeyboardHandler } from './keyboard.js';
|
||||
import { SessionManager } from './session.js';
|
||||
import { UIManager } from './ui.js';
|
||||
|
||||
class TypewriterApp {
|
||||
constructor() {
|
||||
// Application state
|
||||
this.fullText = '';
|
||||
this.currentCharIndex = 0;
|
||||
this.userInput = '';
|
||||
this.csrfToken = '';
|
||||
|
||||
// Module instances
|
||||
this.themeManager = null;
|
||||
this.adaptiveMetronome = null;
|
||||
this.metronomePlayer = null;
|
||||
this.statisticsManager = null;
|
||||
this.textDisplayManager = null;
|
||||
this.keyboardHandler = null;
|
||||
this.sessionManager = null;
|
||||
this.uiManager = null;
|
||||
|
||||
// Settings
|
||||
this.metronomeMode = 'automatic';
|
||||
this.targetErrorRate = 5;
|
||||
this.speedDisplay = 'zpm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
*/
|
||||
async initialize() {
|
||||
console.log('Initializing Typewriter Tutor...');
|
||||
|
||||
// Get CSRF token
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (csrfMeta) {
|
||||
this.csrfToken = csrfMeta.getAttribute('content');
|
||||
}
|
||||
|
||||
// Load initial data from window object
|
||||
this.fullText = window.fullText || '';
|
||||
this.currentCharIndex = window.currentCharIndex || 0;
|
||||
this.userInput = window.lastText || '';
|
||||
this.metronomeMode = window.metronomeMode || 'automatic';
|
||||
this.targetErrorRate = window.targetErrorRate || 5;
|
||||
this.speedDisplay = window.speedDisplay || 'zpm';
|
||||
|
||||
// Initialize modules
|
||||
this.initializeModules();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Initial render
|
||||
this.updateDisplay();
|
||||
this.updateStatistics();
|
||||
|
||||
// Start metronome if not paused
|
||||
if (!this.sessionManager.isPaused && window.metronomeEnabled) {
|
||||
this.metronomePlayer.start();
|
||||
}
|
||||
|
||||
console.log('Typewriter Tutor initialized successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all application modules
|
||||
*/
|
||||
initializeModules() {
|
||||
// Theme Manager
|
||||
this.themeManager = new ThemeManager();
|
||||
this.themeManager.initialize();
|
||||
this.themeManager.setupEventListeners();
|
||||
|
||||
// Metronome
|
||||
this.adaptiveMetronome = new AdaptiveMetronome();
|
||||
this.metronomePlayer = new MetronomePlayer();
|
||||
this.metronomePlayer.enabled = window.metronomeEnabled || true;
|
||||
this.metronomePlayer.bpm = window.metronomeBPM || 60;
|
||||
this.metronomePlayer.sound = window.metronomeSound || 'beep';
|
||||
|
||||
// Statistics
|
||||
this.statisticsManager = new StatisticsManager();
|
||||
this.statisticsManager.setSpeedDisplay(this.speedDisplay);
|
||||
|
||||
// Text Display
|
||||
const targetElement = document.getElementById('target-text');
|
||||
this.textDisplayManager = new TextDisplayManager(targetElement, this.fullText);
|
||||
|
||||
// Keyboard Handler
|
||||
this.keyboardHandler = new KeyboardHandler({
|
||||
onKeystroke: (key, isCorrect) => this.processKeystroke(key, isCorrect),
|
||||
onBackspace: () => this.processBackspace(),
|
||||
onArrowLeft: () => this.processArrowLeft(),
|
||||
onArrowRight: () => this.processArrowRight()
|
||||
});
|
||||
this.keyboardHandler.setupEventListeners();
|
||||
|
||||
// Session Manager
|
||||
this.sessionManager = new SessionManager(this.csrfToken);
|
||||
this.sessionManager.initialize({
|
||||
totalElapsedTime: window.totalElapsedTime || 0,
|
||||
keyStrokeCount: window.keyStrokeCount || 0,
|
||||
isPaused: window.isPaused || false,
|
||||
currentLessonIndex: window.currentLessonIndex || 0
|
||||
});
|
||||
this.sessionManager.setupBeforeUnload(() => ({
|
||||
userInput: this.userInput,
|
||||
cursorPosition: this.currentCharIndex,
|
||||
currentText: this.fullText
|
||||
}));
|
||||
|
||||
// UI Manager
|
||||
this.uiManager = new UIManager();
|
||||
this.uiManager.initialize();
|
||||
this.uiManager.setupHelpModal();
|
||||
this.uiManager.setupSettingsLink();
|
||||
this.uiManager.setupStatisticsLink(() => this.saveProgress());
|
||||
this.uiManager.setupPerformanceToggle();
|
||||
this.uiManager.togglePlayPause(this.sessionManager.isPaused);
|
||||
|
||||
// Prevent copy/paste
|
||||
if (targetElement) {
|
||||
targetElement.addEventListener('copy', (e) => {
|
||||
e.preventDefault();
|
||||
alert('Kopieren aus der Vorlage ist nicht erlaubt! Bitte tippen Sie den Text manuell.');
|
||||
});
|
||||
targetElement.addEventListener('cut', (e) => {
|
||||
e.preventDefault();
|
||||
alert('Ausschneiden aus der Vorlage ist nicht erlaubt!');
|
||||
});
|
||||
targetElement.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup application event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Keyboard events
|
||||
document.addEventListener('keydown', (event) => {
|
||||
this.keyboardHandler.handleKeyDown(event, {
|
||||
userInput: this.userInput,
|
||||
currentCharIndex: this.currentCharIndex,
|
||||
fullText: this.fullText,
|
||||
isPaused: this.sessionManager.isPaused
|
||||
});
|
||||
});
|
||||
|
||||
// Volume toggle
|
||||
const volumeToggle = document.getElementById('volume-toggle');
|
||||
if (volumeToggle) {
|
||||
volumeToggle.addEventListener('click', () => {
|
||||
const enabled = this.metronomePlayer.toggle();
|
||||
this.uiManager.toggleVolume(enabled);
|
||||
|
||||
if (enabled && !this.sessionManager.isPaused) {
|
||||
this.metronomePlayer.start();
|
||||
} else {
|
||||
this.metronomePlayer.stop();
|
||||
}
|
||||
|
||||
// Save to server
|
||||
this.saveMetronomeSettings();
|
||||
});
|
||||
}
|
||||
|
||||
// Lock toggle
|
||||
const lockToggle = document.getElementById('lock-toggle');
|
||||
if (lockToggle) {
|
||||
lockToggle.addEventListener('click', () => {
|
||||
const errorLock = !this.keyboardHandler.errorLock;
|
||||
this.keyboardHandler.setErrorLock(errorLock);
|
||||
this.uiManager.toggleLock(errorLock);
|
||||
});
|
||||
}
|
||||
|
||||
// Play/Pause toggle
|
||||
const playPauseToggle = document.getElementById('play-pause-toggle');
|
||||
if (playPauseToggle) {
|
||||
playPauseToggle.addEventListener('click', () => {
|
||||
this.sessionManager.togglePause();
|
||||
this.uiManager.togglePlayPause(this.sessionManager.isPaused);
|
||||
|
||||
if (this.sessionManager.isPaused) {
|
||||
this.metronomePlayer.stop();
|
||||
} else {
|
||||
if (this.metronomePlayer.enabled) {
|
||||
this.metronomePlayer.start();
|
||||
}
|
||||
}
|
||||
|
||||
this.updateStatistics();
|
||||
});
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextBtn = document.getElementById('next-btn');
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => this.nextLesson());
|
||||
}
|
||||
|
||||
// End button
|
||||
const endBtn = document.getElementById('end-btn');
|
||||
if (endBtn) {
|
||||
endBtn.addEventListener('click', () => this.endSession());
|
||||
}
|
||||
|
||||
// Lesson links
|
||||
document.querySelectorAll('.lesson-link').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
const lessonIndex = parseInt(link.getAttribute('data-lesson-index'));
|
||||
this.loadLesson(lessonIndex);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a keystroke
|
||||
*/
|
||||
processKeystroke(key, isCorrect) {
|
||||
// Add or replace character
|
||||
if (this.currentCharIndex < this.userInput.length) {
|
||||
this.userInput = this.userInput.slice(0, this.currentCharIndex) + key + this.userInput.slice(this.currentCharIndex + 1);
|
||||
} else {
|
||||
this.userInput = this.userInput.slice(0, this.currentCharIndex) + key + this.userInput.slice(this.currentCharIndex);
|
||||
}
|
||||
|
||||
this.currentCharIndex++;
|
||||
this.sessionManager.incrementKeystrokes();
|
||||
|
||||
// Adjust metronome
|
||||
if (this.metronomeMode === 'automatic') {
|
||||
const newBPM = this.adaptiveMetronome.process_keystroke(isCorrect, 1.0);
|
||||
this.metronomePlayer.setBPM(newBPM);
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
this.updateStatistics();
|
||||
|
||||
// Check if lesson is complete
|
||||
if (this.currentCharIndex === this.fullText.length) {
|
||||
setTimeout(() => this.completeLesson(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process backspace
|
||||
*/
|
||||
processBackspace() {
|
||||
if (this.currentCharIndex > 0) {
|
||||
this.userInput = this.userInput.slice(0, this.currentCharIndex - 1) + this.userInput.slice(this.currentCharIndex);
|
||||
this.currentCharIndex--;
|
||||
this.sessionManager.incrementKeystrokes();
|
||||
this.sessionManager.start();
|
||||
this.updateDisplay();
|
||||
this.updateStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process arrow left
|
||||
*/
|
||||
processArrowLeft() {
|
||||
if (this.currentCharIndex > 0) {
|
||||
this.currentCharIndex--;
|
||||
this.updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process arrow right
|
||||
*/
|
||||
processArrowRight() {
|
||||
if (this.currentCharIndex < this.fullText.length) {
|
||||
this.currentCharIndex++;
|
||||
this.updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update text display
|
||||
*/
|
||||
updateDisplay() {
|
||||
this.textDisplayManager.updateDisplay(this.userInput, this.currentCharIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update statistics display
|
||||
*/
|
||||
updateStatistics() {
|
||||
const elapsedTime = this.sessionManager.getElapsedTime();
|
||||
const stats = this.statisticsManager.calculateStatistics(
|
||||
this.userInput,
|
||||
this.fullText,
|
||||
elapsedTime,
|
||||
this.sessionManager.keyStrokeCount
|
||||
);
|
||||
this.statisticsManager.updateDisplay(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current progress
|
||||
*/
|
||||
async saveProgress() {
|
||||
try {
|
||||
await this.sessionManager.saveProgress(
|
||||
this.userInput,
|
||||
this.currentCharIndex,
|
||||
this.fullText
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save metronome settings
|
||||
*/
|
||||
async saveMetronomeSettings() {
|
||||
try {
|
||||
await fetch('/set_metronome', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: this.metronomePlayer.enabled,
|
||||
bpm: this.metronomePlayer.bpm,
|
||||
sound: this.metronomePlayer.sound,
|
||||
mode: this.metronomeMode,
|
||||
speed: this.metronomePlayer.bpm,
|
||||
target_error_rate: this.targetErrorRate
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving metronome settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete current lesson
|
||||
*/
|
||||
async completeLesson() {
|
||||
const wasPlaying = !this.sessionManager.isPaused;
|
||||
if (wasPlaying) {
|
||||
this.sessionManager.pause();
|
||||
this.metronomePlayer.stop();
|
||||
}
|
||||
|
||||
// Get final statistics
|
||||
const elapsedTime = this.sessionManager.getElapsedTime();
|
||||
const stats = this.statisticsManager.calculateStatistics(
|
||||
this.userInput,
|
||||
this.fullText,
|
||||
elapsedTime,
|
||||
this.sessionManager.keyStrokeCount
|
||||
);
|
||||
|
||||
// Save lesson statistics
|
||||
try {
|
||||
await this.statisticsManager.saveLessonStatistics(
|
||||
this.sessionManager.currentLessonIndex,
|
||||
stats.charsPerMinute,
|
||||
stats.errorRate,
|
||||
stats.wpm,
|
||||
this.csrfToken
|
||||
);
|
||||
console.log('Lektion beendet! Statistiken gespeichert.');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern:', error);
|
||||
}
|
||||
|
||||
// Reset for same lesson
|
||||
this.currentCharIndex = 0;
|
||||
this.userInput = '';
|
||||
this.sessionManager.reset();
|
||||
this.textDisplayManager.resetScroll();
|
||||
|
||||
if (wasPlaying) {
|
||||
this.sessionManager.resume();
|
||||
this.uiManager.showPause();
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
this.updateStatistics();
|
||||
|
||||
if (this.metronomePlayer.enabled && !this.sessionManager.isPaused) {
|
||||
this.metronomePlayer.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load next lesson
|
||||
*/
|
||||
async nextLesson() {
|
||||
try {
|
||||
const data = await this.sessionManager.nextLesson();
|
||||
|
||||
this.fullText = data.text;
|
||||
this.currentCharIndex = 0;
|
||||
this.userInput = '';
|
||||
this.sessionManager.reset();
|
||||
this.sessionManager.resume();
|
||||
|
||||
this.textDisplayManager.updateText(this.fullText);
|
||||
this.textDisplayManager.resetScroll();
|
||||
|
||||
this.uiManager.updateLessonTitle(data.title);
|
||||
this.uiManager.updateLessonHighlight(data.index);
|
||||
this.uiManager.showPause();
|
||||
|
||||
this.updateDisplay();
|
||||
this.updateStatistics();
|
||||
} catch (error) {
|
||||
this.uiManager.alert('Fehler beim Laden des nächsten Textes: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load specific lesson
|
||||
*/
|
||||
async loadLesson(lessonIndex) {
|
||||
try {
|
||||
const data = await this.sessionManager.setLesson(lessonIndex);
|
||||
|
||||
this.fullText = data.text;
|
||||
this.currentCharIndex = 0;
|
||||
this.userInput = '';
|
||||
this.sessionManager.reset();
|
||||
this.sessionManager.resume();
|
||||
|
||||
this.textDisplayManager.updateText(this.fullText);
|
||||
this.textDisplayManager.resetScroll();
|
||||
|
||||
this.uiManager.updateLessonTitle(data.title);
|
||||
this.uiManager.updateLessonHighlight(lessonIndex);
|
||||
this.uiManager.showPause();
|
||||
|
||||
this.updateDisplay();
|
||||
this.updateStatistics();
|
||||
} catch (error) {
|
||||
this.uiManager.alert('Fehler: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End session
|
||||
*/
|
||||
async endSession() {
|
||||
try {
|
||||
await this.sessionManager.endSession();
|
||||
this.uiManager.alert('Sitzung beendet. Fortschritt gespeichert.');
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
this.uiManager.alert('Fehler beim Beenden der Sitzung: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize application when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const app = new TypewriterApp();
|
||||
app.initialize();
|
||||
});
|
||||
|
||||
// Error handling
|
||||
window.addEventListener('error', (e) => {
|
||||
console.error('JavaScript Fehler:', e.error);
|
||||
});
|
||||
210
static/js/metronome.js
Normal file
210
static/js/metronome.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Adaptive Metronome Module
|
||||
* Implements an intelligent metronome that adjusts speed based on typing accuracy
|
||||
*/
|
||||
|
||||
export class AdaptiveMetronome {
|
||||
constructor() {
|
||||
this.speed = 60.0; // Starting speed in BPM
|
||||
this.accuracy_history = []; // Last 50 results (1=correct, 0=incorrect)
|
||||
this.consecutive_correct = 0; // Consecutive correct characters
|
||||
this.target_accuracy = 0.96; // Target accuracy (96%)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a keystroke and adjust speed
|
||||
* @param {boolean} is_correct - Whether the typed character was correct
|
||||
* @param {number} current_difficulty - Difficulty factor (1.0 = normal)
|
||||
* @returns {number} New BPM speed
|
||||
*/
|
||||
process_keystroke(is_correct, current_difficulty = 1.0) {
|
||||
// 1. Update history
|
||||
this.accuracy_history.push(is_correct ? 1.0 : 0.0);
|
||||
if (this.accuracy_history.length > 50) {
|
||||
this.accuracy_history.shift();
|
||||
}
|
||||
|
||||
// 2. Count consecutive correct characters
|
||||
if (is_correct) {
|
||||
this.consecutive_correct += 1;
|
||||
} else {
|
||||
this.consecutive_correct = 0;
|
||||
}
|
||||
|
||||
// 3. Adjust speed
|
||||
return this._adjust_speed(current_difficulty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust speed based on current accuracy
|
||||
* @param {number} difficulty - Difficulty factor
|
||||
* @returns {number} New BPM speed
|
||||
* @private
|
||||
*/
|
||||
_adjust_speed(difficulty) {
|
||||
if (this.accuracy_history.length === 0) {
|
||||
return this.speed;
|
||||
}
|
||||
|
||||
let current_accuracy = this.accuracy_history.reduce((a, b) => a + b, 0) / this.accuracy_history.length;
|
||||
let adjusted_target = this.target_accuracy - (difficulty * 0.05);
|
||||
|
||||
// Multiple adjustment strategies:
|
||||
if (this.consecutive_correct >= 15) {
|
||||
// Reward for sustained accuracy
|
||||
this.speed *= 1.03;
|
||||
} else if (current_accuracy > adjusted_target + 0.04) {
|
||||
// Too easy - accelerate more
|
||||
this.speed *= 1.02;
|
||||
} else if (current_accuracy < adjusted_target - 0.06) {
|
||||
// Too difficult - brake harder
|
||||
this.speed *= 0.92;
|
||||
} else if (current_accuracy < adjusted_target) {
|
||||
// Slightly below target - gentle braking
|
||||
this.speed *= 0.97;
|
||||
} else {
|
||||
// In target range - gentle acceleration
|
||||
this.speed *= 1.01;
|
||||
}
|
||||
|
||||
// Speed limits
|
||||
this.speed = Math.max(40, Math.min(200, this.speed));
|
||||
return this.speed;
|
||||
}
|
||||
}
|
||||
|
||||
export class MetronomePlayer {
|
||||
constructor() {
|
||||
this.audioContext = null;
|
||||
this.interval = null;
|
||||
this.enabled = true;
|
||||
this.bpm = 60;
|
||||
this.sound = 'beep';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a beep sound
|
||||
* @param {number} vol - Volume (0-1)
|
||||
* @param {number} freq - Frequency in Hz
|
||||
* @param {number} duration - Duration in ms
|
||||
*/
|
||||
createBeepSound(vol, freq, duration) {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
const gainNode = this.audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(this.audioContext.destination);
|
||||
|
||||
gainNode.gain.value = vol;
|
||||
oscillator.frequency.value = freq;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
oscillator.start();
|
||||
setTimeout(() => {
|
||||
oscillator.stop();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a click sound
|
||||
*/
|
||||
createClickSound() {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
const bufferSize = this.audioContext.sampleRate * 0.01;
|
||||
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const gainNode = this.audioContext.createGain();
|
||||
gainNode.gain.setValueAtTime(0.1, this.audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, this.audioContext.currentTime + 0.1);
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(this.audioContext.destination);
|
||||
source.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play metronome sound and provide visual feedback
|
||||
*/
|
||||
play() {
|
||||
// Visual feedback
|
||||
const cursorElement = document.querySelector('.cursor-char');
|
||||
if (cursorElement) {
|
||||
cursorElement.style.animation = 'pulse 0.3s';
|
||||
setTimeout(() => {
|
||||
cursorElement.style.animation = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Audio feedback (only if enabled)
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sound === 'beep') {
|
||||
this.createBeepSound(0.1, 800, 50);
|
||||
} else {
|
||||
this.createClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the metronome
|
||||
*/
|
||||
start() {
|
||||
this.stop();
|
||||
const interval = (60 / this.bpm) * 1000;
|
||||
this.play(); // Play immediately
|
||||
this.interval = setInterval(() => this.play(), interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the metronome
|
||||
*/
|
||||
stop() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set BPM and restart if playing
|
||||
* @param {number} bpm - Beats per minute
|
||||
*/
|
||||
setBPM(bpm) {
|
||||
const wasPlaying = this.interval !== null;
|
||||
this.bpm = bpm;
|
||||
if (wasPlaying) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle metronome on/off
|
||||
*/
|
||||
toggle() {
|
||||
this.enabled = !this.enabled;
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sound type
|
||||
* @param {string} sound - 'beep' or 'click'
|
||||
*/
|
||||
setSound(sound) {
|
||||
this.sound = sound;
|
||||
}
|
||||
}
|
||||
255
static/js/session.js
Normal file
255
static/js/session.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* Session Management Module
|
||||
* Handles session state, progress saving, and lesson management
|
||||
*/
|
||||
|
||||
export class SessionManager {
|
||||
constructor(csrfToken) {
|
||||
this.csrfToken = csrfToken;
|
||||
this.lastStartTime = null;
|
||||
this.totalElapsedTime = 0;
|
||||
this.keyStrokeCount = 0;
|
||||
this.isPaused = false;
|
||||
this.currentLessonIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from server-provided state
|
||||
* @param {Object} initialState - Initial state from server
|
||||
*/
|
||||
initialize(initialState) {
|
||||
this.totalElapsedTime = initialState.totalElapsedTime || 0;
|
||||
this.keyStrokeCount = initialState.keyStrokeCount || 0;
|
||||
this.isPaused = initialState.isPaused || false;
|
||||
this.currentLessonIndex = initialState.currentLessonIndex || 0;
|
||||
|
||||
if (!this.isPaused) {
|
||||
this.lastStartTime = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get elapsed time in seconds
|
||||
* @returns {number} Elapsed time in seconds
|
||||
*/
|
||||
getElapsedTime() {
|
||||
let elapsed = this.totalElapsedTime;
|
||||
if (this.lastStartTime !== null) {
|
||||
elapsed += (new Date() - this.lastStartTime);
|
||||
}
|
||||
return elapsed / 1000; // Convert to seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or resume timing
|
||||
*/
|
||||
start() {
|
||||
if (!this.isPaused && this.lastStartTime === null) {
|
||||
this.lastStartTime = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause timing
|
||||
*/
|
||||
pause() {
|
||||
if (this.lastStartTime !== null) {
|
||||
this.totalElapsedTime += (new Date() - this.lastStartTime);
|
||||
this.lastStartTime = null;
|
||||
}
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume timing
|
||||
*/
|
||||
resume() {
|
||||
this.isPaused = false;
|
||||
this.lastStartTime = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
* @returns {boolean} New pause state
|
||||
*/
|
||||
togglePause() {
|
||||
if (this.isPaused) {
|
||||
this.resume();
|
||||
} else {
|
||||
this.pause();
|
||||
}
|
||||
return this.isPaused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment keystroke count
|
||||
*/
|
||||
incrementKeystrokes() {
|
||||
this.keyStrokeCount++;
|
||||
if (this.lastStartTime === null && !this.isPaused) {
|
||||
this.lastStartTime = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset session state for new lesson
|
||||
*/
|
||||
reset() {
|
||||
this.lastStartTime = null;
|
||||
this.totalElapsedTime = 0;
|
||||
this.keyStrokeCount = 0;
|
||||
this.isPaused = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save progress to server
|
||||
* @param {string} userInput - User's current input
|
||||
* @param {number} cursorPosition - Current cursor position
|
||||
* @param {string} currentText - Current lesson text
|
||||
* @param {boolean} keepalive - Whether to use keepalive option
|
||||
* @returns {Promise} Fetch promise
|
||||
*/
|
||||
async saveProgress(userInput, cursorPosition, currentText, keepalive = false) {
|
||||
const data = {
|
||||
user_input: userInput,
|
||||
cursor_position: cursorPosition,
|
||||
current_text: currentText,
|
||||
elapsed_time: this.totalElapsedTime / 1000,
|
||||
total_elapsed_time: this.totalElapsedTime,
|
||||
key_stroke_count: this.keyStrokeCount,
|
||||
is_paused: this.isPaused
|
||||
};
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
};
|
||||
|
||||
if (keepalive) {
|
||||
options.keepalive = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/update_progress', options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern des Fortschritts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load next lesson
|
||||
* @returns {Promise<Object>} Lesson data
|
||||
*/
|
||||
async nextLesson() {
|
||||
try {
|
||||
const response = await fetch('/next_text', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
this.currentLessonIndex = data.index;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der nächsten Lektion:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set specific lesson
|
||||
* @param {number} lessonIndex - Index of the lesson
|
||||
* @returns {Promise<Object>} Lesson data
|
||||
*/
|
||||
async setLesson(lessonIndex) {
|
||||
try {
|
||||
const response = await fetch('/set_lesson', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lesson_index: lessonIndex
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
this.currentLessonIndex = lessonIndex;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Setzen der Lektion:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End session
|
||||
* @returns {Promise} Fetch promise
|
||||
*/
|
||||
async endSession() {
|
||||
try {
|
||||
const response = await fetch('/end_session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Beenden der Session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup beforeunload handler to save progress
|
||||
* @param {Function} getSaveData - Function that returns data to save
|
||||
*/
|
||||
setupBeforeUnload(getSaveData) {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
const data = getSaveData();
|
||||
this.saveProgress(
|
||||
data.userInput,
|
||||
data.cursorPosition,
|
||||
data.currentText,
|
||||
true // Use keepalive
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
115
static/js/statistics.js
Normal file
115
static/js/statistics.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Statistics Module
|
||||
* Handles calculation and display of typing statistics
|
||||
*/
|
||||
|
||||
export class StatisticsManager {
|
||||
constructor() {
|
||||
this.speedDisplay = 'zpm'; // 'zpm' or 'wpm'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate typing statistics
|
||||
* @param {string} userInput - User's typed input
|
||||
* @param {string} fullText - Target text
|
||||
* @param {number} elapsedTime - Time elapsed in seconds
|
||||
* @param {number} totalKeyStrokes - Total keystrokes including backspace
|
||||
* @returns {Object} Statistics object
|
||||
*/
|
||||
calculateStatistics(userInput, fullText, elapsedTime, totalKeyStrokes) {
|
||||
let correctKeyStrokes = 0;
|
||||
|
||||
// Count correct characters
|
||||
for (let i = 0; i < userInput.length; i++) {
|
||||
if (i < fullText.length && userInput[i] === fullText[i]) {
|
||||
correctKeyStrokes++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let incorrectKeyStrokes = totalKeyStrokes - correctKeyStrokes;
|
||||
let charsPerMinute = elapsedTime > 0 ? (correctKeyStrokes / elapsedTime) * 60 : 0;
|
||||
let errorRate = totalKeyStrokes > 0 ? (incorrectKeyStrokes * 100.0) / totalKeyStrokes : 0;
|
||||
let wpm = elapsedTime > 0 ? (correctKeyStrokes / 5) / (elapsedTime / 60) : 0;
|
||||
|
||||
return {
|
||||
correctKeyStrokes,
|
||||
incorrectKeyStrokes,
|
||||
totalKeyStrokes,
|
||||
charsPerMinute,
|
||||
errorRate,
|
||||
wpm
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update statistics display in the UI
|
||||
* @param {Object} stats - Statistics object from calculateStatistics
|
||||
*/
|
||||
updateDisplay(stats) {
|
||||
const speedElement = document.getElementById('speed');
|
||||
const errorRateElement = document.getElementById('error-rate');
|
||||
const correctElement = document.getElementById('correct');
|
||||
const incorrectElement = document.getElementById('incorrect');
|
||||
const totalElement = document.getElementById('total');
|
||||
const wpmElement = document.getElementById('wpm');
|
||||
const speedLabel = document.querySelector('#speed-label');
|
||||
|
||||
// Update display based on speed display preference
|
||||
if (this.speedDisplay === 'wpm') {
|
||||
if (speedElement) speedElement.textContent = Math.round(stats.wpm);
|
||||
if (speedLabel) speedLabel.textContent = 'WPM:';
|
||||
} else {
|
||||
if (speedElement) speedElement.textContent = Math.round(stats.charsPerMinute);
|
||||
if (speedLabel) speedLabel.textContent = 'ZPM:';
|
||||
}
|
||||
|
||||
if (errorRateElement) errorRateElement.textContent = stats.errorRate.toFixed(1);
|
||||
if (correctElement) correctElement.textContent = stats.correctKeyStrokes;
|
||||
if (incorrectElement) incorrectElement.textContent = stats.incorrectKeyStrokes;
|
||||
if (totalElement) totalElement.textContent = stats.totalKeyStrokes;
|
||||
if (wpmElement) wpmElement.textContent = Math.round(stats.wpm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set speed display preference
|
||||
* @param {string} display - 'zpm' or 'wpm'
|
||||
*/
|
||||
setSpeedDisplay(display) {
|
||||
this.speedDisplay = display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save lesson statistics to server
|
||||
* @param {number} lessonIndex - Index of the lesson
|
||||
* @param {number} charsPerMinute - Characters per minute
|
||||
* @param {number} errorRate - Error rate percentage
|
||||
* @param {number} wpm - Words per minute
|
||||
* @param {string} csrfToken - CSRF token
|
||||
* @returns {Promise} Fetch promise
|
||||
*/
|
||||
async saveLessonStatistics(lessonIndex, charsPerMinute, errorRate, wpm, csrfToken) {
|
||||
try {
|
||||
const response = await fetch('/save_lesson_statistics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lesson_index: lessonIndex,
|
||||
chars_per_minute: charsPerMinute,
|
||||
error_rate: errorRate,
|
||||
wpm: wpm
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern der Statistiken:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
219
static/js/textDisplay.js
Normal file
219
static/js/textDisplay.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Text Display Module
|
||||
* Handles text rendering, highlighting, and scrolling
|
||||
*/
|
||||
|
||||
export class TextDisplayManager {
|
||||
constructor(targetElement, fullText) {
|
||||
this.targetElement = targetElement;
|
||||
this.fullText = fullText;
|
||||
this.lines = fullText.split('\n');
|
||||
this.userScrolled = false;
|
||||
this.firstVisibleLine = 0;
|
||||
|
||||
// Setup scroll listener
|
||||
if (this.targetElement) {
|
||||
this.targetElement.addEventListener('scroll', () => {
|
||||
this.userScrolled = true;
|
||||
const lineHeight = this.targetElement.scrollHeight / this.lines.length;
|
||||
this.firstVisibleLine = Math.floor(this.targetElement.scrollTop / lineHeight);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the full text (e.g., when switching lessons)
|
||||
* @param {string} newText - New text to display
|
||||
*/
|
||||
updateText(newText) {
|
||||
this.fullText = newText;
|
||||
this.lines = newText.split('\n');
|
||||
this.userScrolled = false;
|
||||
this.firstVisibleLine = 0;
|
||||
if (this.targetElement) {
|
||||
this.targetElement.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML escape function for safe DOM manipulation
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped HTML
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a character with appropriate styling
|
||||
* @param {string} char - Character to highlight
|
||||
* @param {string} type - 'correct', 'incorrect', or 'neutral'
|
||||
* @param {boolean} isCursor - Whether this is the cursor position
|
||||
* @param {string|null} incorrectChar - The incorrect character typed (if applicable)
|
||||
* @returns {string} HTML string for the highlighted character
|
||||
*/
|
||||
highlightChar(char, type, isCursor = false, incorrectChar = null) {
|
||||
let className = `highlight-${type}`;
|
||||
if (isCursor) {
|
||||
className += ' cursor-char';
|
||||
}
|
||||
|
||||
const escapedChar = this.escapeHtml(char);
|
||||
|
||||
if (char === ' ') {
|
||||
return `<span class="${className} highlight-space">·</span>`;
|
||||
} else if (char === '\t') {
|
||||
return `<span class="${className} highlight-tab">→</span>`;
|
||||
} else if (char === '\n') {
|
||||
return `<span class="${className} highlight-newline">↵</span><br>`;
|
||||
} else {
|
||||
if (type === 'incorrect' && incorrectChar !== null) {
|
||||
const escapedIncorrectChar = this.escapeHtml(incorrectChar);
|
||||
return `<span class="${className} incorrect-char-container">${escapedChar}<span class="incorrect-char-overlay">${escapedIncorrectChar}</span></span>`;
|
||||
} else {
|
||||
return `<span class="${className}">${escapedChar}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the current line index based on character position
|
||||
* @param {number} currentCharIndex - Current character position
|
||||
* @returns {number} Line index
|
||||
*/
|
||||
getCurrentLineIndex(currentCharIndex) {
|
||||
let charCount = 0;
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
const lineLength = this.lines[i].length + (i < this.lines.length - 1 ? 1 : 0);
|
||||
if (currentCharIndex < charCount + lineLength) {
|
||||
return i;
|
||||
}
|
||||
charCount += lineLength;
|
||||
}
|
||||
return this.lines.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the text display with highlighting
|
||||
* @param {string} userInput - User's current input
|
||||
* @param {number} currentCharIndex - Current cursor position
|
||||
*/
|
||||
updateDisplay(userInput, currentCharIndex) {
|
||||
if (!this.targetElement) return;
|
||||
|
||||
let newHTML = '';
|
||||
let charCount = 0;
|
||||
|
||||
for (let j = 0; j < this.lines.length; j++) {
|
||||
const line = this.lines[j];
|
||||
let highlightedLine = '';
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const currentChar = line[i];
|
||||
const isCursor = (charCount === currentCharIndex);
|
||||
|
||||
if (charCount < userInput.length) {
|
||||
if (userInput[charCount] === currentChar) {
|
||||
highlightedLine += this.highlightChar(currentChar, 'correct', isCursor);
|
||||
} else {
|
||||
highlightedLine += this.highlightChar(currentChar, 'incorrect', isCursor, userInput[charCount]);
|
||||
}
|
||||
} else {
|
||||
highlightedLine += this.highlightChar(currentChar, 'neutral', isCursor);
|
||||
}
|
||||
charCount++;
|
||||
}
|
||||
|
||||
// Newline after each line (except the last)
|
||||
if (j < this.lines.length - 1) {
|
||||
const isCursor = (charCount === currentCharIndex);
|
||||
if (charCount < userInput.length) {
|
||||
if (userInput[charCount] === '\n') {
|
||||
highlightedLine += this.highlightChar('\n', 'correct', isCursor);
|
||||
} else {
|
||||
highlightedLine += this.highlightChar('\n', 'incorrect', isCursor, userInput[charCount]);
|
||||
}
|
||||
} else {
|
||||
highlightedLine += this.highlightChar('\n', 'neutral', isCursor);
|
||||
}
|
||||
charCount++;
|
||||
}
|
||||
|
||||
const currentLineIndex = this.getCurrentLineIndex(currentCharIndex);
|
||||
newHTML += `<div class="text-line ${j === currentLineIndex ? 'current-line' : ''}">${highlightedLine}</div>`;
|
||||
}
|
||||
|
||||
this.targetElement.innerHTML = newHTML;
|
||||
|
||||
// Auto-scroll if user hasn't manually scrolled
|
||||
if (!this.userScrolled) {
|
||||
setTimeout(() => {
|
||||
const currentLineIndex = this.getCurrentLineIndex(currentCharIndex);
|
||||
const lineElements = this.targetElement.getElementsByClassName('text-line');
|
||||
|
||||
if (lineElements.length > 0) {
|
||||
const lineHeight = lineElements[0].offsetHeight;
|
||||
const containerHeight = this.targetElement.clientHeight;
|
||||
|
||||
if (lineHeight === 0) {
|
||||
// Retry if height is 0
|
||||
setTimeout(() => this.updateDisplay(userInput, currentCharIndex), 10);
|
||||
return;
|
||||
}
|
||||
|
||||
let desiredScrollTop = currentLineIndex * lineHeight;
|
||||
|
||||
const maxScrollTop = this.targetElement.scrollHeight - containerHeight;
|
||||
desiredScrollTop = Math.min(desiredScrollTop, maxScrollTop);
|
||||
desiredScrollTop = Math.max(0, desiredScrollTop);
|
||||
|
||||
this.targetElement.scrollTop = desiredScrollTop;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset scroll state
|
||||
*/
|
||||
resetScroll() {
|
||||
this.userScrolled = false;
|
||||
this.firstVisibleLine = 0;
|
||||
if (this.targetElement) {
|
||||
this.targetElement.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is completely correct
|
||||
* @param {number} lineIndex - Index of the line to check
|
||||
* @param {string} userInput - User's input
|
||||
* @returns {boolean} True if line is complete and correct
|
||||
*/
|
||||
isLineComplete(lineIndex, userInput) {
|
||||
if (lineIndex >= this.lines.length) return false;
|
||||
|
||||
let startIndex = 0;
|
||||
for (let i = 0; i < lineIndex; i++) {
|
||||
startIndex += this.lines[i].length + 1;
|
||||
}
|
||||
const endIndex = startIndex + this.lines[lineIndex].length;
|
||||
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
if (i >= userInput.length || userInput[i] !== this.fullText[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (lineIndex < this.lines.length - 1) {
|
||||
const newlineIndex = endIndex;
|
||||
if (newlineIndex >= userInput.length || userInput[newlineIndex] !== '\n') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
67
static/js/theme.js
Normal file
67
static/js/theme.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Theme Management Module
|
||||
* Handles light/dark theme switching and persistence
|
||||
*/
|
||||
|
||||
export class ThemeManager {
|
||||
constructor() {
|
||||
this.currentTheme = 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize theme from localStorage or default to light mode
|
||||
*/
|
||||
initialize() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
this.setTheme(savedTheme);
|
||||
this.updateThemeButton(savedTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the theme
|
||||
* @param {string} theme - 'light' or 'dark'
|
||||
*/
|
||||
setTheme(theme) {
|
||||
this.currentTheme = theme;
|
||||
document.body.setAttribute('data-bs-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark theme
|
||||
*/
|
||||
toggle() {
|
||||
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
|
||||
this.setTheme(newTheme);
|
||||
this.updateThemeButton(newTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the theme toggle button icons
|
||||
* @param {string} theme - Current theme
|
||||
*/
|
||||
updateThemeButton(theme) {
|
||||
const sunIcon = document.getElementById('theme-toggle-sun');
|
||||
const moonIcon = document.getElementById('theme-toggle-moon');
|
||||
|
||||
if (sunIcon && moonIcon) {
|
||||
if (theme === 'dark') {
|
||||
sunIcon.style.display = 'none';
|
||||
moonIcon.style.display = 'block';
|
||||
} else {
|
||||
sunIcon.style.display = 'block';
|
||||
moonIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for theme toggle button
|
||||
*/
|
||||
setupEventListeners() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => this.toggle());
|
||||
}
|
||||
}
|
||||
}
|
||||
253
static/js/ui.js
Normal file
253
static/js/ui.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* UI Management Module
|
||||
* Handles UI interactions, buttons, modals, and toggles
|
||||
*/
|
||||
|
||||
export class UIManager {
|
||||
constructor() {
|
||||
this.volumeOn = null;
|
||||
this.volumeOff = null;
|
||||
this.lockClosed = null;
|
||||
this.lockOpen = null;
|
||||
this.playPausePause = null;
|
||||
this.playPausePlay = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize UI elements
|
||||
*/
|
||||
initialize() {
|
||||
// Lucide Icons
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Get UI elements
|
||||
this.volumeOn = document.getElementById('volume-on');
|
||||
this.volumeOff = document.getElementById('volume-off');
|
||||
this.lockClosed = document.getElementById('lock-closed');
|
||||
this.lockOpen = document.getElementById('lock-open');
|
||||
this.playPausePause = document.getElementById('play-pause-pause');
|
||||
this.playPausePlay = document.getElementById('play-pause-play');
|
||||
|
||||
// Initialize states
|
||||
this.showVolumeOn();
|
||||
this.showLockOpen();
|
||||
this.showPause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show volume on icon
|
||||
*/
|
||||
showVolumeOn() {
|
||||
if (this.volumeOn) this.volumeOn.style.display = 'block';
|
||||
if (this.volumeOff) this.volumeOff.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show volume off icon
|
||||
*/
|
||||
showVolumeOff() {
|
||||
if (this.volumeOn) this.volumeOn.style.display = 'none';
|
||||
if (this.volumeOff) this.volumeOff.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle volume icon
|
||||
* @param {boolean} enabled - Whether volume is enabled
|
||||
*/
|
||||
toggleVolume(enabled) {
|
||||
if (enabled) {
|
||||
this.showVolumeOn();
|
||||
} else {
|
||||
this.showVolumeOff();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show lock closed icon
|
||||
*/
|
||||
showLockClosed() {
|
||||
if (this.lockClosed) this.lockClosed.style.display = 'block';
|
||||
if (this.lockOpen) this.lockOpen.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show lock open icon
|
||||
*/
|
||||
showLockOpen() {
|
||||
if (this.lockClosed) this.lockClosed.style.display = 'none';
|
||||
if (this.lockOpen) this.lockOpen.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle lock icon
|
||||
* @param {boolean} locked - Whether error lock is enabled
|
||||
*/
|
||||
toggleLock(locked) {
|
||||
if (locked) {
|
||||
this.showLockClosed();
|
||||
} else {
|
||||
this.showLockOpen();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show pause icon (playing state)
|
||||
*/
|
||||
showPause() {
|
||||
if (this.playPausePause) this.playPausePause.style.display = 'block';
|
||||
if (this.playPausePlay) this.playPausePlay.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show play icon (paused state)
|
||||
*/
|
||||
showPlay() {
|
||||
if (this.playPausePause) this.playPausePause.style.display = 'none';
|
||||
if (this.playPausePlay) this.playPausePlay.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle play/pause icon
|
||||
* @param {boolean} isPaused - Whether session is paused
|
||||
*/
|
||||
togglePlayPause(isPaused) {
|
||||
if (isPaused) {
|
||||
this.showPlay();
|
||||
} else {
|
||||
this.showPause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update lesson title in UI
|
||||
* @param {string} title - Lesson title
|
||||
*/
|
||||
updateLessonTitle(title) {
|
||||
const titleElement = document.getElementById('lesson-title');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = title;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update lesson highlight in sidebar
|
||||
* @param {number} lessonIndex - Index of current lesson
|
||||
*/
|
||||
updateLessonHighlight(lessonIndex) {
|
||||
document.querySelectorAll('.lesson-link').forEach(link => {
|
||||
const index = parseInt(link.getAttribute('data-lesson-index'));
|
||||
if (index === lessonIndex) {
|
||||
link.classList.add('fw-bold', 'text-danger');
|
||||
link.classList.remove('text-primary');
|
||||
} else {
|
||||
link.classList.remove('fw-bold', 'text-danger');
|
||||
link.classList.add('text-primary');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup help modal
|
||||
*/
|
||||
setupHelpModal() {
|
||||
const helpToggle = document.getElementById('help-toggle');
|
||||
if (helpToggle) {
|
||||
helpToggle.addEventListener('click', () => {
|
||||
const helpModal = new bootstrap.Modal(document.getElementById('helpModal'));
|
||||
helpModal.show();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup settings navigation
|
||||
*/
|
||||
setupSettingsLink() {
|
||||
const settingsToggle = document.getElementById('settings-toggle');
|
||||
if (settingsToggle) {
|
||||
settingsToggle.addEventListener('click', () => {
|
||||
window.location.href = '/settings';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup statistics navigation
|
||||
*/
|
||||
setupStatisticsLink(saveCallback) {
|
||||
document.querySelectorAll('.statistics-link').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
console.log('Statistics link clicked');
|
||||
if (saveCallback) {
|
||||
saveCallback();
|
||||
}
|
||||
window.location.href = '/statistics';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup performance toggle
|
||||
*/
|
||||
setupPerformanceToggle() {
|
||||
const performanceBtn = document.getElementById('performance-btn');
|
||||
const progressContainer = document.querySelector('.progress-stats-container');
|
||||
|
||||
if (!performanceBtn || !progressContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load saved state
|
||||
const progressStatsVisible = localStorage.getItem('progressStatsVisible') === 'true';
|
||||
this.setProgressStatsVisibility(progressStatsVisible);
|
||||
|
||||
// Event listener
|
||||
performanceBtn.addEventListener('click', () => {
|
||||
const isVisible = progressContainer.classList.contains('visible');
|
||||
this.setProgressStatsVisibility(!isVisible);
|
||||
localStorage.setItem('progressStatsVisible', !isVisible);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set progress stats visibility
|
||||
* @param {boolean} visible - Whether stats should be visible
|
||||
*/
|
||||
setProgressStatsVisibility(visible) {
|
||||
const progressContainer = document.querySelector('.progress-stats-container');
|
||||
const performanceBtn = document.getElementById('performance-btn');
|
||||
|
||||
if (!progressContainer || !performanceBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
progressContainer.classList.add('visible');
|
||||
performanceBtn.classList.remove('btn-light-custom');
|
||||
performanceBtn.classList.add('btn-primary');
|
||||
} else {
|
||||
progressContainer.classList.remove('visible');
|
||||
performanceBtn.classList.remove('btn-primary');
|
||||
performanceBtn.classList.add('btn-light-custom');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show alert message
|
||||
* @param {string} message - Message to display
|
||||
*/
|
||||
alert(message) {
|
||||
alert(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm dialog
|
||||
* @param {string} message - Message to display
|
||||
* @returns {boolean} User's choice
|
||||
*/
|
||||
confirm(message) {
|
||||
return confirm(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -268,9 +268,11 @@
|
|||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
// Übergib die aktuelle Lektionsnummer an JavaScript
|
||||
// Übergib zusätzliche Konfiguration an JavaScript
|
||||
window.currentLessonIndex = {{ current_lesson_index }};
|
||||
window.speedDisplay = {{ speed_display|tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
<!-- Load modular JavaScript (ES6 modules) -->
|
||||
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue