15 Commits

Author SHA1 Message Date
AliceDarkest 55a09cb093 Doku: STYLEGUIDE.md – Designsystem/Farbtokens aus theme.css
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:09:21 +02:00
AliceDarkest 4599dd12b8 Syntax Saga: neues Spiel (server-autoritativ) + 34 Seed-Missionen
Drittes Mindflux-Spiel: interaktive IT-Geschichten, in denen man den
fehlenden Code (Schlüsselwörter, Operatoren, ganze Zeilen) in eine kleine
Story tippt. Freitext-Eingabe, drei wählbare Stufen (Junior/Professional/
Senior), mehrere Lücken je Codeblock und mehrere gültige Antworten je Lücke
(optional case-insensitive). Prüfung und Score ausschließlich serverseitig –
die Lösungen verlassen nie das Backend (Anti-Cheat wie TechTacular/MindMatch).
Fehler-Mechanik: bis zu 3 Versuche mit Hinweis, dann Auflösung; kein Game-Over.

Inhalte DB-gestützt und im Admin pflegbar (/admin/syntaxsaga); 34 Seed-
Missionen (Java/SQL/Bash/Git/HTML/Python) aus syntaxsaga/content.json.

- Entities SyntaxSagaMission/Gap/Solution + Flyway V7__syntax_saga.sql
- SyntaxSagaService (Story ohne Lösung / serverseitige Prüfung / Score-Recompute)
- API /api/syntaxsaga (CSRF-ausgenommen) + Admin-CRUD
- Frontend syntaxsaga.html/js/css (Prefix ss-, theme.css-Tokens)
- Dashboard-Kachel aktiv (vorher "coming soon")

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:13:37 +02:00
AliceDarkest bbe24c67eb Mobile-Fixes: Spiele scrollbar + FinalFlux-Hub Overlap/Menue
- MindMatch/TechTacular: Boards passten auf dem Handy nicht in 100vh
  (iOS-Adressleiste) und die Seite war auf overflow:hidden gesperrt ->
  untere Felder unerreichbar. Auf <=820px jetzt scrollbar (height:auto,
  overflow-y:auto, 100dvh), Karten via aspect-ratio, Kacheln via min-height.
- FinalFlux-Hub: das generische `main {}` aus der Alt-style.css traf das
  Hub-<main class="finalflux-main"> (display:flex; width:90%; margin:auto)
  und verursachte Overlap + unerreichbares Themen-Menue. In .finalflux-main
  neutralisiert; Shell stapelt auf Mobil als display:block, Sidebar volle Hoehe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:12:10 +02:00
AliceDarkest b4d71d7d59 Merge pull request 'Feature/responsive mobile' (#5) from feature/responsive-mobile into main
mindflux-ci / build (push) Successful in 29s
Reviewed-on: #5
2026-06-29 17:51:19 +00:00
AliceDarkest e26a0c059c Fehler-Rot von Orange abgesetzt (kuehleres Crimson)
--danger lag farblich zu nah am orangen Akzent (v.a. Light-Mode:
accent #d65116 vs danger #c61f2b). Jetzt kuehleres Crimson-Rose:
dunkel #ff4d6d, hell #be123c -> klare Abgrenzung von Orange, weiterhin
eindeutig Alarm-Rot. Erbt automatisch in alle Spiele-/Alert-States.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:48:03 +02:00
AliceDarkest bf27c9ccdc Fix: Login-Overlay im Light-Mode lesbar (Orange-Panel statt weiss-auf-weiss)
Das Overlay nutzt im Dark-Mode dunkle Surface-Toene mit Glow. Im Light-Mode
sind diese weiss -> das rechte Panel verschwand (weiss auf weiss). Per
[data-theme="light"]-Overrides bekommt es jetzt ein kraeftiges Orange-Panel
(accent-2 -> accent) mit weissem Text, Emblem und Ghost-Button. Dark-Mode
bleibt unveraendert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:43:14 +02:00
AliceDarkest 0e4645541d Responsive: mobile Off-Canvas-Navigation + Layout-Anpassungen
- Sidebar wird auf <=820px nicht mehr ausgeblendet (kein Nav-Verlust), sondern
  zu einem Off-Canvas-Drawer mit Hamburger-Button + Backdrop (theme.js
  injiziert ihn auf allen .mf-shell-Seiten; Styles in theme.css).
- Topbar stapelt/umbricht auf Mobil (dekorativer Puls + Suche ausgeblendet,
  kleinere Begrüßung), HUD-Karten-Grid 3->2->1, breite mf-Tabellen horizontal
  scrollbar. Schließen per Backdrop, Nav-Link oder Escape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:39:33 +02:00
AliceDarkest 477d4dc04a Merge pull request 'Rechtstexte, FinalFlux-Überarbeitung, neue Login-Optik, Konto-Löschung-Fix, BTTF-Farbrelaunch' (#4) from feature/bttf-relaunch-und-inhalte into main
mindflux-ci / build (push) Successful in 30s
Reviewed-on: #4
2026-06-29 17:27:45 +00:00
AliceDarkest 34b050503d Rechtstexte, FinalFlux-Überarbeitung, neue Login-Optik, Konto-Löschung-Fix, BTTF-Farbrelaunch
- Rechtsseiten: Impressum-Platzhalter + echte Nutzungsbedingungen/AGB,
  Datenschutz (DSGVO) und Richtlinien; LegalController/ImpressumController,
  Footer app-weit verlinkt, Seiten als permitAll freigegeben.
- FinalFlux: 28 Themen / ~742 Fragen überarbeitet (plausible Distraktoren statt
  Dummy-Antworten, echte Erklärungen) + echte Lehrtexte je Seite;
  Netzwerktopologien mit Inline-SVG-Diagrammen (Stern/Bus/Ring/Mesh/Baum).
- Login/Registrierung als kombiniertes Dual-Panel (Slide-Overlay + Neon-Hover-
  Button), ausgelagert nach auth.css; Register-Duplikat -> /?registerError.
- Konto löschen repariert: @Transactional + Löschen von Spielständen,
  Achievements und Profilbild-Datei (DSGVO Art. 17). Impressum-Zurück-Link
  login-abhängig.
- App-weiter Farb-Relaunch auf "Back to the Future"-Palette: Feuer-Amber primär
  + Flux-Blau sekundär, warmer Glow; Dark/Light bleiben. Spiele-Feedback ohne
  Grün: richtig = Blau + Haken, falsch = Rot + Kreuz (neue Tokens
  --success/--danger/--secondary). Legacy-Cyan in style.css auf Variablen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:08:05 +02:00
AliceDarkest 83929f24ba Merge pull request 'Profilbild-Upload (413+Skalierung) und CSRF' (#3) from fix/upload-csrf into main
mindflux-ci / build (push) Successful in 27s
2026-06-26 13:24:32 +00:00
AliceDarkest fc98e5aad1 Profilbild-Upload (413 + Auto-Skalierung) und CSRF aktivieren
- application.properties: multipart max-file/request-size auf 10MB
  (nginx client_max_body_size muss serverseitig passend gesetzt sein)
- ProfileController: hochgeladene Profilbilder werden serverseitig auf
  max. 400px herunterskaliert und als JPEG normalisiert gespeichert
  (statt der Originaldatei roh); nicht lesbare Bilder werden ignoriert
- SecurityConfig: CSRF aktiviert; Spiele-JSON-APIs (/api/games,
  /api/techtacular, /api/mindmatch) ausgenommen; GET-Logout erlaubt,
  da die Logout-Links in den Templates GET sind

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:22:25 +02:00
AliceDarkest b3a0fde104 Merge pull request 'Fix: Passwort-Aenderung Whitelabel-Error' (#2) from fix/change-password into main
mindflux-ci / build (push) Successful in 26s
2026-06-26 13:10:01 +00:00
AliceDarkest 7cb6621d1b Fix: Passwort-Änderung wirft Whitelabel-Error
ProfileController.changePassword gab "profile" zurück, ohne das von
profile.html benötigte Model-Attribut `user` zu setzen -> Template-Rendering
schlug fehl (500/Whitelabel). Zudem lag das Formular auf der settings-Seite.

- changePassword nutzt jetzt PRG: Redirect auf /profile/settings mit
  Flash-Attributen (message/error) statt direktem View-Return.
- settings.html zeigt die Flash-Meldungen (Erfolg/Fehler) an.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:07:53 +02:00
AliceDarkest 7ede78d984 Merge pull request 'Volle Mindflux-App ins Deploy-Repo uebernehmen' (#1) from deploy/full-app into main
mindflux-ci / build (push) Successful in 27s
2026-06-26 12:59:01 +00:00
AliceDarkest 9022961762 Volle Mindflux-App ins Deploy-Repo übernehmen
Ersetzt das Hello-World-Gerüst durch die vollständige Anwendung
(com.example.mindflux: 64 Klassen, Thymeleaf-Templates, Static-Assets,
Flyway V1–V6). Deploy-Infrastruktur (.gitea, Dockerfile) bleibt erhalten.

- pom: volle Abhängigkeiten (Security, Thymeleaf, JPA, Flyway), Spring Boot 3.4.4 / Java 17
- docker-compose: SPRING_DATASOURCE_* unverändert; App lauscht auf 8080
  (nginx -> mindflux:8080); Uploads-Volume; Admin-Passwörter via Gitea-Secrets
- Workflow: Deploy-Step injiziert MINDFLUX_ADMIN*_PASSWORD aus Secrets
- .dockerignore ergänzt; Hello-World (com.mindflux) und Test-Datei 'main' entfernt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:51:30 +02:00
217 changed files with 28334 additions and 142 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
target
uploads
*.log
.env
.env.*
.claude
.idea
.settings
.classpath
.project
+2
View File
@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf
+3
View File
@@ -21,6 +21,9 @@ jobs:
docker push localhost:5000/mindflux:latest
- name: Deploy
env:
MINDFLUX_ADMIN_PASSWORD: ${{ secrets.MINDFLUX_ADMIN_PASSWORD }}
MINDFLUX_ADMIN2_PASSWORD: ${{ secrets.MINDFLUX_ADMIN2_PASSWORD }}
run: |
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
+36 -41
View File
@@ -1,45 +1,40 @@
# ---> Java
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
# ---> Maven
HELP.md
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# https://github.com/takari/maven-wrapper#usage-without-binary-jar
.mvn/wrapper/maven-wrapper.jar
uploads/
.env
.env.*
!.env.example
*.log
.claude/
hs_err_pid*.log
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
# Eclipse m2e generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
+132
View File
@@ -0,0 +1,132 @@
# Changelog
Alle nennenswerten Änderungen an **Mindflux** werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
Versionierung [semantisch](https://semver.org/lang/de/).
## [Unreleased] – 2026-06-30
Neues Spiel **Syntax Saga**: interaktive IT-Geschichten, in denen man den fehlenden
Code (Schlüsselwörter, Operatoren, ganze Zeilen) in eine kleine Story tippt. Server-autoritativ
nach dem TechTacular-Muster, Inhalte DB-gestützt und im Admin pflegbar. Die Dashboard-Kachel
„Syntax Saga" ist damit aktiv (vorher „coming soon").
### Hinzugefügt
- **Spiel: Syntax Saga (`/syntaxsaga`):** Missions-Saga in drei **wählbaren Schwierigkeitsstufen**
(Junior = eine Lücke, Professional = mehrere Lücken, Senior = ganze Zeile rekonstruieren). Jede
Mission besteht aus Erzähltext + optionalem Dialog + Codeblock mit `{{n}}`-Lücken. Eine Lücke kann
**mehrere gültige Antworten** haben (z. B. `localhost` / `192.168.0.1`) und optional
groß-/kleinschreibungs-unabhängig prüfen (z. B. SQL-Keywords). Eingabe per Freitext; **Prüfung und
Score ausschließlich serverseitig** – die Lösungen verlassen nie das Backend (Anti-Cheat wie bei
TechTacular/MindMatch).
- **Fehler-Mechanik:** pro Lücke bis zu drei Versuche mit Hinweis, danach **„Auflösung anzeigen"**; die
Geschichte läuft immer weiter (**kein Game-Over**). Punkte je nach Versuch (Erstversuch voll, danach
weniger), aufgelöste Lücke = 0 Punkte; Sterne­wertung 0–3.
- **Admin-Verwaltung (`/admin/syntaxsaga`):** Missionen, Lücken und Lösungen anlegen/bearbeiten/löschen,
aktiv/inaktiv schalten. Mitgeliefert: **34 Seed-Missionen** (16 Junior · 10 Professional · 8 Senior,
zusammen 44 Lücken) aus `syntaxsaga/content.json` (idempotenter Seeder, lädt nur in leere Tabellen).
Themen quer durch den Stack: **Java** (Schleifen, OOP/`class`/`this`, Exceptions, Collections,
Operatoren), **SQL** (`WHERE`/`ORDER BY`/`JOIN … ON`/`INSERT`/`UPDATE`/`DELETE`), **Bash**
(`cd`/`grep`/Pipe), **Git** (`commit`), **HTML** (Link/Formular) und **Python** (`print`); Senior
rekonstruiert ganze Zeilen (z. B. `public static void main(String[] args)`,
`Scanner sc = new Scanner(System.in)`) mit ein paar tolerierten Schreibvarianten.
- Spielstände laufen über die bestehende generische `game_scores`-Tabelle (Spiel-Kennung `syntax-saga`),
inkl. Highscore/„Neuer Bestwert" und Statistik im Spiel-HUD.
### Technik
- Neue Entities `SyntaxSagaMission` / `SyntaxSagaGap` / `SyntaxSagaSolution` + Flyway-Migration
`V7__syntax_saga.sql`; server-autoritativer `SyntaxSagaService` (Story ohne Lösungen / serverseitige
Prüfung / Score-Recompute beim Abschluss); JSON-API `/api/syntaxsaga/**` (CSRF-ausgenommen). Frontend
unter `syntaxsaga.html` / `syntaxsaga.js` / `syntaxsaga.css` (Prefix `ss-`, zentrale `theme.css`-Tokens).
## [Unreleased] – 2026-06-29
Inhaltlicher und optischer Feinschliff: echte Rechtstexte, vollständig
überarbeitete FinalFlux-Inhalte, neue Login-Optik, eine reparierte
Konto-Löschung sowie ein app-weiter Farb-Relaunch auf das
„Back to the Future"-Schema.
### Hinzugefügt
- **Rechtsseiten:** Impressum-Platzhalter (Hinweis „in Entwicklung / nicht für die öffentliche Nutzung") sowie **echte** Nutzungsbedingungen/AGB, Datenschutzerklärung (DSGVO-konform, spiegelt die tatsächlich verarbeiteten Daten) und Community-/Nutzungsrichtlinien. Neuer `LegalController` (`/nutzungsbedingungen`, `/datenschutz`, `/richtlinien`) + `ImpressumController`; alle Footer-Links app-weit verdrahtet.
- **Kombinierte Login/Registrierung:** Dual-Panel mit überschiebendem Overlay (Sign-in ↔ „Account erstellen") und Neon-Button mit Hover-Expand und rotierendem Conic-Glow; ausgelagert nach `static/css/auth.css`. Responsiver Fallback (gestapelt) auf schmalen Screens.
- **FinalFlux – Netzwerktopologien:** fünf theme-adaptive Inline-SVG-Diagramme (Stern, Bus, Ring, Mesh, Baum) in den Erklärungen.
### Geändert
- **FinalFlux-Inhalte komplett überarbeitet (28 Themen, ~742 Fragen):** die generischen Dummy-Falschantworten („nur Dekoration", „zufälliger Begriff" …) durch plausible, themennahe Distraktoren ersetzt, jede Frage mit echter Begründung und variierender Antwortposition. Jede Themenseite hat jetzt **echten Lehrtext** (Definitionen, Funktionsweise, Rechenbeispiele, Merksätze) statt des bisherigen Stubs.
- **App-weiter Farb-Relaunch auf „Back to the Future"-Palette:** Primär **Feuer-Amber** (`#f0a81a`) + Gelb-Glow, **Flux-Blau** (`#2d9cce`) als Zweitakzent (u. a. Links); warmer Bernstein-Glow statt Matrix-Grün. Dark/Light bleiben (sehr dunkler/sehr heller Grund). Zentral über `theme.css`-Tokens; Legacy-Cyan in `style.css` auf Theme-Variablen umgestellt.
- **Spiele-Feedback ohne Grün:** richtig = **Flux-Blau + ✓**, falsch = **Rot + ✗** in FinalFlux, MindMatch und TechTacular (löst den fehlenden Kontrast von „grün auf grün"); neue Tokens `--success`, `--danger`, `--secondary`. Das Fehler-Rot ist ein kühleres Crimson (`#ff4d6d` dunkel / `#be123c` hell), damit es sich klar vom orangen Akzent abhebt (v. a. im Light-Mode).
- Registrierungs-Duplikat leitet auf `/?registerError` (kombinierte Seite öffnet automatisch das Signup-Panel).
- **Vollständig responsive / mobiltauglich:** Auf schmalen Screens wird die App-Sidebar nicht mehr ausgeblendet (vorher fehlte die Navigation komplett), sondern zu einer Off-Canvas-Schublade mit Hamburger-Button + Backdrop (global via `theme.js` injiziert, Styles in `theme.css`). Topbar stapelt/umbricht (dekorativer Puls + Suche ausgeblendet), Spielkarten-Grid 3→2→1, breite Tabellen horizontal scrollbar. Spiele, FinalFlux und Login/Registrierung waren bereits responsiv.
### Behoben
- **Konto-Löschung repariert:** „Profil löschen" warf einen 500er (fehlendes `@Transactional` bei einer abgeleiteten Delete-Query) und löschte nichts. Jetzt werden Konto **plus** Spielstände, Achievements und Profilbild-Datei in einer Transaktion entfernt — erfüllt die in der Datenschutzerklärung zugesicherte Löschung (Art. 17 DSGVO).
- Impressum-„Zurück"-Link loggte eingeloggte Nutzer scheinbar aus; jetzt login-abhängig (eingeloggt → Dashboard, sonst → Startseite). Gleiche Logik auf den Rechtsseiten.
- Login-Overlay im **Light-Mode** war weiß-auf-weiß/verwaschen; jetzt kräftiges Orange-Panel mit weißem Text (Dark-Mode unverändert).
- **Mobile-Bedienbarkeit der Spiele:** MindMatch- und TechTacular-Boards ragten auf dem Handy aus dem Bildschirm, ohne dass man scrollen konnte (`height:100vh; overflow:hidden` + iOS-Adressleiste). Auf Touch-Breiten jetzt scrollbar, Karten/Kacheln mit fester Proportion bzw. Mindesthöhe → alle Felder erreichbar.
- **FinalFlux-Hub auf Mobil:** Überlappung von Kopfbereich und Inhalt + unerreichbares Themen-Menü. Ursache: das generische `main {}` aus der Alt-`style.css` traf das Hub-`<main>`; jetzt in `.finalflux-main` neutralisiert und die Shell stapelt auf Mobil sauber (`display:block`, Sidebar mit voller Höhe).
### Sicherheit
- Neue Rechtsseiten öffentlich erreichbar (`permitAll` für `/impressum`, `/nutzungsbedingungen`, `/datenschutz`, `/richtlinien`).
### Hinweise
- Footer-Links **Support** und **Feedback** sind weiterhin Platzhalter (`href="#"`).
- Die Rechtstexte sind inhaltlich vollständig, aber KI-erstellt — vor einem öffentlichen Launch fachlich/juristisch prüfen (insb. konkrete Anbieteranschrift im Impressum).
- Vier ungenutzte Alt-CSS-Dateien (`progress/settings/achievements/soziales.css`, keine Referenzen) blieben vorerst liegen.
## [Unreleased] – 2026-06-25
Großer Ausbau: zwei vollständige Spiele, durchgängig DB-gestützte und
admin-pflegbare Inhalte sowie ein kompletter Optik-Relaunch (Theme-System
mit Dark/Light-Umschaltung).
### Hinzugefügt
#### Spiel: MindMatch (Lern-Memory)
- Memory-Spiel, das **Begriff ↔ Bedeutung** paart (klassische Aufdeck-Mechanik mit Lernwert).
- Deck-Auswahl, Zug-/Zeitzähler, Sternebewertung und „Spiel abbrechen".
- **No-Scroll-Layout**: alle Karten einer Runde passen ohne Scrollen auf den Schirm (Brett skaliert die Karten).
- Kartenrückseiten tragen das leuchtende Mindflux-Emblem (grün/magenta je Theme).
- Inhalte **DB-gestützt und im Admin pflegbar** (`/admin/mindmatch`): Decks und Paare jederzeit erweiterbar.
- Mitgelieferte Decks: Netzwerk, Hardware, IT-Sicherheit, SQL-Befehle sowie ein großer Fachinformatiker-Satz (15 Decks insgesamt).
#### Spiel: TechTacular (Jeopardy-Quiz)
- **Echtes Jeopardy-Prinzip (umgekehrt):** angezeigt wird eine Aussage, gesucht ist der Begriff als „Was ist …?".
- 5×5-Board (Kategorie × Punktwert 100–500), Antwortprüfung mit Erklärung.
- **Server-autoritativ & Anti-Cheat:** das Board enthält nie die Lösung; Prüfung und Wertung passieren ausschließlich serverseitig.
- **≥ 60 % neue Fragen pro Runde:** das Board schließt die Fragen der Vorrunde aus.
- **Kuratierter Inhalt:** 12 Kategorien über das ganze Informatik-Prüfungsspektrum (Netzwerk, Betriebssysteme, Programmierung, Datenstrukturen & Algorithmen, Datenbanken/SQL, IT-Sicherheit & Krypto, Web, Rechnerarchitektur, Software-Engineering, Theoretische Informatik, Cloud/DevOps, IT-Recht), **384 Hinweise**.
- Admin-Verwaltung als Drill-down (`/admin/techtacular`).
#### Generisches Spiel-Backend
- Tabelle `game_scores` + `GameScoreService`/`GameScoreController` mit `POST /api/games/{game}/scores` und `GET /api/games/{game}/stats` für alle Spiele.
- `/progress` zeigt echte Spiel-Statistiken (Highscore, Spiele, zuletzt gespielt).
#### Optik-Relaunch / Theme-System
- **Zentrale Tokens** in `theme.css` + Umschalter `theme.js`, gesteuert über `<html data-theme="dark|light">` (Persistenz via `localStorage`, kein Aufblitzen).
- **Dark = Grün-Neon, Light = heller Grund + Magenta** (bewusst invertiert).
- Wiederverwendbare `mf-`-Komponenten (Shell/Sidebar/Topbar, HUD-Karten, Panels, Buttons, Formulare, Tabellen, Footer …).
- **Dashboard** im „Cyber-Grid"-Look neu: linke Sidebar (Nav, Profil, Level/XP, Streak), Mitte mit HUD-Spielkarten, rechte Widget-Spalte (System-Status, Fortschritt, Top-Kategorien, Challenge), Top-Leiste mit Suche/Glocke/Theme-Schalter.
- Original-Mindflux-Logo per CSS-Maske theme-abhängig eingefärbt (`/img/mindflux-emblem.png`).
- Theme + Umschalter **app-weit** ausgerollt: Login/Registrierung, beide Spiele, Profil/Fortschritt/Achievements/Einstellungen/Soziales/Knowledge, alle Admin-Seiten, FinalFlux-Hub und 28 statische Lernseiten.
- **Fußzeile** (Support · Feedback · Nutzungsbedingungen · Richtlinien · Datenschutz · Impressum) als `.mf-footer`-Komponente, einheitlich auf allen Content-Seiten.
### Geändert
- `ProgressService` liefert echte DB-Daten statt der bisherigen Dummy-Spiele.
- Admin-Bootstrap-User auf `AliceDarkest` umbenannt (vorher „Alice Darkest" mit Leerzeichen); zusätzlicher Admin `KevCipher` angelegt.
### Sicherheit
- Statische JS-Ressourcen (`/js/**`) wie CSS/Bilder öffentlich (`permitAll`) — kein schützenswerter Inhalt.
- Score-Endpunkte gegen eine Allowlist bekannter Spiele abgesichert; Punkte werden serverseitig berechnet (kein vom Client gemeldeter Score).
### Technisch
- **Flyway-Migrationen V2–V6:** `game_scores` (V2), MindMatch-Decks/-Paare (V3) + Fachinformatiker-Inhalte (V4), TechTacular-Schema (V5) + kuratierte Reverse-Inhalte (V6).
- Inhalts-Import für TechTacular über einen `CommandLineRunner`-Seeder aus `techtacular/content.json`.
- Hinweis (Thymeleaf): Inline-`<script>` mit `[[`/`]]` benötigt `th:inline="none"`, sonst bricht die Antwort ab.
### Hinweise
- Footer-Links sind aktuell Platzhalter (`href="#"`); echte Impressum-/AGB-/Datenschutz-Seiten existieren noch nicht.
- Die fünf übrigen Dashboard-Spiele (Syntax Saga, Code Escape Room, Service Clash, Bug Hunter, Netzwerker) sind noch nicht umgesetzt und verweisen auf „coming soon".
---
_Frühere Stände (Grundgerüst der Lernplattform: Benutzer/Login, Profil, Achievements, FinalFlux, Admin) wurden vor Einführung dieses Changelogs entwickelt._
+173
View File
@@ -0,0 +1,173 @@
# Mindflux – Style Guide
Verbindliche Design-Referenz für **Mindflux**. **Single Source of Truth ist
`src/main/resources/static/css/theme.css`** – diese Datei dokumentiert nur, was dort definiert
ist. Bei Änderungen immer zuerst `theme.css` anpassen und diese Datei nachziehen.
> **Grundregel:** Niemals Hex-/RGBA-Werte hart in Templates oder Spiele-CSS schreiben. **Immer**
> die Tokens (`var(--accent)`, `var(--surface)` …) verwenden, damit Dark/Light und künftige
> Palettenwechsel automatisch greifen.
Theme-Umschaltung: über das Attribut `<html data-theme="dark|light">` (gesetzt/persistiert von
`theme.js`; injiziert auch den mobilen Off-Canvas-Drawer). **Dark ist Default.**
---
## Farbkonzept – „Back to the Future"
- **Primärakzent = Feuer-Amber** (Dark `#f0a81a` / Light `#d65116`) + **Gelb-Glow** (`--accent-2`)
für Verläufe und Leuchten.
- **Zweitakzent = Flux-Blau** (`#2d9cce`) – für **Links** und das Spiel-Feedback **„richtig"**.
- **Warmer Bernstein-Glow** statt Matrix-Grün; dunkler bzw. sehr heller Grund je Theme.
- **Kein Grün** im Erfolgs-Feedback: **richtig = Blau ✓**, **falsch = Rot ✗** (Rot ist ein kühles
Crimson, damit es sich klar vom orangen Akzent abhebt – v. a. im Light-Mode).
---
## Token-Referenz (aus `theme.css`)
| Token | Dark | Light | Verwendung |
|---|---|---|---|
| `--bg` | `#0a0806` | `#fbf6ec` | Seitenhintergrund (unter `--bg-glow`) |
| `--bg-glow` | radial `rgba(240,168,26,.10)` | radial `rgba(214,81,22,.10)` | weicher Lichtschein oben |
| `--panel` | `#13100b` | `#ffffff` | Sidebar, Topbar, Footer |
| `--panel-2` | `#171209` | `#fdf7ea` | Panel-Variante |
| `--surface` | `#120f0a` | `#ffffff` | Karten / Panels / Eingaben (Grundfläche) |
| `--surface-2` | `#1b1610` | `#fbf4e6` | erhöhte Fläche, Hover, Felder |
| `--border` | `#2a2117` | `#ece1cf` | Standard-Rahmen |
| `--line` | `#221b12` | `#f1e8d8` | feine Linien, Scrollbar, Chart-Achsen |
| `--border-accent` | `rgba(240,168,26,.45)` | `rgba(214,81,22,.40)` | akzentuierte Rahmen |
| `--accent` | `#f0a81a` | `#d65116` | **Primärakzent**: Buttons, aktive Nav, Icons, Headline-Akzent |
| `--accent-2` | `#f7dc11` | `#e4801d` | Sekundär-Akzent für Verläufe (Gelb-Glow) |
| `--accent-ink` | `#1c1100` | `#ffffff` | Textfarbe **auf** Akzentflächen |
| `--secondary` | `#2d9cce` | `#1f86b8` | **Flux-Blau**: Links + „richtig" |
| `--secondary-2` | `#5cc4e8` | `#2d9cce` | helleres Blau (Hover/Variante) |
| `--text` | `#ece3d2` | `#33271c` | Fließtext |
| `--muted` | `#8c8170` | `#8a7d6c` | gedämpfter Text, Labels, Captions |
| `--heading` | `#ffffff` | `#241a12` | Überschriften |
| `--success` | `#2d9cce` | `#1f86b8` | **„richtig"** (= Flux-Blau, bewusst kein Grün) |
| `--danger` | `#ff4d6d` | `#be123c` | **„falsch"** / Fehler / Löschen (kühles Crimson) |
| `--glow-s` | `rgba(240,168,26,.55)` | `rgba(214,81,22,.40)` | **starker** Glow (Text-/Drop-Shadow auf Akzent) |
| `--glow-w` | `rgba(240,168,26,.16)` | `rgba(214,81,22,.12)` | **weicher** Ambient-Glow |
| `--g-soft` | `0 0 18px var(--glow-w)` | `0 6px 18px var(--glow-w)` | Standard-Panel-Schatten |
### Semantik kurz
- **Akzent (Amber/Orange):** alles Primäre/Interaktive. Text auf Akzent immer `--accent-ink`.
- **`--accent` + `--accent-2`** zusammen ergeben die typischen Verläufe (`linear-gradient(…,var(--accent),var(--accent-2))`).
- **Blau (`--secondary` / `--success`):** Links und Erfolg.
- **Rot (`--danger`):** Fehler, Löschen-Buttons, „falsch".
---
## Spiele-Feedback (verbindlich)
In allen Spielen (FinalFlux, MindMatch, TechTacular, Syntax Saga) gilt einheitlich:
- **richtig** → `var(--success)` (Blau) + Häkchen `✓`
- **falsch** → `var(--danger)` (Rot) + Kreuz `✗`
- aufgelöst/neutral (Syntax Saga) → `var(--secondary)`
Muster (siehe z. B. `syntaxsaga.css` / `techtacular.css`):
```css
.x-feedback.correct { color: var(--success); }
.x-feedback.correct::before { content: "\2713 "; }
.x-feedback.wrong { color: var(--danger); }
.x-feedback.wrong::before { content: "\2717 "; }
```
---
## Typografie
- **UI/Fließtext:** `"Inter","Segoe UI",system-ui,-apple-system,Roboto,sans-serif`
- **Code/Monospace:** `"JetBrains Mono","Fira Code","Consolas","Courier New",monospace`
- **Überschriften:** `font-weight:800`, Farbe `--heading`.
- **Eyebrow/Labels (`.mf-eyebrow`, Panel-`h4`):** `--accent`, `text-transform:uppercase`,
`letter-spacing:.08–.12em`, kleine Schrift.
---
## Glow, Schatten & Radien
- **Glow stark** `--glow-s`: `text-shadow`/`drop-shadow` auf leuchtenden Akzentelementen.
- **Glow weich** `--glow-w`: dezenter Schein um Panels/Buttons.
- **Panel-Schatten:** `box-shadow: var(--g-soft)`.
- **Radien (Konvention):** Badges/Buttons ~`7–9px`, Inputs/Icon-Buttons `10px`, Nav `11px`,
Panels `13–14px`, Standalone-Karten `16–18px`, Pills/Bars `99px`.
- **Spielkarten** (`.mf-card`) haben eine **abgeschrägte Ecke** via `clip-path` (`--n:18px`) statt
rundem Radius – ein bewusstes „HUD"-Detail.
---
## Layout-Shell & Responsivität
App-Seiten nutzen das Grid `.mf-shell` (definiert in `theme.css`):
```
┌── Sidebar 264px ──┬─────── Topbar (Spalte 2–3) ───────┐
│ ├──── Main 1fr ────┬─ Rightbar 332px ┤
└───────────────────┴──────────────────┴─────────────────┘
```
- `.mf-shell.no-right` → nur `264px | 1fr` (ohne rechte Spalte).
- **Breakpoints:** `≤1180px` rechte Spalte aus, 2-spaltig · `≤820px` Shell stapelt, Sidebar wird
zum **Off-Canvas-Drawer** (Hamburger + Backdrop, via `theme.js`) · `≤560px` Spielkarten-Grid 1-spaltig.
- Spielseiten (MindMatch/TechTacular/Syntax Saga) haben eigene, schlankere Shells, nutzen aber
dieselben Tokens und sind ebenfalls mobil-tauglich.
---
## Komponenten-Cheatsheet (`.mf-*` aus `theme.css`)
| Klasse | Zweck |
|---|---|
| `.mf-btn` | Primär-Button (Akzent). Varianten: `.outline`, `.ghost`, `.danger` |
| `.mf-panel` | Karte/Box; `.accent` = akzentuierter Rahmen + Glow |
| `.mf-card` / `.mf-grid` | HUD-Spielkarte (abgeschrägte Ecke) im 3er-Grid |
| `.mf-input`, `.mf-select`, `textarea.mf-input` | Formularfelder (Fokus = Akzentrahmen + Glow) |
| `.mf-field`, `.mf-label` | Formular-Zeile + Label |
| `.mf-table` | Tabellen (Kopf in Akzent, mobil horizontal scrollbar) |
| `.mf-badge` | kleines Status-Label |
| `.mf-eyebrow` | Kicker/Overline über Headlines |
| `.mf-nav` / `.mf-sidebar` | linke Navigation (aktiv = Akzent-Verlauf) |
| `.mf-bar` + `> i` | Fortschrittsbalken |
| `.mf-center` / `.mf-cardbox` | zentrierte Standalone-Karte (Login/Rechtsseiten) |
| `.mf-container`, `.mf-page-head`, `.mf-back` | einfache Inhaltsseiten + Zurück-Link |
| `.mf-footer` | globale Fußzeile |
| `.mf-theme` / `.mf-theme-float` | Dark/Light-Umschalter (Logik in `theme.js`) |
---
## Pro-Spiel-CSS
Jedes Spiel hat eine eigene CSS-Datei mit eigenem **Klassen-Prefix**, die **ausschließlich
Theme-Tokens** verwendet (keine eigenen Farbwerte):
| Spiel | Datei | Prefix |
|---|---|---|
| MindMatch | `mindmatch.css` | `mm-` |
| TechTacular | `techtacular.css` | `tt-` |
| Syntax Saga | `syntaxsaga.css` | `ss-` |
| FinalFlux | `finalflux.css` | `finalflux-` |
Neues Spiel → neue `<spiel>.css` mit eigenem Prefix anlegen, **nur** `var(--…)`-Tokens nutzen,
Feedback-Konvention (Blau/Rot) einhalten.
---
## Do / Don't
**Do**
- Farben/Schatten/Glow ausschließlich über Tokens.
- Auf Akzentflächen Text in `--accent-ink`.
- Erfolg = Blau, Fehler = Rot (nie Grün/Orange für „richtig/falsch").
- Neue Komponenten an den `.mf-*`-Bausteinen orientieren.
**Don't**
- Keine hart kodierten Hex-/RGBA-Werte in Templates oder Spiele-CSS.
- Nicht die **Legacy-Dateien** als Stil-Referenz nehmen: `achievements.css`, `progress.css`,
`settings.css`, `soziales.css` (ungenutzt) sowie alte Schrift-Deklarationen
(`'Segoe UI'`/`'Poppins'`) entsprechen **nicht** dem aktuellen System. Maßgeblich ist `theme.css`.
---
*Stand: 2026-06-30 · Quelle: `src/main/resources/static/css/theme.css` (BTTF-Palette, Dark/Light).*
+20 -3
View File
@@ -5,16 +5,33 @@ services:
restart: unless-stopped
environment:
# --- Datenbank: unverändert wie im bisherigen Deploy ---
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mindfluxdb
SPRING_DATASOURCE_USERNAME: mindflux
SPRING_DATASOURCE_PASSWORD: mindflux_pass
# --- App: lauscht intern auf 8080 (nginx -> mindflux:8080) ---
SERVER_PORT: "8080"
MINDFLUX_PUBLIC_URL: "https://mindflux.sysnaptik.de"
MINDFLUX_COOKIE_SECURE: "true"
# --- Bootstrap-Admins: Passwörter kommen aus Gitea-Secrets (siehe Workflow) ---
MINDFLUX_ADMIN_USERNAME: "AliceDarkest"
MINDFLUX_ADMIN_EMAIL: "AliceDarkest@gmail.com"
MINDFLUX_ADMIN_PASSWORD: "${MINDFLUX_ADMIN_PASSWORD}"
MINDFLUX_ADMIN2_USERNAME: "KevCipher"
MINDFLUX_ADMIN2_EMAIL: "kevcipher@proton.me"
MINDFLUX_ADMIN2_PASSWORD: "${MINDFLUX_ADMIN2_PASSWORD}"
volumes:
- mindflux_uploads:/app/uploads
networks:
- backend_network
ports:
- "8081:8080"
networks:
backend_network:
external: true
volumes:
mindflux_uploads:
-17
View File
@@ -1,17 +0,0 @@
hello ci pipeline
versuch 2
Versuch 3
Versuch 4
Versuch 5
Versuch 6
Versuch 7
Versuch 8
Versuch 9
Versuch 10
Versuch 11
Versuch 12
Versuch 13
Versuch 14 Claude Fix
Versuch 15 Maven Fix
Versuch 16 Maven Fix pushed
rollback
+107 -54
View File
@@ -1,59 +1,112 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<groupId>com.mindflux</groupId>
<artifactId>mindflux</artifactId>
<version>1.0.0</version>
<modelVersion>4.0.0</modelVersion>
<parent>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<relativePath/>
</parent>
<groupId>com.mindflux</groupId>
<artifactId>mindflux</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mindflux</name>
<description>Mindflux – Lernplattform zur Prüfungsvorbereitung</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Thymeleaf für Templates -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Thymeleaf + Spring Security Integration -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<!-- JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Security komplett -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Passwort-Verschlüsselung (BCrypt) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<!-- PostgreSQL Datenbanktreiber -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Datenbank-Migrationen -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- Hot Reload für Entwicklung -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Unit Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- JPA optional aber sinnvoll -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package com.example.mindflux;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class AppStartCheck implements CommandLineRunner {
@Override
public void run(String... args) {
System.out.println("✅ App läuft. Sicherheitsmodul sollte aktiv sein.");
}
}
@@ -1,10 +1,11 @@
package com.mindflux;
package com.example.mindflux;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MindfluxApplication {
public static void main(String[] args) {
SpringApplication.run(MindfluxApplication.class, args);
}
@@ -0,0 +1,60 @@
package com.example.mindflux.config;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class AdminSetup {
@Value("${MINDFLUX_ADMIN_USERNAME:admin}")
private String adminUsername;
@Value("${MINDFLUX_ADMIN_EMAIL:admin@mindflux.de}")
private String adminEmail;
@Value("${MINDFLUX_ADMIN_PASSWORD:}")
private String adminPassword;
// Optionaler zweiter Bootstrap-Admin. Wird nur angelegt, wenn Username + Passwort gesetzt sind.
@Value("${MINDFLUX_ADMIN2_USERNAME:}")
private String admin2Username;
@Value("${MINDFLUX_ADMIN2_EMAIL:}")
private String admin2Email;
@Value("${MINDFLUX_ADMIN2_PASSWORD:}")
private String admin2Password;
@Bean
public CommandLineRunner createAdmin(UserRepository userRepo, BCryptPasswordEncoder passwordEncoder) {
return args -> {
createAdminIfAbsent(userRepo, passwordEncoder, adminUsername, adminEmail, adminPassword);
createAdminIfAbsent(userRepo, passwordEncoder, admin2Username, admin2Email, admin2Password);
};
}
private void createAdminIfAbsent(UserRepository userRepo, BCryptPasswordEncoder passwordEncoder,
String username, String email, String password) {
if (username == null || username.isBlank()) {
return; // Admin-Slot nicht konfiguriert -> still überspringen.
}
if (password == null || password.isBlank()) {
System.out.println("Kein Bootstrap-Admin erstellt: Passwort für '" + username + "' ist nicht gesetzt.");
return;
}
if (userRepo.findByUsername(username).isEmpty()) {
User admin = new User();
admin.setUsername(username);
admin.setEmail(email);
admin.setPassword(passwordEncoder.encode(password));
admin.setRole("ADMIN");
userRepo.save(admin);
System.out.println("Bootstrap-Admin erstellt: " + username);
}
}
}
@@ -0,0 +1,72 @@
package com.example.mindflux.config;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") // NEU: Adminbereich absichern
.requestMatchers("/api/users/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/") // login.html liegt auf "/"
.loginProcessingUrl("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/?error") // zeigt Loginfehler an
.permitAll()
)
.logout(logout -> logout
// Logout-Links in den Templates sind GET -> GET-Logout erlauben, sonst 404/Fehler unter CSRF
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
.logoutSuccessUrl("/?logout")
.permitAll()
)
.csrf(csrf -> csrf
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**")
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo) {
return username -> {
User user = userRepo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Benutzer nicht gefunden"));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole()) // Achtung: Nur ein String, z.B. "USER" oder "ADMIN"
.build();
};
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
@@ -0,0 +1,152 @@
package com.example.mindflux.config;
import com.example.mindflux.model.SyntaxSagaGap;
import com.example.mindflux.model.SyntaxSagaMission;
import com.example.mindflux.model.SyntaxSagaSolution;
import com.example.mindflux.repository.SyntaxSagaMissionRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.Set;
/**
* Befüllt Syntax Saga einmalig (wenn leer) aus der kuratierten Inhaltsdatei
* syntaxsaga/content.json. Format: Missionen mit Story, Codetemplate (Lücken als
* {@code {{n}}}) und je Lücke einer Liste gültiger Lösungen.
*/
@Component
public class SyntaxSagaSeeder implements CommandLineRunner {
private static final Set<String> DIFFICULTIES = Set.of("junior", "professional", "senior");
private final SyntaxSagaMissionRepository missionRepository;
private final ObjectMapper objectMapper;
public SyntaxSagaSeeder(SyntaxSagaMissionRepository missionRepository, ObjectMapper objectMapper) {
this.missionRepository = missionRepository;
this.objectMapper = objectMapper;
}
@Override
@Transactional
public void run(String... args) {
if (missionRepository.count() > 0) {
return; // schon befüllt
}
try {
JsonNode root = objectMapper.readTree(
new ClassPathResource("syntaxsaga/content.json").getInputStream());
JsonNode missions = root.get("missions");
if (missions == null || !missions.isArray()) {
System.out.println("⚠️ Syntax-Saga-Seed: content.json ohne 'missions'.");
return;
}
Set<String> usedSlugs = new HashSet<>();
int order = 1;
int missionCount = 0;
int gapCount = 0;
for (JsonNode mNode : missions) {
String title = mNode.path("title").asText("");
String code = mNode.path("code").asText("");
JsonNode gaps = mNode.get("gaps");
if (title.isBlank() || code.isBlank() || gaps == null || !gaps.isArray()) {
continue;
}
SyntaxSagaMission mission = new SyntaxSagaMission(
uniqueSlug(title, usedSlugs),
trim(title, 200),
normalizeDifficulty(mNode.path("difficulty").asText("junior")),
trim(mNode.path("narrative").asText(""), 2000),
trim(textOrNull(mNode, "dialog"), 1000),
trim(textOrNull(mNode, "language"), 30),
trim(code, 2000),
trim(textOrNull(mNode, "successText"), 2000),
order, true);
int pos = 1;
for (JsonNode gNode : gaps) {
JsonNode solutions = gNode.get("solutions");
if (solutions == null || !solutions.isArray() || solutions.isEmpty()) {
continue;
}
SyntaxSagaGap gap = new SyntaxSagaGap(
gNode.path("position").asInt(pos),
gNode.path("points").asInt(100),
gNode.path("caseSensitive").asBoolean(true),
trim(textOrNull(gNode, "hint"), 500),
trim(textOrNull(gNode, "placeholder"), 120));
int sort = 0;
for (JsonNode s : solutions) {
String st = s.asText("");
if (!st.isBlank()) {
gap.addSolution(new SyntaxSagaSolution(trim(st, 500), sort++));
}
}
if (!gap.getSolutions().isEmpty()) {
mission.addGap(gap);
gapCount++;
}
pos++;
}
if (!mission.getGaps().isEmpty()) {
missionRepository.save(mission);
order++;
missionCount++;
}
}
System.out.println("✅ Syntax Saga: " + missionCount + " Missionen / " + gapCount + " Lücken geladen.");
} catch (Exception e) {
System.out.println("⚠️ Syntax-Saga-Seed übersprungen: " + e.getMessage());
}
}
private String normalizeDifficulty(String difficulty) {
if (difficulty == null) {
return "junior";
}
String x = difficulty.trim().toLowerCase();
return DIFFICULTIES.contains(x) ? x : "junior";
}
private String textOrNull(JsonNode node, String field) {
if (!node.hasNonNull(field)) {
return null;
}
String value = node.get(field).asText("");
return value.isBlank() ? null : value;
}
private String uniqueSlug(String title, Set<String> used) {
String base = title.toLowerCase()
.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (base.isBlank()) {
base = "mission";
}
String slug = base;
int counter = 2;
while (used.contains(slug) || missionRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
used.add(slug);
return slug;
}
private String trim(String value, int max) {
if (value == null) {
return null;
}
return value.length() <= max ? value : value.substring(0, max);
}
}
@@ -0,0 +1,127 @@
package com.example.mindflux.config;
import com.example.mindflux.model.TechtacularAnswer;
import com.example.mindflux.model.TechtacularCategory;
import com.example.mindflux.model.TechtacularQuestion;
import com.example.mindflux.repository.TechtacularCategoryRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.Set;
/**
* Befüllt TechTacular einmalig (wenn leer) aus der kuratierten Inhaltsdatei
* techtacular/content.json. Format: Jeopardy umgekehrt – pro Hinweis eine Aussage
* ("clue") und der gesuchte Begriff ("solution", = richtige Antwort) plus Distraktoren.
*/
@Component
public class TechtacularSeeder implements CommandLineRunner {
private final TechtacularCategoryRepository categoryRepository;
private final ObjectMapper objectMapper;
public TechtacularSeeder(TechtacularCategoryRepository categoryRepository, ObjectMapper objectMapper) {
this.categoryRepository = categoryRepository;
this.objectMapper = objectMapper;
}
@Override
@Transactional
public void run(String... args) {
if (categoryRepository.count() > 0) {
return; // schon befüllt
}
try {
JsonNode root = objectMapper.readTree(new ClassPathResource("techtacular/content.json").getInputStream());
JsonNode categories = root.get("categories");
if (categories == null || !categories.isArray()) {
System.out.println("⚠️ TechTacular-Seed: content.json ohne 'categories'.");
return;
}
Set<String> usedSlugs = new HashSet<>();
int order = 1;
int catCount = 0;
int clueCount = 0;
for (JsonNode catNode : categories) {
String title = catNode.path("title").asText("");
JsonNode clues = catNode.get("clues");
if (title.isBlank() || clues == null || !clues.isArray()) {
continue;
}
TechtacularCategory category =
new TechtacularCategory(uniqueSlug(title, usedSlugs), trim(title, 200), order, true);
for (JsonNode clue : clues) {
String clueText = clue.path("clue").asText("");
String solution = clue.path("solution").asText("");
JsonNode distractors = clue.get("distractors");
if (clueText.isBlank() || solution.isBlank() || distractors == null || !distractors.isArray()) {
continue;
}
int points = clue.path("points").asInt(100);
String explanation = clue.hasNonNull("explanation") ? clue.get("explanation").asText() : null;
TechtacularQuestion question = new TechtacularQuestion(
trim(clueText, 1000),
(explanation == null || explanation.isBlank()) ? null : trim(explanation, 1000),
points);
int sort = 0;
question.addAnswer(new TechtacularAnswer(trim(solution, 500), true, sort++)); // richtige Antwort
for (JsonNode d : distractors) {
String dt = d.asText("");
if (!dt.isBlank()) {
question.addAnswer(new TechtacularAnswer(trim(dt, 500), false, sort++));
}
}
if (question.getAnswers().size() >= 2) {
category.addQuestion(question);
clueCount++;
}
}
if (!category.getQuestions().isEmpty()) {
categoryRepository.save(category);
order++;
catCount++;
}
}
System.out.println("✅ TechTacular: " + catCount + " Kategorien / " + clueCount + " Hinweise geladen.");
} catch (Exception e) {
System.out.println("⚠️ TechTacular-Seed übersprungen: " + e.getMessage());
}
}
private String uniqueSlug(String title, Set<String> used) {
String base = title.toLowerCase()
.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (base.isBlank()) {
base = "kategorie";
}
String slug = base;
int counter = 2;
while (used.contains(slug) || categoryRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
used.add(slug);
return slug;
}
private String trim(String value, int max) {
if (value == null) {
return null;
}
return value.length() <= max ? value : value.substring(0, max);
}
}
@@ -0,0 +1,16 @@
package com.example.mindflux.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry
.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + System.getProperty("user.dir") + "/uploads/");
}
}
@@ -0,0 +1,38 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.Achievement;
import com.example.mindflux.service.AchievementService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
import java.util.List;
@Controller
public class AchievementController {
@Autowired
private AchievementService achievementService;
@GetMapping("/profile/achievements")
public String showAchievements(Model model, Principal principal) {
String username = principal.getName();
List<Achievement> achievements = achievementService.getAchievementsForUser(username);
// 🔥 Dummy-Daten nur für Tests!
if (achievements == null || achievements.isEmpty()) {
achievements = List.of(
new Achievement("Hangman Hero", "Gewinne 10 Runden Hangman", "hangman.png", true),
new Achievement("Memory Meister", "Erreiche 1000 Punkte im Memory", "memory.png", false),
new Achievement("Quizkönig", "Beantworte 50 Fragen richtig", "quiz.png", true),
new Achievement("Early Bird", "Lerne vor 7 Uhr morgens", "earlybird.png", false)
);
}
model.addAttribute("achievements", achievements);
return "achievements"; // lädt achievements.html
}
}
@@ -0,0 +1,59 @@
package com.example.mindflux.controller;
import com.example.mindflux.service.MindMatchService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
/**
* Admin-Verwaltung der MindMatch-Decks und -Paare. Liegt unter /admin/** und ist
* damit per SecurityConfig auf ROLE_ADMIN beschränkt.
*/
@Controller
@RequestMapping("/admin/mindmatch")
public class AdminMindMatchController {
private final MindMatchService mindMatchService;
public AdminMindMatchController(MindMatchService mindMatchService) {
this.mindMatchService = mindMatchService;
}
@GetMapping("")
public String manage(Model model) {
model.addAttribute("decks", mindMatchService.allDecksWithPairs());
return "admin/mindmatch";
}
@PostMapping("/decks")
public String createDeck(@RequestParam String title) {
mindMatchService.createDeck(title);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/pairs")
public String addPair(@PathVariable Long deckId,
@RequestParam String term,
@RequestParam String match) {
mindMatchService.addPair(deckId, term, match);
return "redirect:/admin/mindmatch";
}
@PostMapping("/pairs/{pairId}/delete")
public String deletePair(@PathVariable Long pairId) {
mindMatchService.deletePair(pairId);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/delete")
public String deleteDeck(@PathVariable Long deckId) {
mindMatchService.deleteDeck(deckId);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/toggle")
public String toggleDeck(@PathVariable Long deckId) {
mindMatchService.toggleDeck(deckId);
return "redirect:/admin/mindmatch";
}
}
@@ -0,0 +1,102 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.SyntaxSagaMission;
import com.example.mindflux.service.SyntaxSagaService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
/** Admin-Verwaltung der Syntax-Saga-Missionen, -Lücken und -Lösungen (ROLE_ADMIN via /admin/**). */
@Controller
@RequestMapping("/admin/syntaxsaga")
public class AdminSyntaxSagaController {
private final SyntaxSagaService syntaxSagaService;
public AdminSyntaxSagaController(SyntaxSagaService syntaxSagaService) {
this.syntaxSagaService = syntaxSagaService;
}
/** Übersicht aller Missionen. */
@GetMapping("")
public String manage(Model model) {
model.addAttribute("missions", syntaxSagaService.missionRows());
return "admin/syntaxsaga";
}
/** Detailseite einer Mission mit ihren Lücken. */
@GetMapping("/missions/{missionId}")
public String mission(@PathVariable Long missionId, Model model) {
SyntaxSagaMission mission = syntaxSagaService.missionWithGaps(missionId);
if (mission == null) {
return "redirect:/admin/syntaxsaga";
}
model.addAttribute("mission", mission);
return "admin/syntaxsaga-mission";
}
@PostMapping("/missions")
public String createMission(@RequestParam String title,
@RequestParam(required = false) String difficulty,
@RequestParam(required = false) String narrative,
@RequestParam(required = false) String dialog,
@RequestParam(required = false) String language,
@RequestParam String codeTemplate,
@RequestParam(required = false) String successText) {
Long id = syntaxSagaService.createMission(title, difficulty, narrative, dialog,
language, codeTemplate, successText);
return id == null ? "redirect:/admin/syntaxsaga"
: "redirect:/admin/syntaxsaga/missions/" + id;
}
@PostMapping("/missions/{missionId}/update")
public String updateMission(@PathVariable Long missionId,
@RequestParam String title,
@RequestParam(required = false) String difficulty,
@RequestParam(required = false) String narrative,
@RequestParam(required = false) String dialog,
@RequestParam(required = false) String language,
@RequestParam String codeTemplate,
@RequestParam(required = false) String successText) {
syntaxSagaService.updateMission(missionId, title, difficulty, narrative, dialog,
language, codeTemplate, successText);
return "redirect:/admin/syntaxsaga/missions/" + missionId;
}
@PostMapping("/missions/{missionId}/gaps")
public String addGap(@PathVariable Long missionId,
@RequestParam(defaultValue = "0") int position,
@RequestParam(defaultValue = "100") int points,
@RequestParam(defaultValue = "false") boolean caseSensitive,
@RequestParam(required = false) String hint,
@RequestParam(required = false) String placeholder,
@RequestParam(required = false, defaultValue = "") String solutions) {
List<String> parsed = Arrays.stream(solutions.split("\\r?\\n"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
syntaxSagaService.addGap(missionId, position, points, caseSensitive, hint, placeholder, parsed);
return "redirect:/admin/syntaxsaga/missions/" + missionId;
}
@PostMapping("/gaps/{gapId}/delete")
public String deleteGap(@PathVariable Long gapId, @RequestParam Long missionId) {
syntaxSagaService.deleteGap(gapId);
return "redirect:/admin/syntaxsaga/missions/" + missionId;
}
@PostMapping("/missions/{missionId}/delete")
public String deleteMission(@PathVariable Long missionId) {
syntaxSagaService.deleteMission(missionId);
return "redirect:/admin/syntaxsaga";
}
@PostMapping("/missions/{missionId}/toggle")
public String toggleMission(@PathVariable Long missionId) {
syntaxSagaService.toggleMission(missionId);
return "redirect:/admin/syntaxsaga";
}
}
@@ -0,0 +1,79 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.TechtacularCategory;
import com.example.mindflux.service.TechtacularService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
/** Admin-Verwaltung der TechTacular-Kategorien, -Fragen und -Antworten (ROLE_ADMIN via /admin/**). */
@Controller
@RequestMapping("/admin/techtacular")
public class AdminTechtacularController {
private final TechtacularService techtacularService;
public AdminTechtacularController(TechtacularService techtacularService) {
this.techtacularService = techtacularService;
}
/** Übersicht aller Kategorien. */
@GetMapping("")
public String manage(Model model) {
model.addAttribute("categories", techtacularService.categoryRows());
return "admin/techtacular";
}
/** Detailseite einer Kategorie mit ihren Fragen. */
@GetMapping("/categories/{categoryId}")
public String category(@PathVariable Long categoryId, Model model) {
TechtacularCategory category = techtacularService.categoryWithQuestions(categoryId);
if (category == null) {
return "redirect:/admin/techtacular";
}
model.addAttribute("category", category);
return "admin/techtacular-category";
}
@PostMapping("/categories")
public String createCategory(@RequestParam String title) {
techtacularService.createCategory(title);
return "redirect:/admin/techtacular";
}
@PostMapping("/categories/{categoryId}/questions")
public String addQuestion(@PathVariable Long categoryId,
@RequestParam String text,
@RequestParam(required = false) String explanation,
@RequestParam(defaultValue = "100") int points,
@RequestParam(required = false, defaultValue = "") String answer1,
@RequestParam(required = false, defaultValue = "") String answer2,
@RequestParam(required = false, defaultValue = "") String answer3,
@RequestParam(required = false, defaultValue = "") String answer4,
@RequestParam(defaultValue = "0") int correct) {
List<String> answers = Arrays.asList(answer1, answer2, answer3, answer4);
techtacularService.addQuestion(categoryId, text, explanation, points, answers, correct);
return "redirect:/admin/techtacular/categories/" + categoryId;
}
@PostMapping("/questions/{questionId}/delete")
public String deleteQuestion(@PathVariable Long questionId, @RequestParam Long categoryId) {
techtacularService.deleteQuestion(questionId);
return "redirect:/admin/techtacular/categories/" + categoryId;
}
@PostMapping("/categories/{categoryId}/delete")
public String deleteCategory(@PathVariable Long categoryId) {
techtacularService.deleteCategory(categoryId);
return "redirect:/admin/techtacular";
}
@PostMapping("/categories/{categoryId}/toggle")
public String toggleCategory(@PathVariable Long categoryId) {
techtacularService.toggleCategory(categoryId);
return "redirect:/admin/techtacular";
}
}
@@ -0,0 +1,66 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.AdminUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/admin")
public class AdminUserController {
@Autowired
private AdminUserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
// ⬅️ Neu: Dashboard-Startseite
@GetMapping("")
public String adminDashboard() {
return "admin/dashboard"; // du brauchst: templates/admin/dashboard.html
}
// ⬅️ Benutzerverwaltung
@GetMapping("/users")
public String listUsers(Model model) {
model.addAttribute("users", userService.findAll());
return "admin/users/list";
}
@GetMapping("/users/new")
public String createUserForm(Model model) {
model.addAttribute("user", new User());
return "admin/users/form";
}
@PostMapping("/users/save")
public String saveUser(@ModelAttribute("user") User user) {
if (user.getId() != null && (user.getPassword() == null || user.getPassword().isBlank())) {
User existingUser = userService.findById(user.getId())
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + user.getId()));
user.setPassword(existingUser.getPassword());
} else {
user.setPassword(passwordEncoder.encode(user.getPassword()));
}
userService.save(user);
return "redirect:/admin/users";
}
@GetMapping("/users/edit/{id}")
public String editUserForm(@PathVariable Long id, Model model) {
User user = userService.findById(id).orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
model.addAttribute("user", user);
return "admin/users/form";
}
@PostMapping("/users/delete/{id}")
public String deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return "redirect:/admin/users";
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DashboardController {
@GetMapping("/dashboard")
public String showDashboard() {
return "dashboard"; // lädt dashboard.html aus templates
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DummyController {
@GetMapping("/comingsoon")
public String comingSoon() {
return "dummy"; // verweist auf dummy.html in src/main/resources/templates
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class FinalfluxController {
@GetMapping("/finalflux")
public String showFinalfluxOverview() {
return "finalflux"; // Lädt templates/finalflux.html
}
}
@@ -0,0 +1,50 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.GameStats;
import com.example.mindflux.dto.ScoreResult;
import com.example.mindflux.dto.ScoreSubmission;
import com.example.mindflux.service.GameScoreService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.Set;
/**
* Generisches Score-API für alle Mindflux-Spiele. Der Spielname kommt aus der URL
* und wird gegen eine Allowlist geprüft, damit keine beliebigen Spiele-Kennungen
* in die Datenbank wandern.
*/
@RestController
@RequestMapping("/api/games")
public class GameScoreController {
/** Bekannte Spiele (entsprechen den Dashboard-Routen). */
private static final Set<String> KNOWN_GAMES = Set.of(
"mindmatch", "techtacular", "syntax-saga", "escape-room",
"service-clash", "bug-hunter", "netzwerker");
private final GameScoreService gameScoreService;
public GameScoreController(GameScoreService gameScoreService) {
this.gameScoreService = gameScoreService;
}
@PostMapping("/{game}/scores")
public ResponseEntity<ScoreResult> submitScore(@PathVariable String game,
@RequestBody ScoreSubmission submission,
Principal principal) {
if (!KNOWN_GAMES.contains(game)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(gameScoreService.record(principal.getName(), game, submission));
}
@GetMapping("/{game}/stats")
public ResponseEntity<GameStats> getStats(@PathVariable String game, Principal principal) {
if (!KNOWN_GAMES.contains(game)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(gameScoreService.getStats(principal.getName(), game));
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ImpressumController {
@GetMapping("/impressum")
public String impressum() {
return "impressum"; // verweist auf impressum.html in src/main/resources/templates
}
}
@@ -0,0 +1,27 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* Liefert die statischen Rechts-/Informationsseiten aus dem Footer:
* AGB / Nutzungsbedingungen, Datenschutzerklärung und Community-Richtlinien.
*/
@Controller
public class LegalController {
@GetMapping("/nutzungsbedingungen")
public String nutzungsbedingungen() {
return "legal/nutzungsbedingungen";
}
@GetMapping("/datenschutz")
public String datenschutz() {
return "legal/datenschutz";
}
@GetMapping("/richtlinien")
public String richtlinien() {
return "legal/richtlinien";
}
}
@@ -0,0 +1,26 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.MindMatchDeckView;
import com.example.mindflux.service.MindMatchService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** Liefert die aktiven MindMatch-Decks (inkl. Paare) an die Spielseite. */
@RestController
@RequestMapping("/api/mindmatch")
public class MindMatchApiController {
private final MindMatchService mindMatchService;
public MindMatchApiController(MindMatchService mindMatchService) {
this.mindMatchService = mindMatchService;
}
@GetMapping("/decks")
public List<MindMatchDeckView> decks() {
return mindMatchService.activeDecks();
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MindMatchController {
@GetMapping("/mindmatch")
public String showMindMatch() {
return "mindmatch"; // lädt templates/mindmatch.html
}
}
@@ -0,0 +1,209 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.Principal;
import java.util.UUID;
@Controller
public class ProfileController {
@Autowired
private UserService userService;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads/profiles/";
@GetMapping("/profile")
public String profile(Model model, Principal principal, @RequestParam(value = "success", required = false) Boolean success) {
String username = principal.getName();
User user = userService.findByUsername(username);
model.addAttribute("user", user);
if (Boolean.TRUE.equals(success)) {
model.addAttribute("message", "Profil erfolgreich aktualisiert!");
}
return "profile";
}
@PostMapping("/profile/delete-picture")
public String deleteProfilePicture(Principal principal) {
String username = principal.getName();
User existingUser = userService.findByUsername(username);
if (existingUser != null) {
String profilePicture = existingUser.getProfilePicture();
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
System.out.println("Versuche zu löschen: " + picturePath.toAbsolutePath());
if (Files.exists(picturePath)) {
Files.delete(picturePath);
System.out.println("Datei erfolgreich gelöscht: " + picturePath.getFileName());
} else {
System.out.println("Datei existiert nicht: " + picturePath.toAbsolutePath());
}
} catch (IOException e) {
System.err.println("Fehler beim Löschen der Datei: " + e.getMessage());
}
} else {
System.out.println("Kein Profilbild gesetzt, nichts zu löschen.");
}
existingUser.setProfilePicture(null); // Wichtig: auch wenn Datei nicht existiert
userService.save(existingUser);
}
return "redirect:/profile?success=true";
}
@GetMapping("/profile/knowledge")
public String knowledgeCenter(Model model, Principal principal) {
String username = principal.getName();
User user = userService.findByUsername(username);
model.addAttribute("user", user);
return "knowledge";
}
@PostMapping("/profile/update")
public String updateProfile(@RequestParam("email") String email,
@RequestParam(value = "profilePicture", required = false) MultipartFile file,
Principal principal) {
String username = principal.getName();
User existingUser = userService.findByUsername(username);
if (existingUser != null) {
existingUser.setEmail(email); // Email aktualisieren
if (file != null && !file.isEmpty()) {
try {
BufferedImage original = ImageIO.read(file.getInputStream());
if (original == null) {
// Keine lesbare Bilddatei -> Upload ignorieren, restliches Profil trotzdem speichern.
System.out.println("Profilbild-Upload ignoriert: keine lesbare Bilddatei.");
} else {
// Auf max. Kantenlänge herunterskalieren (kein Hochskalieren).
final int MAX_DIM = 400;
int w = original.getWidth();
int h = original.getHeight();
double scale = Math.min(1.0, (double) MAX_DIM / Math.max(w, h));
int nw = Math.max(1, (int) Math.round(w * scale));
int nh = Math.max(1, (int) Math.round(h * scale));
BufferedImage scaled = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setColor(Color.WHITE); // JPEG kennt kein Alpha -> Transparenz auf weißem Grund
g.fillRect(0, 0, nw, nh);
g.drawImage(original, 0, 0, nw, nh, null);
g.dispose();
// Altes Profilbild löschen (falls vorhanden)
if (existingUser.getProfilePicture() != null && !existingUser.getProfilePicture().isEmpty()) {
Path oldFilePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", existingUser.getProfilePicture());
Files.deleteIfExists(oldFilePath);
}
// Normalisiert als JPEG speichern
String fileName = UUID.randomUUID() + ".jpg";
Path uploadPath = Paths.get(UPLOAD_DIR);
Files.createDirectories(uploadPath);
ImageIO.write(scaled, "jpg", uploadPath.resolve(fileName).toFile());
existingUser.setProfilePicture(fileName);
}
} catch (IOException e) {
e.printStackTrace();
}
}
userService.save(existingUser);
}
return "redirect:/profile?success=true";
}
@PostMapping("/profile/change-password")
public String changePassword(@RequestParam("oldPassword") String oldPassword,
@RequestParam("newPassword") String newPassword,
Principal principal,
RedirectAttributes redirectAttributes) {
String username = principal.getName();
User user = userService.findByUsername(username);
if (user == null) {
redirectAttributes.addFlashAttribute("error", "Benutzer nicht gefunden.");
return "redirect:/profile/settings";
}
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
redirectAttributes.addFlashAttribute("error", "Das aktuelle Passwort ist falsch.");
return "redirect:/profile/settings";
}
user.setPassword(passwordEncoder.encode(newPassword));
userService.save(user);
redirectAttributes.addFlashAttribute("message", "Passwort erfolgreich geändert.");
return "redirect:/profile/settings";
}
@PostMapping("/profile/delete")
public String deleteProfile(Principal principal, Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
String username = principal.getName();
// Profilbild-Datei von der Platte entfernen (DB-Daten löscht der Service in einer Transaktion).
User user = userService.findByUsername(username);
if (user != null) {
String profilePicture = user.getProfilePicture();
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
Files.deleteIfExists(picturePath);
} catch (IOException e) {
System.err.println("Fehler beim Löschen des Profilbilds: " + e.getMessage());
}
}
}
userService.deleteByUsername(username);
new SecurityContextLogoutHandler().logout(request, response, authentication);
return "redirect:/?logout";
}
// Hilfsmethode: Dateiendung extrahieren
private String getFileExtension(String filename) {
if (filename == null || filename.lastIndexOf(".") == -1) {
return "";
}
return filename.substring(filename.lastIndexOf("."));
}
}
@@ -0,0 +1,47 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.GameProgress;
import com.example.mindflux.dto.ProgressData;
import com.example.mindflux.service.ProgressService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
import java.util.List;
@Controller
public class ProgressController {
@Autowired
private ProgressService progressService;
@GetMapping("/progress") // <-- hier geändert!
public String showProgress(Model model, Principal principal) {
String username = principal.getName(); // aktueller eingeloggter Benutzer
ProgressData progress = progressService.getOverallProgress(username);
List<GameProgress> games = progressService.getGameProgress(username);
if (progress == null) {
// Falls keine Fortschrittsdaten vorhanden sind, Dummy-Daten setzen
model.addAttribute("correctAnswers", 0);
model.addAttribute("currentXp", 0);
model.addAttribute("xpForNextLevel", 1000);
model.addAttribute("userLevel", 1);
model.addAttribute("xpPercentage", 0);
} else {
model.addAttribute("correctAnswers", progress.getCorrectAnswers());
model.addAttribute("currentXp", progress.getCurrentXp());
model.addAttribute("xpForNextLevel", progress.getXpForNextLevel());
model.addAttribute("userLevel", progress.getUserLevel());
model.addAttribute("xpPercentage", progress.getProgressPercent());
}
model.addAttribute("gameProgress", games != null ? games : List.of()); // leere Liste, falls null
return "progress"; // ruft progress.html auf
}
}
@@ -0,0 +1,44 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
public class RegisterController {
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
@GetMapping("/register")
public String showRegistrationForm(Model model) {
model.addAttribute("user", new User());
return "register";
}
@PostMapping("/register")
public String processRegistration(@ModelAttribute("user") User user, Model model) {
// Benutzername klein schreiben, um Doppelungen zu vermeiden
user.setUsername(user.getUsername().toLowerCase());
// Prüfen, ob Benutzername schon existiert
if (userService.findByUsername(user.getUsername()) != null) {
// Zurück zur kombinierten Login/Signup-Seite mit geöffnetem Signup-Panel
return "redirect:/?registerError";
}
// Passwort verschlüsseln
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setRole("USER"); // Standardrolle
userService.save(user);
return "redirect:/?registerSuccess"; // zurück zum Login mit Erfolgsmeldung
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SettingsController {
@GetMapping("/profile/settings")
public String showSettingsPage() {
return "settings"; // lädt templates/settings.html
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SozialesController {
@GetMapping("/soziales")
public String soziales() {
return "soziales"; // -> templates/soziales.html
}
}
@@ -0,0 +1,41 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.SyntaxSagaCheckRequest;
import com.example.mindflux.dto.SyntaxSagaCheckResult;
import com.example.mindflux.dto.SyntaxSagaFinishRequest;
import com.example.mindflux.dto.SyntaxSagaResult;
import com.example.mindflux.dto.SyntaxSagaStoryView;
import com.example.mindflux.service.SyntaxSagaService;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
/** JSON-API für Syntax Saga. Server-autoritativ: Lösungen werden nie ausgeliefert. */
@RestController
@RequestMapping("/api/syntaxsaga")
public class SyntaxSagaApiController {
private final SyntaxSagaService syntaxSagaService;
public SyntaxSagaApiController(SyntaxSagaService syntaxSagaService) {
this.syntaxSagaService = syntaxSagaService;
}
/** Missionen einer Stufe (oder stufenübergreifend) – ohne Lösungen. */
@GetMapping("/story")
public SyntaxSagaStoryView story(@RequestParam(required = false) String difficulty) {
return syntaxSagaService.buildStory(difficulty);
}
/** Eine Mission serverseitig prüfen; mit {@code reveal=true} auch die Auflösung. */
@PostMapping("/check")
public SyntaxSagaCheckResult check(@RequestBody SyntaxSagaCheckRequest request) {
return syntaxSagaService.check(request);
}
/** Durchlauf abschließen: Score wird serverseitig neu berechnet und gespeichert. */
@PostMapping("/finish")
public SyntaxSagaResult finish(@RequestBody SyntaxSagaFinishRequest request, Principal principal) {
return syntaxSagaService.finish(principal.getName(), request);
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SyntaxSagaController {
@GetMapping("/syntaxsaga")
public String show() {
return "syntaxsaga"; // Lädt templates/syntaxsaga.html
}
}
@@ -0,0 +1,51 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.*;
import com.example.mindflux.service.TechtacularService;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.HashSet;
import java.util.Set;
/** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */
@RestController
@RequestMapping("/api/techtacular")
public class TechtacularApiController {
private final TechtacularService techtacularService;
public TechtacularApiController(TechtacularService techtacularService) {
this.techtacularService = techtacularService;
}
/** exclude = komma-separierte Frage-IDs der Vorrunde, die möglichst nicht erneut kommen sollen. */
@GetMapping("/board")
public TechtacularBoardView board(@RequestParam(required = false) String exclude) {
return techtacularService.buildBoard(parseIds(exclude));
}
private Set<Long> parseIds(String csv) {
Set<Long> ids = new HashSet<>();
if (csv != null && !csv.isBlank()) {
for (String part : csv.split(",")) {
try {
ids.add(Long.parseLong(part.trim()));
} catch (NumberFormatException ignored) {
// ungültige IDs einfach überspringen
}
}
}
return ids;
}
@PostMapping("/answer")
public AnswerResult answer(@RequestBody AnswerRequest request) {
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId());
}
@PostMapping("/finish")
public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal) {
return techtacularService.finish(principal.getName(), request);
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class TechtacularController {
@GetMapping("/techtacular")
public String showTechtacular() {
return "techtacular"; // lädt templates/techtacular.html
}
}
@@ -0,0 +1,67 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
// 📥 User erstellen
@PostMapping
public User createUser(@RequestBody User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
}
// 📋 Alle User anzeigen
@GetMapping
public List<User> getAllUsers() {
return userRepository.findAll();
}
// 📍 Einzelnen User anzeigen
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
Optional<User> user = userRepository.findById(id);
return user.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
// ✏️ User aktualisieren
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User updatedUser) {
return userRepository.findById(id).map(user -> {
user.setUsername(updatedUser.getUsername());
if (updatedUser.getPassword() != null && !updatedUser.getPassword().isBlank()) {
user.setPassword(passwordEncoder.encode(updatedUser.getPassword()));
}
user.setEmail(updatedUser.getEmail());
user.setRole(updatedUser.getRole());
return ResponseEntity.ok(userRepository.save(user));
}).orElseGet(() -> ResponseEntity.notFound().build());
}
// 🗑️ User löschen
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userRepository.existsById(id)) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
@@ -0,0 +1,31 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/users")
public class UserViewController {
@Autowired
private UserRepository userRepository;
// Formular anzeigen
@GetMapping("/form")
public String showForm(Model model) {
model.addAttribute("user", new User());
return "user-form";
}
// Benutzer speichern
@PostMapping("/save")
public String saveUser(@ModelAttribute User user) {
userRepository.save(user);
return "user-success"; // statt redirect
}
}
@@ -0,0 +1,24 @@
package com.example.mindflux.dto;
/** Eine Zeile in der TechTacular-Kategorienübersicht (ohne alle Fragen zu laden). */
public class AdminCategoryRow {
private final Long id;
private final String slug;
private final String title;
private final boolean active;
private final long questionCount;
public AdminCategoryRow(Long id, String slug, String title, boolean active, long questionCount) {
this.id = id;
this.slug = slug;
this.title = title;
this.active = active;
this.questionCount = questionCount;
}
public Long getId() { return id; }
public String getSlug() { return slug; }
public String getTitle() { return title; }
public boolean isActive() { return active; }
public long getQuestionCount() { return questionCount; }
}
@@ -0,0 +1,13 @@
package com.example.mindflux.dto;
/** Eine vom Spieler gewählte Antwort. */
public class AnswerRequest {
private Long questionId;
private Long answerId;
public Long getQuestionId() { return questionId; }
public void setQuestionId(Long questionId) { this.questionId = questionId; }
public Long getAnswerId() { return answerId; }
public void setAnswerId(Long answerId) { this.answerId = answerId; }
}
@@ -0,0 +1,21 @@
package com.example.mindflux.dto;
/** Serverseitige Bewertung einer einzelnen Antwort (instant feedback). */
public class AnswerResult {
private final boolean correct;
private final Long correctAnswerId;
private final String explanation;
private final int points;
public AnswerResult(boolean correct, Long correctAnswerId, String explanation, int points) {
this.correct = correct;
this.correctAnswerId = correctAnswerId;
this.explanation = explanation;
this.points = points;
}
public boolean isCorrect() { return correct; }
public Long getCorrectAnswerId() { return correctAnswerId; }
public String getExplanation() { return explanation; }
public int getPoints() { return points; }
}
@@ -0,0 +1,15 @@
package com.example.mindflux.dto;
import java.util.List;
/** Abschluss einer TechTacular-Runde: alle gewählten Antworten + Dauer. Score wird serverseitig recomputed. */
public class FinishRequest {
private int durationSeconds;
private List<AnswerRequest> answers;
public int getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; }
public List<AnswerRequest> getAnswers() { return answers; }
public void setAnswers(List<AnswerRequest> answers) { this.answers = answers; }
}
@@ -0,0 +1,25 @@
package com.example.mindflux.dto;
import java.time.LocalDate;
public class GameProgress {
private String name;
private int progressPercent;
private int highscore;
private int plays;
private LocalDate lastPlayed;
// Getter
public String getName() { return name; }
public int getProgressPercent() { return progressPercent; }
public int getHighscore() { return highscore; }
public int getPlays() { return plays; }
public LocalDate getLastPlayed() { return lastPlayed; }
// Setter
public void setName(String name) { this.name = name; }
public void setProgressPercent(int progressPercent) { this.progressPercent = progressPercent; }
public void setHighscore(int highscore) { this.highscore = highscore; }
public void setPlays(int plays) { this.plays = plays; }
public void setLastPlayed(LocalDate lastPlayed) { this.lastPlayed = lastPlayed; }
}
@@ -0,0 +1,20 @@
package com.example.mindflux.dto;
import java.time.LocalDate;
/** Zusammengefasste Statistik eines Spielers für ein Spiel. */
public class GameStats {
private final int highscore;
private final long plays;
private final LocalDate lastPlayed;
public GameStats(int highscore, long plays, LocalDate lastPlayed) {
this.highscore = highscore;
this.plays = plays;
this.lastPlayed = lastPlayed;
}
public int getHighscore() { return highscore; }
public long getPlays() { return plays; }
public LocalDate getLastPlayed() { return lastPlayed; }
}
@@ -0,0 +1,20 @@
package com.example.mindflux.dto;
import java.util.List;
/** Ein aktives Deck mit seinen Paaren fürs Spiel-API. */
public class MindMatchDeckView {
private final String slug;
private final String title;
private final List<MindMatchPairView> pairs;
public MindMatchDeckView(String slug, String title, List<MindMatchPairView> pairs) {
this.slug = slug;
this.title = title;
this.pairs = pairs;
}
public String getSlug() { return slug; }
public String getTitle() { return title; }
public List<MindMatchPairView> getPairs() { return pairs; }
}
@@ -0,0 +1,15 @@
package com.example.mindflux.dto;
/** Ein Paar fürs Spiel-API. Feld heißt "match" (passend zur bisherigen JS-Logik). */
public class MindMatchPairView {
private final String term;
private final String match;
public MindMatchPairView(String term, String match) {
this.term = term;
this.match = match;
}
public String getTerm() { return term; }
public String getMatch() { return match; }
}
@@ -0,0 +1,49 @@
package com.example.mindflux.dto;
public class ProgressData {
private int correctAnswers;
private int currentXp;
private int xpForNextLevel;
private int userLevel;
private int progressPercent;
public int getCorrectAnswers() {
return correctAnswers;
}
public void setCorrectAnswers(int correctAnswers) {
this.correctAnswers = correctAnswers;
}
public int getCurrentXp() {
return currentXp;
}
public void setCurrentXp(int currentXp) {
this.currentXp = currentXp;
}
public int getXpForNextLevel() {
return xpForNextLevel;
}
public void setXpForNextLevel(int xpForNextLevel) {
this.xpForNextLevel = xpForNextLevel;
}
public int getUserLevel() {
return userLevel;
}
public void setUserLevel(int userLevel) {
this.userLevel = userLevel;
}
public int getProgressPercent() {
return progressPercent;
}
public void setProgressPercent(int progressPercent) {
this.progressPercent = progressPercent;
}
}
@@ -0,0 +1,21 @@
package com.example.mindflux.dto;
/** Antwort nach dem Speichern eines Ergebnisses. */
public class ScoreResult {
private final int score;
private final int highscore;
private final boolean newHighscore;
private final long plays;
public ScoreResult(int score, int highscore, boolean newHighscore, long plays) {
this.score = score;
this.highscore = highscore;
this.newHighscore = newHighscore;
this.plays = plays;
}
public int getScore() { return score; }
public int getHighscore() { return highscore; }
public boolean isNewHighscore() { return newHighscore; }
public long getPlays() { return plays; }
}
@@ -0,0 +1,25 @@
package com.example.mindflux.dto;
/**
* Was der Client nach einem abgeschlossenen Spiel sendet. Der Score wird
* bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten
* berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann.
*/
public class ScoreSubmission {
private String deck;
private int pairs;
private int moves;
private int durationSeconds;
public String getDeck() { return deck; }
public void setDeck(String deck) { this.deck = deck; }
public int getPairs() { return pairs; }
public void setPairs(int pairs) { this.pairs = pairs; }
public int getMoves() { return moves; }
public void setMoves(int moves) { this.moves = moves; }
public int getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; }
}
@@ -0,0 +1,31 @@
package com.example.mindflux.dto;
import java.util.List;
/** Eine Mission prüfen: die eingetippten Lücken (+ ob die Auflösung angefordert wird). */
public class SyntaxSagaCheckRequest {
private Long missionId;
private boolean reveal;
private List<GapAnswer> answers;
public Long getMissionId() { return missionId; }
public void setMissionId(Long missionId) { this.missionId = missionId; }
public boolean isReveal() { return reveal; }
public void setReveal(boolean reveal) { this.reveal = reveal; }
public List<GapAnswer> getAnswers() { return answers; }
public void setAnswers(List<GapAnswer> answers) { this.answers = answers; }
public static class GapAnswer {
private int position;
private String text;
public int getPosition() { return position; }
public void setPosition(int position) { this.position = position; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
}
}
@@ -0,0 +1,37 @@
package com.example.mindflux.dto;
import java.util.List;
/**
* Serverseitige Bewertung einer Mission (instant feedback). {@code solution} ist nur
* gefüllt, wenn die Auflösung angefordert wurde und die Lücke nicht korrekt war.
*/
public class SyntaxSagaCheckResult {
private final boolean allCorrect;
private final List<GapVerdict> results;
public SyntaxSagaCheckResult(boolean allCorrect, List<GapVerdict> results) {
this.allCorrect = allCorrect;
this.results = results;
}
public boolean isAllCorrect() { return allCorrect; }
public List<GapVerdict> getResults() { return results; }
public static class GapVerdict {
private final int position;
private final boolean correct;
private final String solution;
public GapVerdict(int position, boolean correct, String solution) {
this.position = position;
this.correct = correct;
this.solution = solution;
}
public int getPosition() { return position; }
public boolean isCorrect() { return correct; }
public String getSolution() { return solution; }
}
}
@@ -0,0 +1,46 @@
package com.example.mindflux.dto;
import java.util.List;
/**
* Abschluss eines Saga-Durchlaufs: alle eingetippten Lücken über alle Missionen einer
* Stufe (+ Versuche und ob aufgelöst wurde). Der Score wird serverseitig neu berechnet.
*/
public class SyntaxSagaFinishRequest {
private String difficulty;
private int durationSeconds;
private List<FinishGap> gaps;
public String getDifficulty() { return difficulty; }
public void setDifficulty(String difficulty) { this.difficulty = difficulty; }
public int getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; }
public List<FinishGap> getGaps() { return gaps; }
public void setGaps(List<FinishGap> gaps) { this.gaps = gaps; }
public static class FinishGap {
private Long missionId;
private int position;
private String text;
private int attempts;
private boolean revealed;
public Long getMissionId() { return missionId; }
public void setMissionId(Long missionId) { this.missionId = missionId; }
public int getPosition() { return position; }
public void setPosition(int position) { this.position = position; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public int getAttempts() { return attempts; }
public void setAttempts(int attempts) { this.attempts = attempts; }
public boolean isRevealed() { return revealed; }
public void setRevealed(boolean revealed) { this.revealed = revealed; }
}
}
@@ -0,0 +1,28 @@
package com.example.mindflux.dto;
/** Eine Zeile in der Admin-Missionsübersicht (ohne alle Lücken zu laden). */
public class SyntaxSagaMissionRow {
private final Long id;
private final String slug;
private final String title;
private final String difficulty;
private final boolean active;
private final long gapCount;
public SyntaxSagaMissionRow(Long id, String slug, String title, String difficulty,
boolean active, long gapCount) {
this.id = id;
this.slug = slug;
this.title = title;
this.difficulty = difficulty;
this.active = active;
this.gapCount = gapCount;
}
public Long getId() { return id; }
public String getSlug() { return slug; }
public String getTitle() { return title; }
public String getDifficulty() { return difficulty; }
public boolean isActive() { return active; }
public long getGapCount() { return gapCount; }
}
@@ -0,0 +1,35 @@
package com.example.mindflux.dto;
/** Endergebnis eines Saga-Durchlaufs (Score serverseitig berechnet) + Highscore-Stand. */
public class SyntaxSagaResult {
private final int score;
private final int maxScore;
private final int solvedGaps;
private final int totalGaps;
private final int stars;
private final int highscore;
private final boolean newHighscore;
private final long plays;
public SyntaxSagaResult(int score, int maxScore, int solvedGaps, int totalGaps, int stars,
int highscore, boolean newHighscore, long plays) {
this.score = score;
this.maxScore = maxScore;
this.solvedGaps = solvedGaps;
this.totalGaps = totalGaps;
this.stars = stars;
this.highscore = highscore;
this.newHighscore = newHighscore;
this.plays = plays;
}
public int getScore() { return score; }
public int getMaxScore() { return maxScore; }
public int getSolvedGaps() { return solvedGaps; }
public int getTotalGaps() { return totalGaps; }
public int getStars() { return stars; }
public int getHighscore() { return highscore; }
public boolean isNewHighscore() { return newHighscore; }
public long getPlays() { return plays; }
}
@@ -0,0 +1,82 @@
package com.example.mindflux.dto;
import java.util.List;
/**
* Die Missionen einer Schwierigkeitsstufe fürs Frontend. Bewusst OHNE Lösungen –
* geprüft und gewertet wird ausschließlich serverseitig.
*/
public class SyntaxSagaStoryView {
private final String difficulty;
private final List<Mission> missions;
private final int maxScore;
private final int totalGaps;
public SyntaxSagaStoryView(String difficulty, List<Mission> missions, int maxScore, int totalGaps) {
this.difficulty = difficulty;
this.missions = missions;
this.maxScore = maxScore;
this.totalGaps = totalGaps;
}
public String getDifficulty() { return difficulty; }
public List<Mission> getMissions() { return missions; }
public int getMaxScore() { return maxScore; }
public int getTotalGaps() { return totalGaps; }
public static class Mission {
private final Long missionId;
private final String title;
private final String difficulty;
private final String language;
private final String narrative;
private final String dialog;
private final String codeTemplate;
private final String successText;
private final List<Gap> gaps;
public Mission(Long missionId, String title, String difficulty, String language,
String narrative, String dialog, String codeTemplate,
String successText, List<Gap> gaps) {
this.missionId = missionId;
this.title = title;
this.difficulty = difficulty;
this.language = language;
this.narrative = narrative;
this.dialog = dialog;
this.codeTemplate = codeTemplate;
this.successText = successText;
this.gaps = gaps;
}
public Long getMissionId() { return missionId; }
public String getTitle() { return title; }
public String getDifficulty() { return difficulty; }
public String getLanguage() { return language; }
public String getNarrative() { return narrative; }
public String getDialog() { return dialog; }
public String getCodeTemplate() { return codeTemplate; }
public String getSuccessText() { return successText; }
public List<Gap> getGaps() { return gaps; }
}
public static class Gap {
private final int position;
private final int points;
private final String hint;
private final String placeholder;
public Gap(int position, int points, String hint, String placeholder) {
this.position = position;
this.points = points;
this.hint = hint;
this.placeholder = placeholder;
}
public int getPosition() { return position; }
public int getPoints() { return points; }
public String getHint() { return hint; }
public String getPlaceholder() { return placeholder; }
}
}
@@ -0,0 +1,66 @@
package com.example.mindflux.dto;
import java.util.List;
/**
* Das Jeopardy-Board fürs Frontend: Spalten (Kategorien) mit Kacheln (Fragen).
* Bewusst OHNE Information darüber, welche Antwort korrekt ist – geprüft wird serverseitig.
*/
public class TechtacularBoardView {
private final List<Category> categories;
private final int maxScore;
public TechtacularBoardView(List<Category> categories, int maxScore) {
this.categories = categories;
this.maxScore = maxScore;
}
public List<Category> getCategories() { return categories; }
public int getMaxScore() { return maxScore; }
public static class Category {
private final String title;
private final List<Tile> tiles;
public Category(String title, List<Tile> tiles) {
this.title = title;
this.tiles = tiles;
}
public String getTitle() { return title; }
public List<Tile> getTiles() { return tiles; }
}
public static class Tile {
private final Long questionId;
private final int points;
private final String text;
private final List<Option> answers;
public Tile(Long questionId, int points, String text, List<Option> answers) {
this.questionId = questionId;
this.points = points;
this.text = text;
this.answers = answers;
}
public Long getQuestionId() { return questionId; }
public int getPoints() { return points; }
public String getText() { return text; }
public List<Option> getAnswers() { return answers; }
}
public static class Option {
private final Long id;
private final String text;
public Option(Long id, String text) {
this.id = id;
this.text = text;
}
public Long getId() { return id; }
public String getText() { return text; }
}
}
@@ -0,0 +1,31 @@
package com.example.mindflux.dto;
/** Ergebnis einer abgeschlossenen TechTacular-Runde. */
public class TechtacularResult {
private final int score;
private final int maxScore;
private final int correctCount;
private final int totalCount;
private final int highscore;
private final boolean newHighscore;
private final long plays;
public TechtacularResult(int score, int maxScore, int correctCount, int totalCount,
int highscore, boolean newHighscore, long plays) {
this.score = score;
this.maxScore = maxScore;
this.correctCount = correctCount;
this.totalCount = totalCount;
this.highscore = highscore;
this.newHighscore = newHighscore;
this.plays = plays;
}
public int getScore() { return score; }
public int getMaxScore() { return maxScore; }
public int getCorrectCount() { return correctCount; }
public int getTotalCount() { return totalCount; }
public int getHighscore() { return highscore; }
public boolean isNewHighscore() { return newHighscore; }
public long getPlays() { return plays; }
}
@@ -0,0 +1,51 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "achievements")
public class Achievement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String title;
private String description;
private String icon; // Dateiname des Icons (z.B. "star.png")
private LocalDateTime dateAchieved;
// 🔥 Neuer Konstruktor für schnelle Dummy-Erstellung (ohne username/dateAchieved)
public Achievement(String title, String description, String icon, boolean unlocked) {
this.title = title;
this.description = description;
this.icon = icon;
this.dateAchieved = unlocked ? LocalDateTime.now() : null;
}
// ⚡ Standard-Konstruktor (wichtig für JPA!)
public Achievement() {
}
// Getter und Setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getIcon() { return icon; }
public void setIcon(String icon) { this.icon = icon; }
public LocalDateTime getDateAchieved() { return dateAchieved; }
public void setDateAchieved(LocalDateTime dateAchieved) { this.dateAchieved = dateAchieved; }
}
@@ -0,0 +1,75 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* Ein einzelnes Spielergebnis. Bewusst generisch gehalten, damit alle
* Mindflux-Spiele (MindMatch, TechTacular, ...) dieselbe Tabelle nutzen können.
* Welches Spiel das Ergebnis erzeugt hat, steht in {@link #game}.
*/
@Entity
@Table(name = "game_scores")
public class GameScore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
/** Spiel-Kennung, z.B. "mindmatch" (entspricht der Route im Dashboard). */
private String game;
/** Optionales Deck / Level innerhalb des Spiels, z.B. "netzwerk". */
private String deck;
private int score;
/** Anzahl der Zug-Versuche (kann je nach Spiel null sein). */
private Integer moves;
@Column(name = "duration_seconds")
private Integer durationSeconds;
@Column(name = "played_at")
private LocalDateTime playedAt;
public GameScore() {
}
public GameScore(String username, String game, String deck, int score,
Integer moves, Integer durationSeconds, LocalDateTime playedAt) {
this.username = username;
this.game = game;
this.deck = deck;
this.score = score;
this.moves = moves;
this.durationSeconds = durationSeconds;
this.playedAt = playedAt;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getGame() { return game; }
public void setGame(String game) { this.game = game; }
public String getDeck() { return deck; }
public void setDeck(String deck) { this.deck = deck; }
public int getScore() { return score; }
public void setScore(int score) { this.score = score; }
public Integer getMoves() { return moves; }
public void setMoves(Integer moves) { this.moves = moves; }
public Integer getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(Integer durationSeconds) { this.durationSeconds = durationSeconds; }
public LocalDateTime getPlayedAt() { return playedAt; }
public void setPlayedAt(LocalDateTime playedAt) { this.playedAt = playedAt; }
}
@@ -0,0 +1,63 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Ein MindMatch-Deck (Kategorie) mit seinen Begriffspaaren. Von Admins pflegbar. */
@Entity
@Table(name = "mindmatch_decks")
public class MindMatchDeck {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String slug;
private String title;
@Column(name = "sort_order")
private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "deck", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id ASC")
private List<MindMatchPair> pairs = new ArrayList<>();
public MindMatchDeck() {
}
public MindMatchDeck(String slug, String title, int sortOrder, boolean active) {
this.slug = slug;
this.title = title;
this.sortOrder = sortOrder;
this.active = active;
}
/** Fügt ein Paar hinzu und hält die Rückreferenz konsistent. */
public void addPair(MindMatchPair pair) {
pair.setDeck(this);
this.pairs.add(pair);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public List<MindMatchPair> getPairs() { return pairs; }
public void setPairs(List<MindMatchPair> pairs) { this.pairs = pairs; }
}
@@ -0,0 +1,43 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
/** Ein Begriffspaar innerhalb eines {@link MindMatchDeck}: Begriff + zugehörige Bedeutung. */
@Entity
@Table(name = "mindmatch_pairs")
public class MindMatchPair {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "deck_id")
private MindMatchDeck deck;
private String term;
/** Spalte heißt "match_text", da MATCH in SQL ein reserviertes Wort ist. */
@Column(name = "match_text")
private String matchText;
public MindMatchPair() {
}
public MindMatchPair(String term, String matchText) {
this.term = term;
this.matchText = matchText;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public MindMatchDeck getDeck() { return deck; }
public void setDeck(MindMatchDeck deck) { this.deck = deck; }
public String getTerm() { return term; }
public void setTerm(String term) { this.term = term; }
public String getMatchText() { return matchText; }
public void setMatchText(String matchText) { this.matchText = matchText; }
}
@@ -0,0 +1,112 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/**
* Eine Lücke in einer {@link SyntaxSagaMission}. Die {@link #position} entspricht der
* Markernummer {@code {{n}}} im Codetemplate. Eine Lücke kann mehrere gültige Lösungen
* haben (z.B. {@code 192.168.0.1} oder {@code localhost}). Die Prüfung passiert
* serverseitig über {@link #matches(String)} – das Frontend kennt die Lösung nie.
*/
@Entity
@Table(name = "syntax_saga_gaps")
public class SyntaxSagaGap {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mission_id")
private SyntaxSagaMission mission;
/** 1-basierte Position, = Markernummer {@code {{n}}} im Codetemplate. */
@Column(name = "gap_position")
private int position;
private int points = 100;
/** Groß-/Kleinschreibung relevant? (true für Java-Bezeichner, false z.B. für SQL-Keywords). */
@Column(name = "case_sensitive")
private boolean caseSensitive = true;
private String hint;
/** Kurzlabel/Platzhalter im Eingabefeld, z.B. "Methodenname". */
private String placeholder;
@OneToMany(mappedBy = "gap", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC, id ASC")
private List<SyntaxSagaSolution> solutions = new ArrayList<>();
public SyntaxSagaGap() {
}
public SyntaxSagaGap(int position, int points, boolean caseSensitive, String hint, String placeholder) {
this.position = position;
this.points = points;
this.caseSensitive = caseSensitive;
this.hint = hint;
this.placeholder = placeholder;
}
public void addSolution(SyntaxSagaSolution solution) {
solution.setGap(this);
this.solutions.add(solution);
}
/** Normalisiert (trim + interne Whitespace zu einem Space). */
private static String normalize(String value) {
return value == null ? "" : value.trim().replaceAll("\\s+", " ");
}
/** Prüft eine Eingabe serverseitig gegen alle gültigen Lösungen. */
public boolean matches(String typed) {
if (typed == null) {
return false;
}
String t = normalize(typed);
if (t.isEmpty()) {
return false;
}
for (SyntaxSagaSolution solution : solutions) {
String sol = normalize(solution.getText());
boolean equal = caseSensitive ? t.equals(sol) : t.equalsIgnoreCase(sol);
if (equal) {
return true;
}
}
return false;
}
/** Kanonische Lösung (für die "Auflösung anzeigen"-Funktion). */
public String firstSolution() {
return solutions.isEmpty() ? "" : solutions.get(0).getText();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public SyntaxSagaMission getMission() { return mission; }
public void setMission(SyntaxSagaMission mission) { this.mission = mission; }
public int getPosition() { return position; }
public void setPosition(int position) { this.position = position; }
public int getPoints() { return points; }
public void setPoints(int points) { this.points = points; }
public boolean isCaseSensitive() { return caseSensitive; }
public void setCaseSensitive(boolean caseSensitive) { this.caseSensitive = caseSensitive; }
public String getHint() { return hint; }
public void setHint(String hint) { this.hint = hint; }
public String getPlaceholder() { return placeholder; }
public void setPlaceholder(String placeholder) { this.placeholder = placeholder; }
public List<SyntaxSagaSolution> getSolutions() { return solutions; }
public void setSolutions(List<SyntaxSagaSolution> solutions) { this.solutions = solutions; }
}
@@ -0,0 +1,114 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/**
* Eine Syntax-Saga-Mission (ein Kapitel): Story + optionaler Dialog + Codeblock mit
* Lücken. Im {@code codeTemplate} markieren {@code {{1}}}, {@code {{2}}} … die Lücken
* (Nummer = {@link SyntaxSagaGap#getPosition() position}). Gespielt wird je
* Schwierigkeitsgrad als eigene lineare Saga.
*/
@Entity
@Table(name = "syntax_saga_missions")
public class SyntaxSagaMission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String slug;
private String title;
/** "junior" | "professional" | "senior". */
private String difficulty;
/** Erzähltext / Szene. */
private String narrative;
/** Optionales Zitat ("Bitte… repariere mich…"). */
private String dialog;
/** Sprache des Codes für Badge/Darstellung, z.B. "java", "sql", "bash". */
@Column(name = "code_language")
private String language;
/** Code mit {@code {{n}}}-Markern an den Lücken. */
@Column(name = "code_template")
private String codeTemplate;
/** Fortsetzung nach dem Lösen ("Das Terminal erwacht…"). */
@Column(name = "success_text")
private String successText;
@Column(name = "sort_order")
private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "mission", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC, id ASC")
private List<SyntaxSagaGap> gaps = new ArrayList<>();
public SyntaxSagaMission() {
}
public SyntaxSagaMission(String slug, String title, String difficulty, String narrative,
String dialog, String language, String codeTemplate,
String successText, int sortOrder, boolean active) {
this.slug = slug;
this.title = title;
this.difficulty = difficulty;
this.narrative = narrative;
this.dialog = dialog;
this.language = language;
this.codeTemplate = codeTemplate;
this.successText = successText;
this.sortOrder = sortOrder;
this.active = active;
}
public void addGap(SyntaxSagaGap gap) {
gap.setMission(this);
this.gaps.add(gap);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDifficulty() { return difficulty; }
public void setDifficulty(String difficulty) { this.difficulty = difficulty; }
public String getNarrative() { return narrative; }
public void setNarrative(String narrative) { this.narrative = narrative; }
public String getDialog() { return dialog; }
public void setDialog(String dialog) { this.dialog = dialog; }
public String getLanguage() { return language; }
public void setLanguage(String language) { this.language = language; }
public String getCodeTemplate() { return codeTemplate; }
public void setCodeTemplate(String codeTemplate) { this.codeTemplate = codeTemplate; }
public String getSuccessText() { return successText; }
public void setSuccessText(String successText) { this.successText = successText; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public List<SyntaxSagaGap> getGaps() { return gaps; }
public void setGaps(List<SyntaxSagaGap> gaps) { this.gaps = gaps; }
}
@@ -0,0 +1,43 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
/** Eine gültige Antwort zu einer {@link SyntaxSagaGap} (eine Lücke kann mehrere haben). */
@Entity
@Table(name = "syntax_saga_solutions")
public class SyntaxSagaSolution {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "gap_id")
private SyntaxSagaGap gap;
@Column(name = "solution_text")
private String text;
@Column(name = "sort_order")
private int sortOrder;
public SyntaxSagaSolution() {
}
public SyntaxSagaSolution(String text, int sortOrder) {
this.text = text;
this.sortOrder = sortOrder;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public SyntaxSagaGap getGap() { return gap; }
public void setGap(SyntaxSagaGap gap) { this.gap = gap; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
}
@@ -0,0 +1,49 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
/** Eine Antwortmöglichkeit zu einer {@link TechtacularQuestion}. */
@Entity
@Table(name = "techtacular_answers")
public class TechtacularAnswer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id")
private TechtacularQuestion question;
@Column(name = "answer_text")
private String text;
private boolean correct;
@Column(name = "sort_order")
private int sortOrder;
public TechtacularAnswer() {
}
public TechtacularAnswer(String text, boolean correct, int sortOrder) {
this.text = text;
this.correct = correct;
this.sortOrder = sortOrder;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public TechtacularQuestion getQuestion() { return question; }
public void setQuestion(TechtacularQuestion question) { this.question = question; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public boolean isCorrect() { return correct; }
public void setCorrect(boolean correct) { this.correct = correct; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
}
@@ -0,0 +1,62 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Eine TechTacular-Kategorie (Spalte im Jeopardy-Board) mit ihren Fragen. */
@Entity
@Table(name = "techtacular_categories")
public class TechtacularCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String slug;
private String title;
@Column(name = "sort_order")
private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("points ASC, id ASC")
private List<TechtacularQuestion> questions = new ArrayList<>();
public TechtacularCategory() {
}
public TechtacularCategory(String slug, String title, int sortOrder, boolean active) {
this.slug = slug;
this.title = title;
this.sortOrder = sortOrder;
this.active = active;
}
public void addQuestion(TechtacularQuestion question) {
question.setCategory(this);
this.questions.add(question);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public List<TechtacularQuestion> getQuestions() { return questions; }
public void setQuestions(List<TechtacularQuestion> questions) { this.questions = questions; }
}
@@ -0,0 +1,67 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Eine Frage mit ihren Antwortmöglichkeiten (genau eine ist korrekt) und Punktwert. */
@Entity
@Table(name = "techtacular_questions")
public class TechtacularQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private TechtacularCategory category;
@Column(name = "question_text")
private String text;
private String explanation;
private int points;
@OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC, id ASC")
private List<TechtacularAnswer> answers = new ArrayList<>();
public TechtacularQuestion() {
}
public TechtacularQuestion(String text, String explanation, int points) {
this.text = text;
this.explanation = explanation;
this.points = points;
}
public void addAnswer(TechtacularAnswer answer) {
answer.setQuestion(this);
this.answers.add(answer);
}
/** Liefert die als korrekt markierte Antwort (oder null). */
public TechtacularAnswer correctAnswer() {
return answers.stream().filter(TechtacularAnswer::isCorrect).findFirst().orElse(null);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public TechtacularCategory getCategory() { return category; }
public void setCategory(TechtacularCategory category) { this.category = category; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public String getExplanation() { return explanation; }
public void setExplanation(String explanation) { this.explanation = explanation; }
public int getPoints() { return points; }
public void setPoints(int points) { this.points = points; }
public List<TechtacularAnswer> getAnswers() { return answers; }
public void setAnswers(List<TechtacularAnswer> answers) { this.answers = answers; }
}
@@ -0,0 +1,54 @@
package com.example.mindflux.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
private String email;
private String role;
@Column(name = "profile_picture")
private String profilePicture;
public User() {}
public User(String username, String password, String email, String role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
}
// —————————————
// Getter / Setter
// —————————————
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String u) { this.username = u; }
public String getPassword() { return password; }
public void setPassword(String p) { this.password = p; }
public String getEmail() { return email; }
public void setEmail(String e) { this.email = e; }
public String getRole() { return role; }
public void setRole(String r) { this.role = r; }
public String getProfilePicture() { return profilePicture; }
public void setProfilePicture(String pictureUrl) { this.profilePicture = pictureUrl; }
}
@@ -0,0 +1,13 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.Achievement;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AchievementRepository extends JpaRepository<Achievement, Long> {
List<Achievement> findByUsername(String username);
/** Alle Achievements eines Spielers löschen (z. B. beim Löschen des Kontos). */
void deleteByUsername(String username);
}
@@ -0,0 +1,7 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AdminUserRepository extends JpaRepository<User, Long> {
}
@@ -0,0 +1,21 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.GameScore;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
/** Bestes Ergebnis eines Spielers in einem Spiel (Highscore). */
Optional<GameScore> findTopByUsernameAndGameOrderByScoreDesc(String username, String game);
/** Zuletzt gespieltes Ergebnis eines Spielers in einem Spiel. */
Optional<GameScore> findTopByUsernameAndGameOrderByPlayedAtDesc(String username, String game);
/** Wie oft ein Spieler ein Spiel abgeschlossen hat. */
long countByUsernameAndGame(String username, String game);
/** Alle Spielstände eines Spielers löschen (z. B. beim Löschen des Kontos). */
void deleteByUsername(String username);
}
@@ -0,0 +1,18 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.MindMatchDeck;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface MindMatchDeckRepository extends JpaRepository<MindMatchDeck, Long> {
List<MindMatchDeck> findAllByActiveTrueOrderBySortOrderAscIdAsc();
List<MindMatchDeck> findAllByOrderBySortOrderAscIdAsc();
Optional<MindMatchDeck> findBySlug(String slug);
boolean existsBySlug(String slug);
}
@@ -0,0 +1,7 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.MindMatchPair;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MindMatchPairRepository extends JpaRepository<MindMatchPair, Long> {
}
@@ -0,0 +1,9 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.SyntaxSagaGap;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SyntaxSagaGapRepository extends JpaRepository<SyntaxSagaGap, Long> {
long countByMissionId(Long missionId);
}
@@ -0,0 +1,20 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.SyntaxSagaMission;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface SyntaxSagaMissionRepository extends JpaRepository<SyntaxSagaMission, Long> {
/** Aktive Missionen einer Schwierigkeitsstufe in Spielreihenfolge. */
List<SyntaxSagaMission> findAllByActiveTrueAndDifficultyOrderBySortOrderAscIdAsc(String difficulty);
/** Alle aktiven Missionen in Spielreihenfolge (stufenübergreifend). */
List<SyntaxSagaMission> findAllByActiveTrueOrderBySortOrderAscIdAsc();
/** Alle Missionen (Admin-Übersicht). */
List<SyntaxSagaMission> findAllByOrderBySortOrderAscIdAsc();
boolean existsBySlug(String slug);
}
@@ -0,0 +1,7 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.SyntaxSagaSolution;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SyntaxSagaSolutionRepository extends JpaRepository<SyntaxSagaSolution, Long> {
}
@@ -0,0 +1,7 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularAnswer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TechtacularAnswerRepository extends JpaRepository<TechtacularAnswer, Long> {
}
@@ -0,0 +1,15 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TechtacularCategoryRepository extends JpaRepository<TechtacularCategory, Long> {
List<TechtacularCategory> findAllByActiveTrueOrderBySortOrderAscIdAsc();
List<TechtacularCategory> findAllByOrderBySortOrderAscIdAsc();
boolean existsBySlug(String slug);
}
@@ -0,0 +1,9 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularQuestion;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TechtacularQuestionRepository extends JpaRepository<TechtacularQuestion, Long> {
long countByCategoryId(Long categoryId);
}
@@ -0,0 +1,14 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
// Benutzer anhand des Benutzernamens suchen
Optional<User> findByUsername(String username);
// Benutzer anhand des Benutzernamens löschen
void deleteByUsername(String username);
}
@@ -0,0 +1,9 @@
package com.example.mindflux.service;
import com.example.mindflux.model.Achievement;
import java.util.List;
public interface AchievementService {
List<Achievement> getAchievementsForUser(String username);
}
@@ -0,0 +1,20 @@
package com.example.mindflux.service;
import com.example.mindflux.model.Achievement;
import com.example.mindflux.repository.AchievementRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AchievementServiceImpl implements AchievementService {
@Autowired
private AchievementRepository achievementRepository;
@Override
public List<Achievement> getAchievementsForUser(String username) {
return achievementRepository.findByUsername(username);
}
}
@@ -0,0 +1,32 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.AdminUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class AdminUserService {
@Autowired
private AdminUserRepository userRepository;
public List<User> findAll() {
return userRepository.findAll();
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public User save(User user) {
return userRepository.save(user);
}
public void deleteById(Long id) {
userRepository.deleteById(id);
}
}
@@ -0,0 +1,83 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.GameStats;
import com.example.mindflux.dto.ScoreResult;
import com.example.mindflux.dto.ScoreSubmission;
import com.example.mindflux.model.GameScore;
import com.example.mindflux.repository.GameScoreRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* Speichert Spielergebnisse und liefert Statistiken. Der Score wird hier
* autoritativ berechnet – der Client schickt nur die Rohwerte.
*/
@Service
public class GameScoreService {
private final GameScoreRepository repository;
public GameScoreService(GameScoreRepository repository) {
this.repository = repository;
}
/**
* Punktezahl aus Genauigkeit und Tempo.
* <ul>
* <li>Genauigkeit: {@code 1000 * Paare / Züge} – 1000 Punkte bei perfektem Spiel
* (jeder Aufdeck-Versuch ein Treffer), weniger bei vielen Fehlversuchen.</li>
* <li>Zeitbonus: bis zu 500 Punkte, der nach 100 Sekunden auf 0 fällt.</li>
* </ul>
* Identisch in {@code mindmatch.js} gespiegelt, damit der Client sofort denselben
* Wert anzeigt.
*/
public static int calculateScore(int pairs, int moves, int durationSeconds) {
if (pairs <= 0) {
return 0;
}
int safeMoves = Math.max(moves, pairs); // niemals weniger Züge als Paare
int safeSeconds = Math.max(durationSeconds, 0);
int accuracyScore = Math.round(1000f * pairs / safeMoves);
int timeBonus = Math.max(0, 500 - safeSeconds * 5);
return accuracyScore + timeBonus;
}
/** Speichert ein abgeschlossenes MindMatch-Spiel (Score aus Rohwerten berechnet). */
public ScoreResult record(String username, String game, ScoreSubmission submission) {
int moves = Math.max(submission.getMoves(), submission.getPairs());
int seconds = Math.max(submission.getDurationSeconds(), 0);
int score = calculateScore(submission.getPairs(), moves, seconds);
return recordScore(username, game, submission.getDeck(), score, moves, seconds);
}
/** Speichert ein bereits berechnetes Ergebnis (z.B. TechTacular) und liefert den Highscore-Stand. */
public ScoreResult recordScore(String username, String game, String deck,
int score, Integer moves, Integer durationSeconds) {
GameScore previousBest = repository
.findTopByUsernameAndGameOrderByScoreDesc(username, game)
.orElse(null);
repository.save(new GameScore(
username, game, deck, score, moves, durationSeconds, LocalDateTime.now()));
boolean newHighscore = previousBest == null || score > previousBest.getScore();
int highscore = previousBest == null ? score : Math.max(previousBest.getScore(), score);
long plays = repository.countByUsernameAndGame(username, game);
return new ScoreResult(score, highscore, newHighscore, plays);
}
/** Aktuelle Statistik (Highscore, Anzahl Spiele, zuletzt gespielt) für ein Spiel. */
public GameStats getStats(String username, String game) {
int highscore = repository.findTopByUsernameAndGameOrderByScoreDesc(username, game)
.map(GameScore::getScore)
.orElse(0);
long plays = repository.countByUsernameAndGame(username, game);
var lastPlayed = repository.findTopByUsernameAndGameOrderByPlayedAtDesc(username, game)
.map(score -> score.getPlayedAt().toLocalDate())
.orElse(null);
return new GameStats(highscore, plays, lastPlayed);
}
}
@@ -0,0 +1,102 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.MindMatchDeckView;
import com.example.mindflux.dto.MindMatchPairView;
import com.example.mindflux.model.MindMatchDeck;
import com.example.mindflux.model.MindMatchPair;
import com.example.mindflux.repository.MindMatchDeckRepository;
import com.example.mindflux.repository.MindMatchPairRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class MindMatchService {
private final MindMatchDeckRepository deckRepository;
private final MindMatchPairRepository pairRepository;
public MindMatchService(MindMatchDeckRepository deckRepository, MindMatchPairRepository pairRepository) {
this.deckRepository = deckRepository;
this.pairRepository = pairRepository;
}
// ---------- Spieler ----------
/** Aktive Decks mit Paaren fürs Spiel (open-in-view=false -> hier initialisiert). */
@Transactional(readOnly = true)
public List<MindMatchDeckView> activeDecks() {
return deckRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc().stream()
.map(deck -> new MindMatchDeckView(
deck.getSlug(),
deck.getTitle(),
deck.getPairs().stream()
.map(pair -> new MindMatchPairView(pair.getTerm(), pair.getMatchText()))
.toList()))
.toList();
}
// ---------- Admin ----------
/** Alle Decks (auch inaktive) inkl. Paaren für die Verwaltung. */
@Transactional(readOnly = true)
public List<MindMatchDeck> allDecksWithPairs() {
List<MindMatchDeck> decks = deckRepository.findAllByOrderBySortOrderAscIdAsc();
decks.forEach(deck -> deck.getPairs().size()); // Lazy-Collection laden
return decks;
}
@Transactional
public void createDeck(String title) {
if (title == null || title.isBlank()) {
return;
}
String clean = title.trim();
int order = (int) deckRepository.count() + 1;
deckRepository.save(new MindMatchDeck(uniqueSlug(clean), clean, order, true));
}
@Transactional
public void addPair(Long deckId, String term, String match) {
if (term == null || term.isBlank() || match == null || match.isBlank()) {
return;
}
deckRepository.findById(deckId).ifPresent(deck -> {
deck.addPair(new MindMatchPair(term.trim(), match.trim()));
deckRepository.save(deck);
});
}
@Transactional
public void deletePair(Long pairId) {
pairRepository.deleteById(pairId);
}
@Transactional
public void deleteDeck(Long deckId) {
deckRepository.deleteById(deckId);
}
@Transactional
public void toggleDeck(Long deckId) {
deckRepository.findById(deckId).ifPresent(deck -> deck.setActive(!deck.isActive()));
}
/** Erzeugt aus dem Titel einen URL-tauglichen, eindeutigen Slug. */
private String uniqueSlug(String title) {
String base = title.toLowerCase()
.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (base.isBlank()) {
base = "deck";
}
String slug = base;
int counter = 2;
while (deckRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
return slug;
}
}
@@ -0,0 +1,66 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.GameProgress;
import com.example.mindflux.dto.GameStats;
import com.example.mindflux.dto.ProgressData;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class ProgressService {
/** Spiel-Kennung -> Anzeigename, in Dashboard-Reihenfolge. */
private static final Map<String, String> GAMES = new LinkedHashMap<>();
static {
GAMES.put("mindmatch", "MindMatch");
GAMES.put("techtacular", "TechTacular");
GAMES.put("syntax-saga", "Syntax Saga");
GAMES.put("escape-room", "Code Escape Room");
GAMES.put("service-clash", "Service Clash");
GAMES.put("bug-hunter", "Bug Hunter");
GAMES.put("netzwerker", "Netzwerker");
}
private final GameScoreService gameScoreService;
public ProgressService(GameScoreService gameScoreService) {
this.gameScoreService = gameScoreService;
}
public ProgressData getOverallProgress(String username) {
// Dummy-Daten – später aus DB berechnen
ProgressData data = new ProgressData();
data.setCorrectAnswers(432);
data.setCurrentXp(2400);
data.setXpForNextLevel(3000);
data.setUserLevel(5);
data.setProgressPercent(78);
return data;
}
/** Echter Spiel-Fortschritt pro Spiel aus der Datenbank (game_scores). */
public List<GameProgress> getGameProgress(String username) {
List<GameProgress> result = new ArrayList<>();
for (Map.Entry<String, String> entry : GAMES.entrySet()) {
GameStats stats = gameScoreService.getStats(username, entry.getKey());
result.add(toGameProgress(entry.getValue(), stats));
}
return result;
}
private GameProgress toGameProgress(String displayName, GameStats stats) {
GameProgress game = new GameProgress();
game.setName(displayName);
game.setHighscore(stats.getHighscore());
game.setPlays((int) stats.getPlays());
game.setLastPlayed(stats.getLastPlayed());
// Fortschritt auf der Score-Skala (max ~1500 Punkte -> 100 %).
game.setProgressPercent(Math.min(100, Math.round(stats.getHighscore() / 15.0f)));
return game;
}
}
@@ -0,0 +1,318 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.*;
import com.example.mindflux.model.SyntaxSagaGap;
import com.example.mindflux.model.SyntaxSagaMission;
import com.example.mindflux.model.SyntaxSagaSolution;
import com.example.mindflux.repository.SyntaxSagaGapRepository;
import com.example.mindflux.repository.SyntaxSagaMissionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* Server-autoritative Logik für Syntax Saga: baut die Story (ohne Lösungen), prüft
* Eingaben serverseitig und berechnet den Score beim Abschluss neu. Wertung wandert
* über {@link GameScoreService} unter der Spiel-Kennung "syntax-saga" in die DB.
*/
@Service
public class SyntaxSagaService {
private static final String GAME = "syntax-saga";
private static final Set<String> DIFFICULTIES = Set.of("junior", "professional", "senior");
private final SyntaxSagaMissionRepository missionRepository;
private final SyntaxSagaGapRepository gapRepository;
private final GameScoreService gameScoreService;
public SyntaxSagaService(SyntaxSagaMissionRepository missionRepository,
SyntaxSagaGapRepository gapRepository,
GameScoreService gameScoreService) {
this.missionRepository = missionRepository;
this.gapRepository = gapRepository;
this.gameScoreService = gameScoreService;
}
// ---------- Spieler ----------
/** Liefert die aktiven Missionen einer Stufe (oder stufenübergreifend) – ohne Lösungen. */
@Transactional(readOnly = true)
public SyntaxSagaStoryView buildStory(String difficulty) {
String diff = normalizeOrNull(difficulty);
List<SyntaxSagaMission> missions = (diff == null)
? missionRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()
: missionRepository.findAllByActiveTrueAndDifficultyOrderBySortOrderAscIdAsc(diff);
List<SyntaxSagaStoryView.Mission> views = new ArrayList<>();
int maxScore = 0;
int totalGaps = 0;
for (SyntaxSagaMission mission : missions) {
if (mission.getGaps().isEmpty()) {
continue;
}
List<SyntaxSagaStoryView.Gap> gapViews = new ArrayList<>();
for (SyntaxSagaGap gap : mission.getGaps()) {
gapViews.add(new SyntaxSagaStoryView.Gap(
gap.getPosition(), gap.getPoints(), gap.getHint(), gap.getPlaceholder()));
maxScore += gap.getPoints();
totalGaps++;
}
views.add(new SyntaxSagaStoryView.Mission(
mission.getId(), mission.getTitle(), mission.getDifficulty(), mission.getLanguage(),
mission.getNarrative(), mission.getDialog(), mission.getCodeTemplate(),
mission.getSuccessText(), gapViews));
}
return new SyntaxSagaStoryView(diff == null ? "all" : diff, views, maxScore, totalGaps);
}
/** Prüft die getippten Lücken einer Mission serverseitig (Frontend kennt die Lösung nicht). */
@Transactional(readOnly = true)
public SyntaxSagaCheckResult check(SyntaxSagaCheckRequest request) {
List<SyntaxSagaCheckResult.GapVerdict> results = new ArrayList<>();
if (request == null || request.getMissionId() == null) {
return new SyntaxSagaCheckResult(false, results);
}
SyntaxSagaMission mission = missionRepository.findById(request.getMissionId()).orElse(null);
if (mission == null) {
return new SyntaxSagaCheckResult(false, results);
}
Map<Integer, String> typedByPosition = new HashMap<>();
if (request.getAnswers() != null) {
for (SyntaxSagaCheckRequest.GapAnswer answer : request.getAnswers()) {
typedByPosition.put(answer.getPosition(), answer.getText());
}
}
boolean allCorrect = true;
for (SyntaxSagaGap gap : mission.getGaps()) {
boolean correct = gap.matches(typedByPosition.get(gap.getPosition()));
if (!correct) {
allCorrect = false;
}
String solution = (request.isReveal() && !correct) ? gap.firstSolution() : null;
results.add(new SyntaxSagaCheckResult.GapVerdict(gap.getPosition(), correct, solution));
}
return new SyntaxSagaCheckResult(allCorrect, results);
}
/** Schließt einen Durchlauf ab: Score wird aus den tatsächlichen Eingaben recomputed und gespeichert. */
@Transactional
public SyntaxSagaResult finish(String username, SyntaxSagaFinishRequest request) {
int score = 0;
int maxScore = 0;
int solvedGaps = 0;
int totalGaps = 0;
Map<Long, SyntaxSagaMission> missionCache = new HashMap<>();
if (request.getGaps() != null) {
for (SyntaxSagaFinishRequest.FinishGap fg : request.getGaps()) {
if (fg.getMissionId() == null) {
continue;
}
SyntaxSagaMission mission = missionCache.computeIfAbsent(
fg.getMissionId(), id -> missionRepository.findById(id).orElse(null));
if (mission == null) {
continue;
}
SyntaxSagaGap gap = gapByPosition(mission, fg.getPosition());
if (gap == null) {
continue;
}
totalGaps++;
maxScore += gap.getPoints();
boolean correct = gap.matches(fg.getText());
if (correct && !fg.isRevealed()) {
solvedGaps++;
score += awardedPoints(gap.getPoints(), fg.getAttempts());
}
}
}
int stars = stars(score, maxScore);
int seconds = Math.max(request.getDurationSeconds(), 0);
String deck = normalizeOrNull(request.getDifficulty());
ScoreResult saved = gameScoreService.recordScore(
username, GAME, deck == null ? "all" : deck, score, solvedGaps, seconds);
return new SyntaxSagaResult(score, maxScore, solvedGaps, totalGaps, stars,
saved.getHighscore(), saved.isNewHighscore(), saved.getPlays());
}
private SyntaxSagaGap gapByPosition(SyntaxSagaMission mission, int position) {
for (SyntaxSagaGap gap : mission.getGaps()) {
if (gap.getPosition() == position) {
return gap;
}
}
return null;
}
/** Volle Punkte bei Erstversuch, weniger bei mehr Versuchen. */
private int awardedPoints(int points, int attempts) {
int a = Math.max(1, attempts);
double factor = switch (a) {
case 1 -> 1.0;
case 2 -> 0.6;
default -> 0.3;
};
return (int) Math.round(points * factor);
}
private int stars(int score, int maxScore) {
if (maxScore <= 0 || score <= 0) {
return 0;
}
double ratio = (double) score / maxScore;
if (ratio >= 0.8) {
return 3;
}
if (ratio >= 0.5) {
return 2;
}
return 1;
}
// ---------- Admin ----------
/** Leichte Übersicht: Missionen mit Lücken-Anzahl (lädt nicht alle Lücken). */
@Transactional(readOnly = true)
public List<SyntaxSagaMissionRow> missionRows() {
return missionRepository.findAllByOrderBySortOrderAscIdAsc().stream()
.map(m -> new SyntaxSagaMissionRow(m.getId(), m.getSlug(), m.getTitle(),
m.getDifficulty(), m.isActive(), gapRepository.countByMissionId(m.getId())))
.toList();
}
/** Eine Mission inkl. Lücken und Lösungen (für die Detailseite). */
@Transactional(readOnly = true)
public SyntaxSagaMission missionWithGaps(Long id) {
SyntaxSagaMission mission = missionRepository.findById(id).orElse(null);
if (mission != null) {
mission.getGaps().forEach(g -> g.getSolutions().size()); // lazy init
}
return mission;
}
@Transactional
public Long createMission(String title, String difficulty, String narrative, String dialog,
String language, String codeTemplate, String successText) {
if (title == null || title.isBlank() || codeTemplate == null || codeTemplate.isBlank()) {
return null;
}
String clean = title.trim();
int order = (int) missionRepository.count() + 1;
SyntaxSagaMission mission = new SyntaxSagaMission(
uniqueSlug(clean), clean, normalizeDifficulty(difficulty),
narrative == null ? "" : narrative.trim(),
blankToNull(dialog), blankToNull(language),
codeTemplate.trim(), blankToNull(successText), order, true);
missionRepository.save(mission);
return mission.getId();
}
@Transactional
public void updateMission(Long missionId, String title, String difficulty, String narrative,
String dialog, String language, String codeTemplate, String successText) {
missionRepository.findById(missionId).ifPresent(m -> {
if (title != null && !title.isBlank()) {
m.setTitle(title.trim());
}
m.setDifficulty(normalizeDifficulty(difficulty));
m.setNarrative(narrative == null ? "" : narrative.trim());
m.setDialog(blankToNull(dialog));
m.setLanguage(blankToNull(language));
if (codeTemplate != null && !codeTemplate.isBlank()) {
m.setCodeTemplate(codeTemplate.trim());
}
m.setSuccessText(blankToNull(successText));
});
}
@Transactional
public void addGap(Long missionId, int position, int points, boolean caseSensitive,
String hint, String placeholder, List<String> solutions) {
if (solutions == null) {
return;
}
missionRepository.findById(missionId).ifPresent(mission -> {
int pos = position > 0 ? position : mission.getGaps().size() + 1;
SyntaxSagaGap gap = new SyntaxSagaGap(
pos, normalizePoints(points), caseSensitive, blankToNull(hint), blankToNull(placeholder));
int sort = 0;
for (String s : solutions) {
if (s != null && !s.isBlank()) {
gap.addSolution(new SyntaxSagaSolution(s.trim(), sort++));
}
}
if (!gap.getSolutions().isEmpty()) {
mission.addGap(gap);
missionRepository.save(mission);
}
});
}
@Transactional
public void deleteGap(Long gapId) {
gapRepository.deleteById(gapId);
}
@Transactional
public void deleteMission(Long missionId) {
missionRepository.deleteById(missionId);
}
@Transactional
public void toggleMission(Long missionId) {
missionRepository.findById(missionId).ifPresent(m -> m.setActive(!m.isActive()));
}
// ---------- Helfer ----------
private int normalizePoints(int points) {
if (points <= 0) {
return 100;
}
return Math.min(1000, points);
}
private String normalizeDifficulty(String difficulty) {
if (difficulty == null) {
return "junior";
}
String x = difficulty.trim().toLowerCase();
return DIFFICULTIES.contains(x) ? x : "junior";
}
private String normalizeOrNull(String difficulty) {
if (difficulty == null || difficulty.isBlank()) {
return null;
}
String x = difficulty.trim().toLowerCase();
return DIFFICULTIES.contains(x) ? x : null;
}
private String blankToNull(String value) {
return (value == null || value.isBlank()) ? null : value.trim();
}
private String uniqueSlug(String title) {
String base = title.toLowerCase()
.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (base.isBlank()) {
base = "mission";
}
String slug = base;
int counter = 2;
while (missionRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
return slug;
}
}
@@ -0,0 +1,227 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.*;
import com.example.mindflux.model.TechtacularAnswer;
import com.example.mindflux.model.TechtacularCategory;
import com.example.mindflux.model.TechtacularQuestion;
import com.example.mindflux.repository.TechtacularCategoryRepository;
import com.example.mindflux.repository.TechtacularQuestionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class TechtacularService {
private static final String GAME = "techtacular";
private static final int BOARD_CATEGORIES = 5;
private static final int[] POINT_TIERS = {100, 200, 300, 400, 500};
private final TechtacularCategoryRepository categoryRepository;
private final TechtacularQuestionRepository questionRepository;
private final GameScoreService gameScoreService;
private final Random random = new Random();
public TechtacularService(TechtacularCategoryRepository categoryRepository,
TechtacularQuestionRepository questionRepository,
GameScoreService gameScoreService) {
this.categoryRepository = categoryRepository;
this.questionRepository = questionRepository;
this.gameScoreService = gameScoreService;
}
// ---------- Spieler ----------
/**
* Baut ein zufälliges Board: bis zu 5 Kategorien, je eine Frage pro Punktstufe (100–500).
* Fragen aus {@code exclude} (z.B. die Vorrunde) werden möglichst vermieden, damit jede
* Runde mit überwiegend neuen Fragen gefüllt wird.
*/
@Transactional(readOnly = true)
public TechtacularBoardView buildBoard(Set<Long> exclude) {
Set<Long> excluded = (exclude == null) ? Set.of() : exclude;
List<TechtacularCategory> usable = categoryRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()
.stream().filter(c -> !c.getQuestions().isEmpty()).collect(Collectors.toList());
Collections.shuffle(usable, random);
List<TechtacularCategory> chosen = usable.stream().limit(BOARD_CATEGORIES).toList();
List<TechtacularBoardView.Category> columns = new ArrayList<>();
int maxScore = 0;
for (TechtacularCategory category : chosen) {
Map<Integer, List<TechtacularQuestion>> byPoints = category.getQuestions().stream()
.collect(Collectors.groupingBy(TechtacularQuestion::getPoints));
List<TechtacularBoardView.Tile> tiles = new ArrayList<>();
for (int value : POINT_TIERS) {
List<TechtacularQuestion> pool = byPoints.get(value);
if (pool == null || pool.isEmpty()) {
continue;
}
// Bevorzugt Fragen, die nicht in der Vorrunde dran waren.
List<TechtacularQuestion> fresh = pool.stream()
.filter(q -> !excluded.contains(q.getId())).collect(Collectors.toList());
List<TechtacularQuestion> pick = fresh.isEmpty() ? pool : fresh;
TechtacularQuestion question = pick.get(random.nextInt(pick.size()));
List<TechtacularBoardView.Option> options = question.getAnswers().stream()
.map(a -> new TechtacularBoardView.Option(a.getId(), a.getText()))
.collect(Collectors.toList());
Collections.shuffle(options, random);
tiles.add(new TechtacularBoardView.Tile(question.getId(), value, question.getText(), options));
maxScore += value;
}
if (!tiles.isEmpty()) {
columns.add(new TechtacularBoardView.Category(category.getTitle(), tiles));
}
}
return new TechtacularBoardView(columns, maxScore);
}
/** Bewertet eine einzelne Antwort serverseitig (Frontend kennt die richtige Antwort vorher nicht). */
@Transactional(readOnly = true)
public AnswerResult checkAnswer(Long questionId, Long answerId) {
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
if (question == null) {
return new AnswerResult(false, null, "", 0);
}
TechtacularAnswer correct = question.correctAnswer();
Long correctId = correct != null ? correct.getId() : null;
boolean isCorrect = correctId != null && correctId.equals(answerId);
return new AnswerResult(isCorrect, correctId, question.getExplanation(), isCorrect ? question.getPoints() : 0);
}
/** Schließt eine Runde ab: Score wird aus den tatsächlichen Antworten recomputed und gespeichert. */
@Transactional
public TechtacularResult finish(String username, FinishRequest request) {
int score = 0;
int maxScore = 0;
int correctCount = 0;
int total = 0;
if (request.getAnswers() != null) {
for (AnswerRequest answer : request.getAnswers()) {
if (answer.getQuestionId() == null) {
continue;
}
TechtacularQuestion question = questionRepository.findById(answer.getQuestionId()).orElse(null);
if (question == null) {
continue;
}
total++;
maxScore += question.getPoints();
TechtacularAnswer correct = question.correctAnswer();
if (correct != null && correct.getId().equals(answer.getAnswerId())) {
score += question.getPoints();
correctCount++;
}
}
}
int seconds = Math.max(request.getDurationSeconds(), 0);
ScoreResult saved = gameScoreService.recordScore(username, GAME, null, score, correctCount, seconds);
return new TechtacularResult(score, maxScore, correctCount, total,
saved.getHighscore(), saved.isNewHighscore(), saved.getPlays());
}
// ---------- Admin ----------
/** Leichte Übersicht: Kategorien mit Fragenanzahl (lädt nicht alle Fragen). */
@Transactional(readOnly = true)
public List<AdminCategoryRow> categoryRows() {
return categoryRepository.findAllByOrderBySortOrderAscIdAsc().stream()
.map(c -> new AdminCategoryRow(c.getId(), c.getSlug(), c.getTitle(), c.isActive(),
questionRepository.countByCategoryId(c.getId())))
.toList();
}
/** Eine Kategorie inkl. Fragen und Antworten (für die Detailseite). */
@Transactional(readOnly = true)
public TechtacularCategory categoryWithQuestions(Long id) {
TechtacularCategory category = categoryRepository.findById(id).orElse(null);
if (category != null) {
category.getQuestions().forEach(q -> q.getAnswers().size()); // lazy init
}
return category;
}
@Transactional
public void createCategory(String title) {
if (title == null || title.isBlank()) {
return;
}
String clean = title.trim();
int order = (int) categoryRepository.count() + 1;
categoryRepository.save(new TechtacularCategory(uniqueSlug(clean), clean, order, true));
}
@Transactional
public void addQuestion(Long categoryId, String text, String explanation, int points,
List<String> answers, int correctIndex) {
if (text == null || text.isBlank() || answers == null) {
return;
}
categoryRepository.findById(categoryId).ifPresent(category -> {
TechtacularQuestion question = new TechtacularQuestion(
text.trim(),
(explanation == null || explanation.isBlank()) ? null : explanation.trim(),
normalizePoints(points));
int sort = 0;
boolean hasCorrect = false;
for (int i = 0; i < answers.size(); i++) {
String answerText = answers.get(i);
if (answerText == null || answerText.isBlank()) {
continue;
}
boolean isCorrect = (i == correctIndex);
hasCorrect = hasCorrect || isCorrect;
question.addAnswer(new TechtacularAnswer(answerText.trim(), isCorrect, sort++));
}
// Nur sinnvolle Fragen speichern: mind. 2 Antworten und genau die markierte ist korrekt.
if (question.getAnswers().size() >= 2 && hasCorrect) {
category.addQuestion(question);
categoryRepository.save(category);
}
});
}
@Transactional
public void deleteQuestion(Long questionId) {
questionRepository.deleteById(questionId);
}
@Transactional
public void deleteCategory(Long categoryId) {
categoryRepository.deleteById(categoryId);
}
@Transactional
public void toggleCategory(Long categoryId) {
categoryRepository.findById(categoryId).ifPresent(c -> c.setActive(!c.isActive()));
}
private int normalizePoints(int points) {
int rounded = Math.round(points / 100f) * 100;
return Math.max(100, Math.min(500, rounded));
}
private String uniqueSlug(String title) {
String base = title.toLowerCase()
.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (base.isBlank()) {
base = "kategorie";
}
String slug = base;
int counter = 2;
while (categoryRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
return slug;
}
}
@@ -0,0 +1,9 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
public interface UserService {
User findByUsername(String username);
void save(User user);
void deleteByUsername(String username); // <-- Nur die Methodensignatur, ohne {}
}
@@ -0,0 +1,43 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.AchievementRepository;
import com.example.mindflux.repository.GameScoreRepository;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private GameScoreRepository gameScoreRepository;
@Autowired
private AchievementRepository achievementRepository;
@Override
public User findByUsername(String username) {
return userRepository.findByUsername(username).orElse(null); // <-- Optional aufgelöst
}
@Override
public void save(User user) {
userRepository.save(user);
}
@Override
@Transactional
public void deleteByUsername(String username) {
// Zugehörige Daten mitlöschen (kein FK/Cascade -> manuell), damit keine
// verwaisten Datensätze zurückbleiben und ein neuer gleichnamiger Account
// keine Alt-Daten erbt.
gameScoreRepository.deleteByUsername(username);
achievementRepository.deleteByUsername(username);
userRepository.deleteByUsername(username);
}
}
@@ -1,19 +0,0 @@
package com.mindflux;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HelloController {
@GetMapping("/")
public Map<String, String> hello() {
return Map.of(
"app", "mindflux",
"status", "running",
"message", "Hello from Spring Boot 🚀, automatic deploy is working. :-)"
);
}
}
+48 -5
View File
@@ -1,8 +1,51 @@
server.port=8080
# =======================
# Datenbankverbindung (PostgreSQL)
# =======================
spring.datasource.url=jdbc:postgresql://postgres:5432/mindfluxdb
spring.datasource.username=mindflux
spring.datasource.password=mindflux_pass
spring.config.import=optional:file:.env[.properties]
spring.datasource.url=${MINDFLUX_DB_URL:jdbc:postgresql://localhost:5433/mindflux_db}
spring.datasource.username=${MINDFLUX_DB_USER:mindflux_user}
spring.datasource.password=${MINDFLUX_DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
# =======================
# Hibernate / JPA
# =======================
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.jpa.show-sql=false
spring.jpa.open-in-view=false
# =======================
# Datenbank-Migrationen
# =======================
spring.flyway.enabled=true
# =======================
# SQL-Initialisierung
# =======================
spring.sql.init.mode=never
# =======================
# Server
# =======================
server.port=8282
server.forward-headers-strategy=framework
server.servlet.session.cookie.secure=${MINDFLUX_COOKIE_SECURE:false}
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.same-site=lax
spring.output.ansi.enabled=ALWAYS
# Öffentliche Basis-URL, z. B. für künftig erzeugte absolute Links.
mindflux.public-url=${MINDFLUX_PUBLIC_URL:http://localhost:8282}
# =======================
# Datei-Uploads (Profilbilder)
# =======================
# Hinweis: Der nginx-Reverse-Proxy muss client_max_body_size passend gesetzt haben,
# sonst greift schon dort ein 413, bevor die App den Upload sieht.
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
@@ -16,5 +16,4 @@ CREATE TABLE achievements (
date_achieved TIMESTAMP
);
CREATE INDEX idx_achievements_username
ON achievements (username);
CREATE INDEX idx_achievements_username ON achievements (username);
@@ -0,0 +1,14 @@
-- Generische Spiel-Ergebnistabelle für alle Mindflux-Spiele
-- (MindMatch ist das erste Spiel, das hier schreibt – die übrigen docken später an).
CREATE TABLE game_scores (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username VARCHAR(100) NOT NULL,
game VARCHAR(50) NOT NULL,
deck VARCHAR(100),
score INT NOT NULL,
moves INT,
duration_seconds INT,
played_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_game_scores_user_game ON game_scores (username, game);
@@ -0,0 +1,68 @@
-- MindMatch-Inhalte in die DB verlagern, damit Admins Decks (Kategorien) und
-- Paare jederzeit zur Laufzeit erweitern können (vorher statische JSON-Dateien).
CREATE TABLE mindmatch_decks (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
title VARCHAR(150) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE mindmatch_pairs (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
deck_id BIGINT NOT NULL REFERENCES mindmatch_decks (id) ON DELETE CASCADE,
term VARCHAR(255) NOT NULL,
match_text VARCHAR(255) NOT NULL
);
CREATE INDEX idx_mindmatch_pairs_deck ON mindmatch_pairs (deck_id);
-- Seed: die bisherigen 3 Decks plus das neue SQL-Befehle-Deck.
INSERT INTO mindmatch_decks (slug, title, sort_order, active) VALUES
('netzwerk', 'Netzwerk-Grundlagen', 1, TRUE),
('hardware', 'Hardware & Komponenten', 2, TRUE),
('it-sicherheit', 'IT-Sicherheit', 3, TRUE),
('sql-befehle', 'SQL-Befehle', 4, TRUE);
INSERT INTO mindmatch_pairs (deck_id, term, match_text)
SELECT d.id, v.term, v.match_text
FROM mindmatch_decks d
JOIN (
VALUES
('netzwerk', 'DNS', 'Löst Namen in IP-Adressen auf'),
('netzwerk', 'DHCP', 'Vergibt IP-Adressen automatisch'),
('netzwerk', 'Router', 'Verbindet verschiedene Netze'),
('netzwerk', 'Switch', 'Verbindet Geräte im LAN'),
('netzwerk', 'Gateway', 'Übergang in ein anderes Netz'),
('netzwerk', 'Firewall', 'Filtert den Netzwerkverkehr'),
('netzwerk', 'MAC-Adresse', 'Physische Hardware-Adresse'),
('netzwerk', 'Subnetzmaske', 'Trennt Netz- und Hostanteil'),
('hardware', 'CPU', 'Zentrale Recheneinheit'),
('hardware', 'RAM', 'Flüchtiger Arbeitsspeicher'),
('hardware', 'SSD', 'Schneller Flash-Speicher'),
('hardware', 'GPU', 'Grafikprozessor'),
('hardware', 'Mainboard', 'Hauptplatine des Rechners'),
('hardware', 'BIOS', 'Startet die Hardware'),
('hardware', 'Netzteil', 'Wandelt die Netzspannung um'),
('hardware', 'Cache', 'Schneller Zwischenspeicher der CPU'),
('it-sicherheit', 'Phishing', 'Täuschung zum Datenklau'),
('it-sicherheit', 'Verschlüsselung', 'Macht Daten unlesbar'),
('it-sicherheit', 'Backup', 'Sicherungskopie von Daten'),
('it-sicherheit', 'VPN', 'Verschlüsselter Tunnel'),
('it-sicherheit', '2FA', 'Zwei-Faktor-Anmeldung'),
('it-sicherheit', 'Malware', 'Schädliche Software'),
('it-sicherheit', 'Patch', 'Schließt Sicherheitslücken'),
('it-sicherheit', 'Brute Force', 'Systematisches Passwortraten'),
('sql-befehle', 'SELECT', 'Daten abfragen'),
('sql-befehle', 'INSERT', 'Datensatz einfügen'),
('sql-befehle', 'UPDATE', 'Datensatz ändern'),
('sql-befehle', 'DELETE', 'Datensatz löschen'),
('sql-befehle', 'WHERE', 'Zeilen filtern'),
('sql-befehle', 'JOIN', 'Tabellen verknüpfen'),
('sql-befehle', 'GROUP BY', 'Zeilen gruppieren'),
('sql-befehle', 'ORDER BY', 'Ergebnis sortieren')
) AS v(slug, term, match_text) ON v.slug = d.slug;
@@ -0,0 +1,152 @@
-- Weitere MindMatch-Decks aus dem Dokument "Memory-Kategorien – Fachinformatiker".
-- 5 Themen-Decks + die Oberkategorie "Abkürzungen" als 6 eigene Decks (Deck-Modell ist flach).
INSERT INTO mindmatch_decks (slug, title, sort_order, active) VALUES
('betriebssysteme', 'Betriebssysteme', 5, TRUE),
('programmierung', 'Programmierung', 6, TRUE),
('webentwicklung', 'Webentwicklung', 7, TRUE),
('softwareentwicklung', 'Softwareentwicklung', 8, TRUE),
('cloud-devops', 'Cloud & DevOps', 9, TRUE),
('abk-netzwerk', 'Abkürzungen: Netzwerk', 10, TRUE),
('abk-hardware', 'Abkürzungen: Hardware', 11, TRUE),
('abk-programmierung-datenformate','Abkürzungen: Programmierung & Datenformate', 12, TRUE),
('abk-web', 'Abkürzungen: Web', 13, TRUE),
('abk-it-sicherheit', 'Abkürzungen: IT-Sicherheit', 14, TRUE),
('abk-betriebssysteme-devops', 'Abkürzungen: Betriebssysteme & DevOps', 15, TRUE);
INSERT INTO mindmatch_pairs (deck_id, term, match_text)
SELECT d.id, v.term, v.match_text
FROM mindmatch_decks d
JOIN (
VALUES
-- Betriebssysteme
('betriebssysteme', 'Windows', 'Microsoft'),
('betriebssysteme', 'Linux', 'Open Source'),
('betriebssysteme', 'Kernel', 'Betriebssystemkern'),
('betriebssysteme', 'Prozess', 'Laufendes Programm'),
('betriebssysteme', 'Thread', 'Ausführungsstrang'),
('betriebssysteme', 'Dateisystem', 'Datenorganisation'),
('betriebssysteme', 'NTFS', 'Windows-Dateisystem'),
('betriebssysteme', 'ext4', 'Linux-Dateisystem'),
('betriebssysteme', 'Shell', 'Kommandozeile'),
('betriebssysteme', 'Terminal', 'Konsolenzugriff'),
('betriebssysteme', 'Dienst', 'Hintergrundprozess'),
('betriebssysteme', 'Rechte', 'Zugriffskontrolle'),
-- Programmierung
('programmierung', 'Variable', 'Speicherplatz'),
('programmierung', 'Schleife', 'Wiederholung'),
('programmierung', 'Array', 'Feld'),
('programmierung', 'Objekt', 'Instanz'),
('programmierung', 'Klasse', 'Bauplan'),
('programmierung', 'Methode', 'Funktion'),
('programmierung', 'Interface', 'Vertrag'),
('programmierung', 'Vererbung', 'OOP-Konzept'),
('programmierung', 'Exception', 'Fehlerbehandlung'),
('programmierung', 'Compiler', 'Übersetzer'),
('programmierung', 'Debugger', 'Fehlersuche'),
('programmierung', 'Git', 'Versionsverwaltung'),
-- Webentwicklung
('webentwicklung', 'HTML', 'Struktur'),
('webentwicklung', 'CSS', 'Gestaltung'),
('webentwicklung', 'JavaScript', 'Interaktivität'),
('webentwicklung', 'DOM', 'Dokumentenmodell'),
('webentwicklung', 'API', 'Programmierschnittstelle'),
('webentwicklung', 'JSON', 'Datenaustausch'),
('webentwicklung', 'REST', 'Architekturstil'),
('webentwicklung', 'HTTP', 'Übertragungsprotokoll'),
('webentwicklung', 'Cookie', 'Browserdaten'),
('webentwicklung', 'Session', 'Benutzersitzung'),
('webentwicklung', 'Backend', 'Serverlogik'),
('webentwicklung', 'Frontend', 'Benutzeroberfläche'),
-- Softwareentwicklung
('softwareentwicklung', 'Scrum', 'Agiles Vorgehen'),
('softwareentwicklung', 'Sprint', 'Entwicklungszyklus'),
('softwareentwicklung', 'Product Owner', 'Produktverantwortung'),
('softwareentwicklung', 'Kanban', 'Aufgabenfluss'),
('softwareentwicklung', 'UML', 'Modellierung'),
('softwareentwicklung', 'Use Case', 'Anwendungsfall'),
('softwareentwicklung', 'ER-Modell', 'Datenmodell'),
('softwareentwicklung', 'Testfall', 'Prüfung'),
('softwareentwicklung', 'CI/CD', 'Automatisierte Auslieferung'),
('softwareentwicklung', 'Refactoring', 'Codeverbesserung'),
('softwareentwicklung', 'Repository', 'Quellcodeablage'),
('softwareentwicklung', 'Merge', 'Zusammenführen'),
-- Cloud & DevOps
('cloud-devops', 'Docker', 'Container'),
('cloud-devops', 'Kubernetes', 'Container-Orchestrierung'),
('cloud-devops', 'VM', 'Virtuelle Maschine'),
('cloud-devops', 'Cloud', 'Ressourcen aus dem Internet'),
('cloud-devops', 'Azure', 'Microsoft Cloud'),
('cloud-devops', 'AWS', 'Amazon Web Services'),
('cloud-devops', 'GitHub', 'Codehosting'),
('cloud-devops', 'Jenkins', 'CI-Server'),
('cloud-devops', 'Pipeline', 'Automatisierung'),
('cloud-devops', 'YAML', 'Konfigurationsdatei'),
('cloud-devops', 'Deployment', 'Bereitstellung'),
('cloud-devops', 'Monitoring', 'Überwachung'),
-- Abkürzungen: Netzwerk
('abk-netzwerk', 'DNS', 'Domain Name System'),
('abk-netzwerk', 'DHCP', 'Dynamic Host Configuration Protocol'),
('abk-netzwerk', 'IP', 'Internet Protocol'),
('abk-netzwerk', 'TCP', 'Transmission Control Protocol'),
('abk-netzwerk', 'UDP', 'User Datagram Protocol'),
('abk-netzwerk', 'FTP', 'File Transfer Protocol'),
('abk-netzwerk', 'SSH', 'Secure Shell'),
('abk-netzwerk', 'VPN', 'Virtual Private Network'),
-- Abkürzungen: Hardware
('abk-hardware', 'CPU', 'Central Processing Unit'),
('abk-hardware', 'GPU', 'Graphics Processing Unit'),
('abk-hardware', 'RAM', 'Random Access Memory'),
('abk-hardware', 'ROM', 'Read Only Memory'),
('abk-hardware', 'SSD', 'Solid State Drive'),
('abk-hardware', 'HDD', 'Hard Disk Drive'),
('abk-hardware', 'PSU', 'Power Supply Unit'),
('abk-hardware', 'BIOS', 'Basic Input/Output System'),
('abk-hardware', 'UEFI', 'Unified Extensible Firmware Interface'),
('abk-hardware', 'PCIe', 'Peripheral Component Interconnect Express'),
-- Abkürzungen: Programmierung & Datenformate
('abk-programmierung-datenformate', 'SQL', 'Structured Query Language'),
('abk-programmierung-datenformate', 'IDE', 'Integrated Development Environment'),
('abk-programmierung-datenformate', 'SDK', 'Software Development Kit'),
('abk-programmierung-datenformate', 'OOP', 'Object Oriented Programming'),
('abk-programmierung-datenformate', 'CRUD', 'Create Read Update Delete'),
('abk-programmierung-datenformate', 'MVC', 'Model View Controller'),
('abk-programmierung-datenformate', 'ORM', 'Object Relational Mapping'),
('abk-programmierung-datenformate', 'XML', 'Extensible Markup Language'),
-- Abkürzungen: Web
('abk-web', 'URL', 'Uniform Resource Locator'),
('abk-web', 'URI', 'Uniform Resource Identifier'),
('abk-web', 'CDN', 'Content Delivery Network'),
('abk-web', 'SOAP', 'Simple Object Access Protocol'),
('abk-web', 'JWT', 'JSON Web Token'),
('abk-web', 'CORS', 'Cross Origin Resource Sharing'),
-- Abkürzungen: IT-Sicherheit
('abk-it-sicherheit', 'MFA', 'Multi Factor Authentication'),
('abk-it-sicherheit', '2FA', 'Two Factor Authentication'),
('abk-it-sicherheit', 'TLS', 'Transport Layer Security'),
('abk-it-sicherheit', 'AES', 'Advanced Encryption Standard'),
('abk-it-sicherheit', 'RSA', 'Rivest Shamir Adleman'),
('abk-it-sicherheit', 'PKI', 'Public Key Infrastructure'),
('abk-it-sicherheit', 'IDS', 'Intrusion Detection System'),
('abk-it-sicherheit', 'IPS', 'Intrusion Prevention System'),
('abk-it-sicherheit', 'SIEM', 'Security Information and Event Management'),
('abk-it-sicherheit', 'CVE', 'Common Vulnerabilities and Exposures'),
-- Abkürzungen: Betriebssysteme & DevOps
('abk-betriebssysteme-devops', 'CLI', 'Command Line Interface'),
('abk-betriebssysteme-devops', 'GUI', 'Graphical User Interface'),
('abk-betriebssysteme-devops', 'VM', 'Virtual Machine'),
('abk-betriebssysteme-devops', 'CI', 'Continuous Integration'),
('abk-betriebssysteme-devops', 'CD', 'Continuous Delivery'),
('abk-betriebssysteme-devops', 'LTS', 'Long Term Support'),
('abk-betriebssysteme-devops', 'FQDN', 'Fully Qualified Domain Name')
) AS v(slug, term, match_text) ON v.slug = d.slug;
@@ -0,0 +1,30 @@
-- TechTacular: IT-Quizduell im Jeopardy-Stil. Inhalte DB-basiert und Admin-pflegbar.
-- Kategorie -> Fragen -> Antworten (genau eine korrekt). Gefüllt wird per Seeder
-- aus den vorhandenen FinalFlux-Fragenkatalogen (siehe TechtacularSeeder).
CREATE TABLE techtacular_categories (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
slug VARCHAR(150) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE techtacular_questions (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
category_id BIGINT NOT NULL REFERENCES techtacular_categories (id) ON DELETE CASCADE,
question_text VARCHAR(1000) NOT NULL,
explanation VARCHAR(1000),
points INT NOT NULL DEFAULT 100
);
CREATE TABLE techtacular_answers (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES techtacular_questions (id) ON DELETE CASCADE,
answer_text VARCHAR(500) NOT NULL,
correct BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX idx_techtacular_questions_cat ON techtacular_questions (category_id);
CREATE INDEX idx_techtacular_answers_q ON techtacular_answers (question_id);
@@ -0,0 +1,4 @@
-- Umstellung auf das echte Jeopardy-Format (umgekehrt) mit kuratierten Inhalten.
-- Der bisherige (templatehafte) FinalFlux-Import wird geleert; der TechtacularSeeder
-- befüllt anschließend neu aus techtacular/content.json.
TRUNCATE TABLE techtacular_categories RESTART IDENTITY CASCADE;

Some files were not shown because too many files have changed in this diff Show More