Compare commits
20 Commits
b674178771
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e51fd8847 | |||
| 14fb0dcbe9 | |||
| 98543979db | |||
| 039d2cbbf4 | |||
| 9e8af27d51 | |||
| c998242cc6 | |||
| e7b4ec571e | |||
| 712f9a243c | |||
| 8154f08e04 | |||
| acfd57ee87 | |||
| e7dbb9d0a7 | |||
| 6b31fddb27 | |||
| 1b44e004bd | |||
| 75bb75853e | |||
| 49f9953342 | |||
| a0572c928b | |||
| f299769ee9 | |||
| ea3a309b2d | |||
| 025b677257 | |||
| f86f42526f |
@@ -1 +1 @@
|
|||||||
VITE_API_URL=https://api.hejyou.com
|
VITE_API_URL=https://hyggecraftery.com/api/snakkimo
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,3 +4,9 @@ dist/
|
|||||||
.env.local
|
.env.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# iOS build artifacts (Capacitor)
|
||||||
|
ios/build/
|
||||||
|
ios/App/Pods/
|
||||||
|
ios/App/output/
|
||||||
|
ios/DerivedData/
|
||||||
|
|||||||
83
CLAUDE.md
Normal file
83
CLAUDE.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Vite dev server (default http://localhost:5173)
|
||||||
|
npm run build # Production build → dist/
|
||||||
|
npm run preview # Serve the built dist/ locally
|
||||||
|
```
|
||||||
|
|
||||||
|
There is **no test runner and no linter** configured — `package.json` only defines `dev`, `build`, `preview`. Don't suggest `npm test`/`npm run lint`.
|
||||||
|
|
||||||
|
Deployment is a two-stage Docker build (`Dockerfile`): Node builds `dist/`, then it's served by nginx (`nginx.conf`) with SPA fallback (`try_files … /index.html`).
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
`VITE_API_URL` (in `.env` / `.env.production`) points at the backend. It's the **only** runtime config — every API call in `src/api/directus.js` reads `import.meta.env.VITE_API_URL`. `.env` is gitignored.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
React 19 + Vite SPA, **no router package**. There are no DB cards/feed entries fetched directly — the app talks to a custom backend, not Directus directly (see below).
|
||||||
|
|
||||||
|
**Routing** is `useState`-based in `src/App.jsx`: a `PAGES` map (`feed`/`game`/`pro`/`profil`) swaps the active page component, driven by `BottomNav`. `Game` and `Pro` are placeholders.
|
||||||
|
|
||||||
|
**Auth gating:** `App.jsx` renders `<AuthScreen />` unless the user has `username`, `language_native_id`, and `language_target_id`. `AuthContext` (`src/context/AuthContext.jsx`) holds the JWT (localStorage key **`hejyou_token`**), calls `getMe()` on mount, and clears the token on failure. Registration is two steps: `RegisterStep1` (email+password) → `RegisterStep2` (username + native/target language).
|
||||||
|
|
||||||
|
### The API client is misnamed
|
||||||
|
|
||||||
|
`src/api/directus.js` is **not** a Directus client anymore — the module name is kept "aus historischen Gründen". It is a thin fetch wrapper around a **custom backend (snakkimo-API)** exposing `/auth/*` endpoints:
|
||||||
|
|
||||||
|
| Function | Endpoint | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `login` / `registerUser` | `POST /auth/login`, `/auth/register` | return `{ token, userId, needsProfile }` |
|
||||||
|
| `getMe` | `GET /auth/me` | auth check + progress: `total_ep`, `streak_days`, `level`, `ep_into_level`, `ep_to_next_level`, `last_practice_at`; also `language_target_greeting` (Profil-Anrede „Hej, …") |
|
||||||
|
| `checkUsername` | `GET /auth/check-username` | |
|
||||||
|
| `createProfile` | `POST /auth/profile` | username + native/target lang |
|
||||||
|
| `getLanguageOptions` | `GET /auth/languages` | merged with local `LANG_META` (flag + Web Speech code) |
|
||||||
|
| `getFeedPairs` | `GET /auth/feed?lang=&limit=&exclude=` | returns "pairs", the feed unit |
|
||||||
|
| `saveProgress` | `POST /auth/progress` | books EP/streak; returns the **milestone contract** (see below) |
|
||||||
|
| `getStats` | `GET /auth/stats` | Profil-Daten: `daily`/`today`/`totals`/`skills` + `categories[]` (Punkte je Kategorie) |
|
||||||
|
| `getAchievements` | `GET /auth/achievements` | `[{ key, label, icon, unlocked, unlocked_at }]` für die Profil-Sektion |
|
||||||
|
| `setDailyGoal` | `PUT /auth/goal` | Tagesziel (EP/Tag) setzen; Backend klemmt auf 5–500 |
|
||||||
|
|
||||||
|
`saveProgress` returns `{ total_ep, level, prev_level, streak_days, streak_increased, daily_ep, daily_goal_ep, goal_just_reached, unlocked_achievements }` — `Feed.jsx` leitet daraus die Feier-Momente ab (Level-Up/Streak/Tagesziel/Achievement). Felder degradieren defensiv: fehlen sie (älteres Backend), greifen lokale Fallbacks.
|
||||||
|
|
||||||
|
`src/pages/Profil.jsx` rendert die Begrüßung (`language_target_greeting`), **führt mit Momentum** (`% bis Level X` + Capability-Satz), zeigt Kategorie-Stufen (`stats.categories` + `categoryTier`), Wochenvergleich, Streak-Status, Erfolge-Grid (`getAchievements`) und einen Sound-Toggle.
|
||||||
|
|
||||||
|
### Fortschritts-/Feier-System (Momente)
|
||||||
|
|
||||||
|
Macht Fortschritt spürbar statt nur zählbar. Bausteine:
|
||||||
|
|
||||||
|
- **`src/utils/leveling.js`** — spiegelt die Backend-Level-Kurve (`levelForEp`/`levelInfo`, Level 1 bei 20 EP). Backend ist Single Source of Truth; das ist Fallback + %-Anzeige.
|
||||||
|
- **`MilestoneOverlay`** (`components/`) — Vollbild-Feier, getriggert aus der `saveProgress`-Response. Typen: `level` / `streak` (Schwellen 3/7/14/30/50/100/200/365) / `goal` / `achievement`. Konfetti via `utils/confetti.js`.
|
||||||
|
- **`EpFloat`** — „+N EP" schwebt am Bestätigen-Button auf; EP-Badge zählt hoch (`hooks/useCountUp.js`).
|
||||||
|
- **`SessionSummary`** — ersetzt die End-Sackgasse mit Zahlen + Story-Zeilen.
|
||||||
|
- **Combo** + variables Lob/ermutigendes Fehler-Feedback (`utils/praise.js`, dort auch `categoryTier`/`capabilitySentence`).
|
||||||
|
- **`utils/sound.js`** — dezente WebAudio-Belohnung (Mute-Pref in localStorage).
|
||||||
|
- **`utils/streak.js`** + **`utils/streakReminder.js`** — Loss-Aversion-Nudge im Feed („Serie endet in X Std") und lokale Tages-Erinnerung via **`@capacitor/local-notifications`** (kein APNs nötig; nur nativ, web no-op — braucht `npx cap sync ios`).
|
||||||
|
|
||||||
|
Several content functions (`getWords`, `getQuestions`, `getActiveLearningPair`, `assetUrl`, …) are **stubs** returning empty/null — content endpoints are not built yet. Don't assume they fetch anything.
|
||||||
|
|
||||||
|
### Feed = "pairs" → cards
|
||||||
|
|
||||||
|
`src/pages/Feed.jsx` fetches an array of *pairs* and maps each to one card by its `answer_type`:
|
||||||
|
|
||||||
|
| `answer_type` | Component | Points (`POINTS` map) |
|
||||||
|
|---|---|---|
|
||||||
|
| `text` | `PairSentenceCard` | 2 |
|
||||||
|
| `yes_no` | `PairYesNoCard` | 2 |
|
||||||
|
| `word` | `PairWordCard` | 3 |
|
||||||
|
| `question` | `PairWordCard` | 3 |
|
||||||
|
|
||||||
|
On completion, `handleComplete` adds the pair id to a local `done` set (cards are hidden, not removed) and POSTs to `saveProgress`. Target language comes from `user.language_target_short` (fallback `de`).
|
||||||
|
|
||||||
|
The **`Pair*` cards** (`PairSentenceCard`, `PairYesNoCard`, `PairWordCard`) are the live card components. The other card files (`NewWord*`, `ImagePick/ImageQuiz`, `AudioQuiz`, `LetterOrder`, `SentenceFill`, `LanguageParentCard`) are an **earlier card model** not wired into the current feed — check `Feed.jsx` before assuming a component is in use.
|
||||||
|
|
||||||
|
Several cards use the browser **Web Speech API** (`SpeechRecognition` / `speechSynthesis`) for the `speak`/`listen` flow; `src/utils/confetti.js` wraps `canvas-confetti` for correct-answer feedback.
|
||||||
|
|
||||||
|
## `knowledge/directus_struktur.md`
|
||||||
|
|
||||||
|
A detailed reference for the **backend data model** (collections, fields, relations) and the original app concept. Useful for understanding the domain and DB schema, but note it predates the custom-backend migration: its "Frontend API-Funktionen" table describes **direct Directus calls that no longer exist** — trust `src/api/directus.js` for the actual frontend contract.
|
||||||
200
FUNKTIONSUEBERSICHT.md
Normal file
200
FUNKTIONSUEBERSICHT.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Funktionsübersicht — Snakkimo iOS-App
|
||||||
|
|
||||||
|
> **Zweck:** Vollständige Liste aller Funktionen der **iOS-App** (Endnutzer-App) zur Funktionsüberprüfung durch das Team.
|
||||||
|
> Jede Funktion hat eine **Soll-Beschreibung** (so muss es sich verhalten) und eine abhakbare Checkbox.
|
||||||
|
> Backend-/Admin-Tools (CMT, Content-Pipeline) sind **nicht** Teil dieses Dokuments.
|
||||||
|
|
||||||
|
**Technischer Rahmen**
|
||||||
|
- iOS-App = **Capacitor-Wrapper** um eine React-19/Vite-Single-Page-App (`webDir: dist`).
|
||||||
|
- Bundle-ID: `com.hyggecraftery.hejyou` · App-Name (iOS-Homescreen): **Snakkimo**.
|
||||||
|
- Alle Inhalte/Logins kommen live vom Backend (`VITE_API_URL`) — **es gibt keinen Offline-Modus**, ohne Netz funktioniert die App nicht.
|
||||||
|
- Aktiv unterstützte Sprachen: **Deutsch 🇩🇪, Englisch 🇬🇧, Schwedisch 🇸🇪**.
|
||||||
|
|
||||||
|
> ⚠️ **Branding-Inkonsistenz (für QA wichtig):** Auf dem Login-Screen steht **„HejYou"**, der iOS-App-Name ist **„Snakkimo"**, der Browser-/Webview-Titel ist **„Language App"**. Bitte als bekannten Punkt behandeln, nicht als neuen Bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Allgemeiner Rahmen
|
||||||
|
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.1 | App-Start / Ladezustand | Beim Start erscheint ein Spinner, bis Profil geladen ist. | ☐ |
|
||||||
|
| 1.2 | Auth-Gating | Ohne gültiges Profil (Username + Mutter- + Zielsprache) erscheint immer der Auth-Screen. | ☐ |
|
||||||
|
| 1.3 | Session-Persistenz | Login bleibt nach App-Neustart erhalten (Token in `localStorage: hejyou_token`). | ☐ |
|
||||||
|
| 1.4 | Auto-Logout bei ungültigem Token | Ist der Token abgelaufen/ungültig, wird er verworfen und der Auth-Screen erscheint. | ☐ |
|
||||||
|
| 1.5 | Untere Navigation (BottomNav) | 4 Tabs: **Feed · Game · Pro · Profil**. Aktiver Tab ist hervorgehoben. | ☐ |
|
||||||
|
| 1.6 | Seitenwechsel-Animation | Beim Tab-Wechsel spielt eine kurze Einblend-Animation (`page-enter`). | ☐ |
|
||||||
|
| 1.7 | Safe-Area / Notch | Layout respektiert iPhone-Notch/Home-Indicator (`viewport-fit=cover`). | ☐ |
|
||||||
|
| 1.8 | Design/Theme | Warmes Creme-/Gold-Design, durchgängige Schrift- und Farb-Tokens. | ☐ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Onboarding & Authentifizierung
|
||||||
|
|
||||||
|
### 2.1 Login (Tab „Anmelden")
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.1.1 | Login/Register-Umschalter | Oben umschaltbar zwischen „Anmelden" und „Registrieren"; letzte Wahl wird gemerkt (`hejyou_last_mode`). | ☐ |
|
||||||
|
| 2.1.2 | Eingabe E-Mail + Passwort | Beide Felder Pflicht; leeres Feld → Fehlermeldung „Bitte E-Mail und Passwort eingeben.". | ☐ |
|
||||||
|
| 2.1.3 | Erfolgreicher Login | Token wird gespeichert, Profil geladen, App öffnet den Feed. | ☐ |
|
||||||
|
| 2.1.4 | Falsche Zugangsdaten | Fehlermeldung vom Server wird angezeigt (z. B. „Invalid credentials"). | ☐ |
|
||||||
|
| 2.1.5 | Login eines Accounts ohne Profil | Springt direkt in **Profil-Schritt (Step 2)** statt in den Feed. | ☐ |
|
||||||
|
| 2.1.6 | Lade-Status | Button zeigt „Anmelden…" und ist während des Requests deaktiviert. | ☐ |
|
||||||
|
|
||||||
|
### 2.2 Registrierung — Schritt 1 (E-Mail + Passwort)
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.2.1 | Schritt-Anzeige | Zeigt Fortschrittspunkte „Schritt 1 von 2". | ☐ |
|
||||||
|
| 2.2.2 | Passwort-Mindestlänge | Passwort < 8 Zeichen → „Passwort muss mindestens 8 Zeichen haben.". | ☐ |
|
||||||
|
| 2.2.3 | E-Mail bereits vergeben | Server-Fehler „Email already registered" wird angezeigt. | ☐ |
|
||||||
|
| 2.2.4 | Erfolg | Weiter zu Schritt 2 (Profil). | ☐ |
|
||||||
|
|
||||||
|
### 2.3 Registrierung — Schritt 2 (Profil)
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.3.1 | Username-Regeln | 3–20 Zeichen, nur Buchstaben/Zahlen/Unterstrich; Verstoß → klare Fehlermeldung. | ☐ |
|
||||||
|
| 2.3.2 | Username-Verfügbarkeit | Vergebener Username → „Dieser Username ist bereits vergeben.". | ☐ |
|
||||||
|
| 2.3.3 | Muttersprache wählen | Dropdown mit Flaggen + Sprachnamen (vom Server geladen). | ☐ |
|
||||||
|
| 2.3.4 | Zielsprache wählen | Dropdown; **die gewählte Muttersprache ist hier ausgeblendet**. | ☐ |
|
||||||
|
| 2.3.5 | Gleiche Sprachen verhindern | Mutter- = Zielsprache → „… dürfen nicht gleich sein.". | ☐ |
|
||||||
|
| 2.3.6 | Profil anlegen | Bei Erfolg: Token persistiert, Profil geladen, Erfolgs-Screen erscheint. | ☐ |
|
||||||
|
| 2.3.7 | Sprachen-Ladefehler | Können Sprachen nicht geladen werden, ist der Button deaktiviert + Hinweis. | ☐ |
|
||||||
|
|
||||||
|
### 2.4 Erfolgs-Screen & Logout
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.4.1 | Willkommens-Screen | Nach Registrierung: „Willkommen, {Username}!" mit Häkchen-Icon. | ☐ |
|
||||||
|
| 2.4.2 | Logout | Über das Logout-Icon im Profil; Token wird gelöscht, zurück zum Auth-Screen. | ☐ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Feed (Kernbereich „Lernen")
|
||||||
|
|
||||||
|
### 3.1 Feed-Grundgerüst
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.1.1 | Karten laden | Beim Öffnen werden bis zu 20 Lernkarten in der **Zielsprache** geladen. | ☐ |
|
||||||
|
| 3.1.2 | EP-Abzeichen oben | Zeigt Fortschritts-Ring (Tagesziel-%), Gesamt-EP und „X/Y heute". | ☐ |
|
||||||
|
| 3.1.3 | Karte abschließen | Nach Lösen verschwindet die Karte aus der Liste (bis App-Neustart). | ☐ |
|
||||||
|
| 3.1.4 | Fortschritt speichern | Jede gelöste Karte sendet EP/Richtig-Falsch an den Server (EP/Streak aktualisiert sich). | ☐ |
|
||||||
|
| 3.1.5 | Ladezustand | „Lade Karten…" während des Ladens. | ☐ |
|
||||||
|
| 3.1.6 | Leerer Feed | Keine Inhalte → „Noch keine Inhalte verfügbar.". | ☐ |
|
||||||
|
| 3.1.7 | Alles gelöst | Alle Karten erledigt → „Super! Alle Karten abgeschlossen. 🎉". | ☐ |
|
||||||
|
|
||||||
|
### 3.2 Gemeinsame Karten-Mechaniken (alle Kartentypen)
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.2.1 | Bild | Jede Karte zeigt oben ein Bild (Platzhalter 🖼️, falls keins). | ☐ |
|
||||||
|
| 3.2.2 | Satz vorlesen (Audio) | Tippen auf den Satz **oder** den Lautsprecher-Button spielt echtes Audio. | ☐ |
|
||||||
|
| 3.2.3 | Verlangsamtes Audio | Audio wird bewusst in **0,7-facher Geschwindigkeit** abgespielt. | ☐ |
|
||||||
|
| 3.2.4 | Nur ein Audio gleichzeitig | Startet man ein neues Audio, stoppt das vorherige. | ☐ |
|
||||||
|
| 3.2.5 | Karaoke-Hervorhebung | Während des Vorlesens wird das **aktuell gesprochene Wort** im Satz hervorgehoben. | ☐ |
|
||||||
|
| 3.2.6 | Wort-Chip antippen | Markiertes Wort antippen → zugehöriges **Label-Abzeichen auf dem Bild**. | ☐ |
|
||||||
|
| 3.2.7 | Objekt-Chip antippen | Objekt-Wort antippen → **Bildregion umrandet**, Rest abgedunkelt, Label sichtbar. | ☐ |
|
||||||
|
| 3.2.8 | Objekt-Sync beim Vorlesen | Wird ein Objekt-Wort vorgelesen, wird seine Bildregion (nur Umriss) live hervorgehoben. | ☐ |
|
||||||
|
| 3.2.9 | Übersetzungs-Hinweis | Unter dem Satz steht die Übersetzung in der Muttersprache (Hint). | ☐ |
|
||||||
|
| 3.2.10 | Defensive Token-Anzeige | Unaufgelöste Pipeline-Tokens (`⟦PHn:wort⟧`) werden als reines Wort angezeigt, nicht roh. | ☐ |
|
||||||
|
|
||||||
|
### 3.3 Kartentyp „Satz / Lesen" (PairSentenceCard, `text`) — **2 EP**
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.3.1 | „Verstanden" gesperrt | Button ist gesperrt, bis man **entweder** vorgelesen **oder** übersetzt hat. | ☐ |
|
||||||
|
| 3.3.2 | Halten-zum-Übersetzen | Übersetzungs-Button **2 Sekunden halten** → Übersetzung blendet ein, Button entsperrt. | ☐ |
|
||||||
|
| 3.3.3 | Halte-Fortschrittsring | Während des Haltens läuft ein Ring 2 s lang voll. | ☐ |
|
||||||
|
| 3.3.4 | TTS-Fallback | Existiert kein Audio-File, wird per Browser-Sprachsynthese vorgelesen (de/en/sv). | ☐ |
|
||||||
|
| 3.3.5 | Vokabel-Chips | Im Satz markierte Vokabeln werden als separate Chips gelistet. | ☐ |
|
||||||
|
| 3.3.6 | Abschluss | „Verstanden" → Konfetti, Karte zählt als richtig (2 EP). | ☐ |
|
||||||
|
|
||||||
|
### 3.4 Kartentyp „Ja/Nein" (PairYesNoCard, `yes_no`) — **2 EP**
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.4.1 | Zwei Antwort-Buttons | „✗ Nein" und „✓ Ja". | ☐ |
|
||||||
|
| 3.4.2 | Auswertung | Antwort wird gegen die korrekte Lösung des Satzes geprüft. | ☐ |
|
||||||
|
| 3.4.3 | Richtig | Konfetti + „✓ Richtig!" (2 EP). | ☐ |
|
||||||
|
| 3.4.4 | Falsch | „✗ Die Antwort war: Ja/Nein"; richtige/falsche Buttons farblich markiert. | ☐ |
|
||||||
|
| 3.4.5 | Mehrfachklick-Schutz | Nach Beantwortung sind die Buttons gesperrt. | ☐ |
|
||||||
|
|
||||||
|
### 3.5 Kartentyp „Wort wählen" (PairWordCard, `word` & `question`) — **3 EP**
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.5.1 | Frage + Bild + Audio | Zeigt Frage-Satz, Bild und Vorlesen wie alle Karten. | ☐ |
|
||||||
|
| 3.5.2 | Wort-Optionen | Mischung aus richtigen + falschen Wörtern, zufällig gemischt. | ☐ |
|
||||||
|
| 3.5.3 | Mehrfachauswahl | Mehrere Optionen an-/abwählbar, dann „Bestätigen". | ☐ |
|
||||||
|
| 3.5.4 | „Bestätigen" gesperrt | Solange nichts gewählt ist, ist „Bestätigen" gesperrt. | ☐ |
|
||||||
|
| 3.5.5 | Auswertung | **Richtig**, wenn nur richtige Wörter gewählt sind (Teilauswahl der richtigen erlaubt, **kein** falsches dabei). | ☐ |
|
||||||
|
| 3.5.6 | Feedback richtig/falsch | Richtig → Konfetti + „✓ Richtig!"; falsch → „✗ Richtig wären: …". | ☐ |
|
||||||
|
| 3.5.7 | Übersetzungs-Hinweis | Nach Bestätigen werden bei richtigen Optionen die muttersprachlichen Hints gezeigt. | ☐ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Game (Tab „Game")
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 4.1 | Platzhalter-Screen | Zeigt **„Bald verfügbar"**-Karte „Spielend lernen" mit Teaser-Punkten. | ☐ |
|
||||||
|
| 4.2 | Keine Funktion | Es gibt **noch keine** spielbare Funktion (bewusst Platzhalter, kein Bug). | ☐ |
|
||||||
|
|
||||||
|
## 5. Pro (Tab „Pro")
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 5.1 | Platzhalter-Screen | Zeigt **„Bald verfügbar"**-Karte „Snakkimo Pro" mit Teaser-Punkten. | ☐ |
|
||||||
|
| 5.2 | Keine Kauf-/Abo-Funktion | Es gibt **noch keine** Bezahl-/Abo-Funktion (bewusst Platzhalter, kein Bug). | ☐ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Profil (Tab „Profil")
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 6.1 | Avatar + Initialen | Kreis mit den ersten 2 Buchstaben des Usernamens, Online-Punkt. | ☐ |
|
||||||
|
| 6.2 | Level-Abzeichen | Sechseck mit Level-Zahl (**Level = Gesamt-EP ÷ 500**, abgerundet). | ☐ |
|
||||||
|
| 6.3 | Name + Handle | Anzeigename und „@username". | ☐ |
|
||||||
|
| 6.4 | Streak-Anzeige | „🔥 X Tage Streak", wenn Streak > 0. | ☐ |
|
||||||
|
| 6.5 | Tagesziel-Karte | Ring mit Tagesziel-%, „heutige EP / Ziel", Restwert-Hinweis bzw. „Geschafft 🎉". | ☐ |
|
||||||
|
| 6.6 | Fortschritts-Karte | Zielsprache (Flagge+Name), Gesamt-EP, XP-Balken, Level-Pille, „EP bis Level X+1". | ☐ |
|
||||||
|
| 6.7 | Wochen-Aktivität | Balkendiagramm der letzten 7 Tage (heutiger Tag markiert). | ☐ |
|
||||||
|
| 6.8 | Aktivitäts-Kalender | Heatmap der letzten 12 Wochen (Farbintensität nach EP/Tag). | ☐ |
|
||||||
|
| 6.9 | Eckdaten-Kacheln | „Karten geübt", „Genauigkeit %", „Tage Streak". | ☐ |
|
||||||
|
| 6.10 | Fähigkeiten-Radar | Radar-Chart **Vokabular / Lesen / Verständnis** (Genauigkeit je Skill). | ☐ |
|
||||||
|
| 6.11 | Leerzustand Skills | Ohne Daten: „Leg los — deine Stärken erscheinen, sobald du Karten löst.". | ☐ |
|
||||||
|
| 6.12 | Tracking-Hinweis | Ist die Statistik (noch) nicht verfügbar: dezenter Hinweis „… komm morgen wieder! 🌱". | ☐ |
|
||||||
|
| 6.13 | Logout | Abmelde-Button oben rechts (siehe 2.4.2). | ☐ |
|
||||||
|
|
||||||
|
> **Mapping Skills:** Vokabular = Kartentypen `word` + `question`; Lesen = `text`; Verständnis = `yes_no`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. iOS-spezifische Prüfpunkte
|
||||||
|
| # | Funktion | Soll-Verhalten | OK |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 7.1 | App-Icon & Name | Auf dem Homescreen erscheint **„Snakkimo"**. | ☐ |
|
||||||
|
| 7.2 | Start ohne Netz | Ohne Internet lädt nichts (Login/Feed schlagen fehl) — Verhalten dokumentieren. | ☐ |
|
||||||
|
| 7.3 | Audio-Wiedergabe iOS | Vorlesen funktioniert auch im iOS-WebView (Touch löst Audio aus). | ☐ |
|
||||||
|
| 7.4 | Touch-Halten (Übersetzen) | 2-Sekunden-Halten funktioniert per Touch (nicht nur Maus). | ☐ |
|
||||||
|
| 7.5 | Scrollen | Auth- und Profil-Screens lassen sich vollständig scrollen, ohne „Gummiband"-Leaks. | ☐ |
|
||||||
|
| 7.6 | Safe-Area / Statusleiste | Kein Inhalt liegt unter Notch/Statusleiste oder Home-Indicator. | ☐ |
|
||||||
|
| 7.7 | Hintergrund/Wiederkehr | App nach Hintergrund zurückholen → Session noch aktiv, Feed nutzbar. | ☐ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Bekannte Einschränkungen / Auffälligkeiten (für QA, kein Bug-Report nötig)
|
||||||
|
- **Game & Pro** sind reine „Bald verfügbar"-Platzhalter.
|
||||||
|
- **Kein Offline-Modus** — App braucht durchgehend Verbindung zum Backend.
|
||||||
|
- **Feed-Wiederholung:** Bereits gelöste Karten verschwinden nur lokal; nach App-Neustart können dieselben Karten erneut erscheinen (keine Spaced-Repetition-Logik in der App).
|
||||||
|
- **Branding uneinheitlich:** „HejYou" (Login) vs. „Snakkimo" (App-Name) vs. „Language App" (Webview-Titel).
|
||||||
|
- **Nur 3 Sprachen** (de/en/sv) auswählbar.
|
||||||
|
- **Inhaltsmenge begrenzt:** Der Feed kann bei intensivem Üben schnell leer sein („Alle Karten abgeschlossen").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Kompakte Test-Checkliste (Smoke-Test, ~10 Min)
|
||||||
|
- [ ] Registrierung (neue E-Mail) → Profil anlegen → landet im Feed
|
||||||
|
- [ ] Logout → erneuter Login → Feed
|
||||||
|
- [ ] Login bleibt nach App-Neustart bestehen
|
||||||
|
- [ ] Feed: Satz-Karte vorlesen (Audio + Karaoke-Highlight)
|
||||||
|
- [ ] Feed: Wort-Chip antippen → Bild-Label; Objekt-Chip → Bildregion umrandet
|
||||||
|
- [ ] Feed: „Verstanden"-Karte über 2-Sek-Halten entsperren und lösen
|
||||||
|
- [ ] Feed: Ja/Nein-Karte richtig + falsch testen (Konfetti / korrekte Lösung)
|
||||||
|
- [ ] Feed: Wort-Wahl-Karte mit Mehrfachauswahl lösen
|
||||||
|
- [ ] EP-Abzeichen oben steigt nach Lösen einer Karte
|
||||||
|
- [ ] Profil: EP/Level, Streak, Wochen-Balken, Heatmap, Radar werden angezeigt
|
||||||
|
- [ ] Game-Tab & Pro-Tab zeigen „Bald verfügbar"
|
||||||
|
- [ ] iOS: Safe-Area korrekt, Touch-Halten & Audio funktionieren
|
||||||
5
capacitor.config.json
Normal file
5
capacitor.config.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.hyggecraftery.snakkimo",
|
||||||
|
"appName": "Snakkimo",
|
||||||
|
"webDir": "dist"
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>Language App</title>
|
<title>Snakkimo</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
13
ios/.gitignore
vendored
Normal file
13
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
App/build
|
||||||
|
App/Pods
|
||||||
|
App/output
|
||||||
|
App/App/public
|
||||||
|
DerivedData
|
||||||
|
xcuserdata
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-ios-plugins
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
App/App/capacitor.config.json
|
||||||
|
App/App/config.xml
|
||||||
378
ios/App/App.xcodeproj/project.pbxproj
Normal file
378
ios/App/App.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 60;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||||
|
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||||
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||||
|
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||||
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||||
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||||
|
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||||
|
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
|
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||||
|
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
504EC3011FED79650016851F /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
504EC2FB1FED79650016851F = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||||
|
504EC3061FED79650016851F /* App */,
|
||||||
|
504EC3051FED79650016851F /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
504EC3051FED79650016851F /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
504EC3041FED79650016851F /* App.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
504EC3061FED79650016851F /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||||
|
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||||
|
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||||
|
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||||
|
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||||
|
504EC3131FED79650016851F /* Info.plist */,
|
||||||
|
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||||
|
50B271D01FEDC1A000F3C39B /* public */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
504EC3031FED79650016851F /* App */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||||
|
buildPhases = (
|
||||||
|
504EC3001FED79650016851F /* Sources */,
|
||||||
|
504EC3011FED79650016851F /* Frameworks */,
|
||||||
|
504EC3021FED79650016851F /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = App;
|
||||||
|
packageProductDependencies = (
|
||||||
|
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||||
|
);
|
||||||
|
productName = App;
|
||||||
|
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
504EC2FC1FED79650016851F /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
LastSwiftUpdateCheck = 0920;
|
||||||
|
LastUpgradeCheck = 0920;
|
||||||
|
TargetAttributes = {
|
||||||
|
504EC3031FED79650016851F = {
|
||||||
|
CreatedOnToolsVersion = 9.2;
|
||||||
|
LastSwiftMigration = 1100;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||||
|
compatibilityVersion = "Xcode 8.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 504EC2FB1FED79650016851F;
|
||||||
|
packageReferences = (
|
||||||
|
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||||
|
);
|
||||||
|
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
504EC3031FED79650016851F /* App */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
504EC3021FED79650016851F /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||||
|
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||||
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||||
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||||
|
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||||
|
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
504EC3001FED79650016851F /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
504EC30C1FED79650016851F /* Base */,
|
||||||
|
);
|
||||||
|
name = Main.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
504EC3111FED79650016851F /* Base */,
|
||||||
|
);
|
||||||
|
name = LaunchScreen.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
504EC3141FED79650016851F /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
504EC3151FED79650016851F /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
504EC3171FED79650016851F /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = LKA5KKQKHY;
|
||||||
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.hyggecraftery.snakkimo;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
504EC3181FED79650016851F /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = LKA5KKQKHY;
|
||||||
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.hyggecraftery.snakkimo;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
504EC3141FED79650016851F /* Debug */,
|
||||||
|
504EC3151FED79650016851F /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
504EC3171FED79650016851F /* Debug */,
|
||||||
|
504EC3181FED79650016851F /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = "CapApp-SPM";
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||||
|
productName = "CapApp-SPM";
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "ba02264f6d4c613de9cfd9120fa05cc1e61acacc858cb62d0cb6cb68ef052757",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "capacitor-swift-pm",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "44ae2505f54a80c5e559533950886d0644fc2fca",
|
||||||
|
"version" : "8.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftkeychainwrapper",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/jrendel/SwiftKeychainWrapper.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "185a3165346a03767101c4f62e9a545a0fe0530f",
|
||||||
|
"version" : "4.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
49
ios/App/App/AppDelegate.swift
Normal file
49
ios/App/App/AppDelegate.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import UIKit
|
||||||
|
import Capacitor
|
||||||
|
|
||||||
|
@UIApplicationMain
|
||||||
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
// Override point for customization after application launch.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillResignActive(_ application: UIApplication) {
|
||||||
|
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||||
|
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||||
|
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
|
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
|
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
|
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||||
|
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||||
|
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||||
|
// Called when the app was launched with an activity, including Universal Links.
|
||||||
|
// Feel free to add additional processing here, but if you want the App API to support
|
||||||
|
// tracking app url opens, make sure to keep this call
|
||||||
|
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
14
ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
14
ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon-512@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios/App/App/Assets.xcassets/Contents.json
Normal file
6
ios/App/App/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
vendored
Normal file
23
ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "splash-2732x2732-2.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "splash-2732x2732-1.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "splash-2732x2732.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
32
ios/App/App/Base.lproj/LaunchScreen.storyboard
Normal file
32
ios/App/App/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||||
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
</imageView>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="Splash" width="1366" height="1366"/>
|
||||||
|
<systemColor name="systemBackgroundColor">
|
||||||
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</systemColor>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
19
ios/App/App/Base.lproj/Main.storyboard
Normal file
19
ios/App/App/Base.lproj/Main.storyboard
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<device id="retina4_7" orientation="portrait">
|
||||||
|
<adaptation id="fullscreen"/>
|
||||||
|
</device>
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Bridge View Controller-->
|
||||||
|
<scene sceneID="tne-QT-ifu">
|
||||||
|
<objects>
|
||||||
|
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
51
ios/App/App/Info.plist
Normal file
51
ios/App/App/Info.plist
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CAPACITOR_DEBUG</key>
|
||||||
|
<string>$(CAPACITOR_DEBUG)</string>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Snakkimo</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
9
ios/App/CapApp-SPM/.gitignore
vendored
Normal file
9
ios/App/CapApp-SPM/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
||||||
27
ios/App/CapApp-SPM/Package.swift
Normal file
27
ios/App/CapApp-SPM/Package.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||||
|
let package = Package(
|
||||||
|
name: "CapApp-SPM",
|
||||||
|
platforms: [.iOS(.v15)],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "CapApp-SPM",
|
||||||
|
targets: ["CapApp-SPM"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.0"),
|
||||||
|
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "CapApp-SPM",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||||
|
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||||
|
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
5
ios/App/CapApp-SPM/README.md
Normal file
5
ios/App/CapApp-SPM/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# CapApp-SPM
|
||||||
|
|
||||||
|
This package is used to host SPM dependencies for your Capacitor project
|
||||||
|
|
||||||
|
Do not modify the contents of it or there may be unintended consequences.
|
||||||
1
ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift
Normal file
1
ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift
Normal file
@@ -0,0 +1 @@
|
|||||||
|
public let isCapacitorApp = true
|
||||||
1
ios/debug.xcconfig
Normal file
1
ios/debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CAPACITOR_DEBUG = true
|
||||||
@@ -4,9 +4,10 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback — index.html nie cachen, sonst hängen Browser auf alten Bundles
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
|
|||||||
1063
package-lock.json
generated
1063
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
{"name":"language-app","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"canvas-confetti":"^1.9.4","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.4","vite":"^6.3.1"}}
|
{"name":"snakkimo","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"@capacitor/cli":"^8.4.0","@capacitor/core":"^8.4.0","@capacitor/ios":"^8.4.0","@capacitor/local-notifications":"^8.2.0","canvas-confetti":"^1.9.4","capacitor-secure-storage-plugin":"^0.13.0","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.4","vite":"^6.3.1"}}
|
||||||
776
setup_auth.mjs
Normal file
776
setup_auth.mjs
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Führe aus: node setup_auth.js
|
||||||
|
// Im Verzeichnis: /Users/tim/Documents/GitTea/Language
|
||||||
|
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const ROOT = '/Users/tim/Documents/GitTea/Language'
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
// ─── .env (falls noch nicht vorhanden) ───────────────────
|
||||||
|
'.env': `VITE_DIRECTUS_URL=https://db.hejyou.com
|
||||||
|
VITE_DIRECTUS_TOKEN=j6YyjhoFidnU3cI3MSrcgTXqO3t2wbZG
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── API Service ─────────────────────────────────────────
|
||||||
|
'src/api/directus.js': `const BASE = import.meta.env.VITE_DIRECTUS_URL
|
||||||
|
const TOKEN = import.meta.env.VITE_DIRECTUS_TOKEN
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': \`Bearer \${TOKEN}\`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email, password) {
|
||||||
|
const res = await fetch(\`\${BASE}/auth/login\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Login fehlgeschlagen.')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(userToken) {
|
||||||
|
const res = await fetch(
|
||||||
|
\`\${BASE}/users/me?fields=id,username,language_native,language_target\`,
|
||||||
|
{ headers: { 'Authorization': \`Bearer \${userToken}\` } }
|
||||||
|
)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerUser(email, password) {
|
||||||
|
const res = await fetch(\`\${BASE}/users\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Registrierung fehlgeschlagen.')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkUsername(username) {
|
||||||
|
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
|
||||||
|
const res = await fetch(
|
||||||
|
\`\${BASE}/items/users_language?filter[username_lowercases][_eq]=\${encodeURIComponent(clean)}&fields=id&limit=1\`,
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
const data = await res.json()
|
||||||
|
return data.data?.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProfile({ userId, username, nativeLang, targetLang, userToken }) {
|
||||||
|
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
|
||||||
|
const authHeaders = userToken
|
||||||
|
? { 'Content-Type': 'application/json', 'Authorization': \`Bearer \${userToken}\` }
|
||||||
|
: headers
|
||||||
|
|
||||||
|
const profileRes = await fetch(\`\${BASE}/items/users_language\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ username_public: username, username_lowercases: clean, status: 'published' }),
|
||||||
|
})
|
||||||
|
const profileData = await profileRes.json()
|
||||||
|
if (!profileRes.ok) throw new Error(profileData.errors?.[0]?.message || 'Profil konnte nicht erstellt werden.')
|
||||||
|
const profileId = profileData.data.id
|
||||||
|
|
||||||
|
await fetch(\`\${BASE}/users/\${userId}\`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({ username: profileId, language_native: nativeLang, language_target: targetLang }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetch(\`\${BASE}/items/users_language/\${profileId}\`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ user: userId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetch(\`\${BASE}/items/learning_pairs\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: profileId,
|
||||||
|
language_from: nativeLang,
|
||||||
|
language_to: targetLang,
|
||||||
|
active: true,
|
||||||
|
current_level: 1,
|
||||||
|
points: 0,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return profileId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LANGUAGE_OPTIONS = [
|
||||||
|
{ id: '88053026-3d7e-4799-b10d-67187f7c1709', label: 'Deutsch', flag: '🇩🇪' },
|
||||||
|
{ id: '99fbaa9d-3cac-48cb-a5e2-dcb320e913e4', label: 'Englisch', flag: '🇬🇧' },
|
||||||
|
{ id: '25350b32-e9ab-4fec-946e-c0f11eff70dd', label: 'Schwedisch', flag: '🇸🇪' },
|
||||||
|
]
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── Auth Context ─────────────────────────────────────────
|
||||||
|
'src/context/AuthContext.jsx': `import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import { getMe } from '../api/directus'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem('hejyou_token'))
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) { setLoading(false); return }
|
||||||
|
getMe(token)
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => { localStorage.removeItem('hejyou_token'); setToken(null) })
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const saveToken = (t) => {
|
||||||
|
localStorage.setItem('hejyou_token', t)
|
||||||
|
setToken(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('hejyou_token')
|
||||||
|
setToken(null)
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ token, user, setUser, saveToken, logout, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext)
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── UI Components ────────────────────────────────────────
|
||||||
|
'src/components/auth/ui.jsx': `import { useState } from 'react'
|
||||||
|
import styles from './auth.module.css'
|
||||||
|
|
||||||
|
export function FormGroup({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
{label && <label className={styles.label}>{label}</label>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ className, ...props }) {
|
||||||
|
return <input className={styles.input} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ children, ...props }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.selectWrap}>
|
||||||
|
<select className={styles.input} {...props}>{children}</select>
|
||||||
|
<div className={styles.selectArrow} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ loading, children, ...props }) {
|
||||||
|
return (
|
||||||
|
<button className={styles.btn} {...props}>
|
||||||
|
{children}
|
||||||
|
{loading && <span className={styles.spinner} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Alert({ message }) {
|
||||||
|
if (!message) return null
|
||||||
|
return <div className={styles.alert}>{message}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepDots({ current, total }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stepDots}>
|
||||||
|
{Array.from({ length: total }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={styles.stepDot}
|
||||||
|
style={{ background: i === current ? 'var(--accent)' : 'var(--border)', width: i === current ? '18px' : '6px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className={styles.stepLabel}>Schritt {current + 1} von {total}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── Auth CSS Module ──────────────────────────────────────
|
||||||
|
'src/components/auth/auth.module.css': `:root {
|
||||||
|
--bg: #F5F0E8;
|
||||||
|
--surface: #FFFCF7;
|
||||||
|
--border: #E2DAD0;
|
||||||
|
--text: #2C2520;
|
||||||
|
--muted: #9A8F85;
|
||||||
|
--accent: #5C7A5E;
|
||||||
|
--accent-lt: #EAF0EA;
|
||||||
|
--danger: #C0544A;
|
||||||
|
--danger-lt: #FBF0EF;
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup { margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(92,122,94,0.12);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectWrap { position: relative; }
|
||||||
|
.selectArrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-top: 6px solid var(--muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.selectWrap .input { padding-right: 36px; cursor: pointer; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #4a6650; }
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.btn:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.35);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
background: var(--danger-lt);
|
||||||
|
border: 1px solid #EBCBC8;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.stepDot {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
.stepLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── Login Form ───────────────────────────────────────────
|
||||||
|
'src/components/auth/LoginForm.jsx': `import { useState } from 'react'
|
||||||
|
import { login, getMe } from '../../api/directus'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { FormGroup, Input, Button, Alert } from './ui'
|
||||||
|
|
||||||
|
export default function LoginForm({ onNeedsProfile }) {
|
||||||
|
const { saveToken, setUser } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [pw, setPw] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return }
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { access_token } = await login(email, pw)
|
||||||
|
saveToken(access_token)
|
||||||
|
const me = await getMe(access_token)
|
||||||
|
setUser(me)
|
||||||
|
if (!me.username || !me.language_native || !me.language_target) {
|
||||||
|
onNeedsProfile(me.id, access_token)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Alert message={error} />
|
||||||
|
<FormGroup label="E-Mail">
|
||||||
|
<Input type="email" placeholder="deine@email.de" value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)} autoComplete="email" autoFocus />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Passwort">
|
||||||
|
<Input type="password" placeholder="••••••••" value={pw}
|
||||||
|
onChange={e => setPw(e.target.value)} autoComplete="current-password" />
|
||||||
|
</FormGroup>
|
||||||
|
<Button type="submit" loading={loading} disabled={loading}>
|
||||||
|
{loading ? 'Anmelden…' : 'Anmelden'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── Register Step 1 ──────────────────────────────────────
|
||||||
|
'src/components/auth/RegisterStep1.jsx': `import { useState } from 'react'
|
||||||
|
import { registerUser, login } from '../../api/directus'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { FormGroup, Input, Button, Alert, StepDots } from './ui'
|
||||||
|
|
||||||
|
export default function RegisterStep1({ onSuccess }) {
|
||||||
|
const { saveToken } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [pw, setPw] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
if (!email || !pw) { setError('Bitte alle Felder ausfüllen.'); return }
|
||||||
|
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const newUser = await registerUser(email, pw)
|
||||||
|
const { access_token } = await login(email, pw)
|
||||||
|
saveToken(access_token)
|
||||||
|
onSuccess(newUser.id, access_token)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<StepDots current={0} total={2} />
|
||||||
|
<Alert message={error} />
|
||||||
|
<FormGroup label="E-Mail">
|
||||||
|
<Input type="email" placeholder="deine@email.de" value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)} autoComplete="email" autoFocus />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Passwort">
|
||||||
|
<Input type="password" placeholder="Mindestens 8 Zeichen" value={pw}
|
||||||
|
onChange={e => setPw(e.target.value)} autoComplete="new-password" />
|
||||||
|
</FormGroup>
|
||||||
|
<Button type="submit" loading={loading} disabled={loading}>
|
||||||
|
{loading ? 'Weiter…' : 'Weiter'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── Register Step 2 ──────────────────────────────────────
|
||||||
|
'src/components/auth/RegisterStep2.jsx': `import { useState, useRef, useCallback } from 'react'
|
||||||
|
import { checkUsername, createProfile, LANGUAGE_OPTIONS } from '../../api/directus'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
|
||||||
|
import styles from './auth.module.css'
|
||||||
|
|
||||||
|
export default function RegisterStep2({ userId, userToken, onSuccess }) {
|
||||||
|
const { setUser } = useAuth()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [nativeLang, setNativeLang] = useState('')
|
||||||
|
const [targetLang, setTargetLang] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [usernameState, setUsernameState] = useState('idle')
|
||||||
|
const debounceRef = useRef(null)
|
||||||
|
|
||||||
|
const handleUsernameChange = useCallback((val) => {
|
||||||
|
setUsername(val)
|
||||||
|
setUsernameState('idle')
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
if (val.length < 3) return
|
||||||
|
setUsernameState('checking')
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const available = await checkUsername(val)
|
||||||
|
setUsernameState(available ? 'available' : 'taken')
|
||||||
|
} catch { setUsernameState('idle') }
|
||||||
|
}, 450)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
if (!username) { setError('Bitte einen Username wählen.'); return }
|
||||||
|
if (usernameState !== 'available') { setError('Bitte einen verfügbaren Username wählen.'); return }
|
||||||
|
if (!nativeLang) { setError('Bitte Muttersprache wählen.'); return }
|
||||||
|
if (!targetLang) { setError('Bitte Zielsprache wählen.'); return }
|
||||||
|
if (nativeLang === targetLang) { setError('Mutter- und Zielsprache dürfen nicht gleich sein.'); return }
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await createProfile({ userId, username, nativeLang, targetLang, userToken })
|
||||||
|
setUser(prev => ({ ...prev, username: true, language_native: nativeLang, language_target: targetLang }))
|
||||||
|
onSuccess(username)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = { idle: 'var(--muted)', checking: 'var(--muted)', available: 'var(--accent)', taken: 'var(--danger)' }
|
||||||
|
const statusText = { idle: '', checking: '…', available: '✓ verfügbar', taken: '✕ vergeben' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<StepDots current={1} total={2} />
|
||||||
|
<Alert message={error} />
|
||||||
|
<FormGroup label="Username">
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Input type="text" placeholder="dein_username" value={username}
|
||||||
|
onChange={e => handleUsernameChange(e.target.value)}
|
||||||
|
autoComplete="off" autoFocus style={{ paddingRight: '110px' }} />
|
||||||
|
{usernameState !== 'idle' && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', right: '12px', top: '50%',
|
||||||
|
transform: 'translateY(-50%)', fontSize: '12px', fontWeight: 500,
|
||||||
|
color: statusColor[usernameState], whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{statusText[usernameState]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Muttersprache">
|
||||||
|
<Select value={nativeLang} onChange={e => setNativeLang(e.target.value)}>
|
||||||
|
<option value="">Bitte wählen…</option>
|
||||||
|
{LANGUAGE_OPTIONS.map(l => <option key={l.id} value={l.id}>{l.flag} {l.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Ich möchte lernen">
|
||||||
|
<Select value={targetLang} onChange={e => setTargetLang(e.target.value)}>
|
||||||
|
<option value="">Bitte wählen…</option>
|
||||||
|
{LANGUAGE_OPTIONS.filter(l => l.id !== nativeLang).map(l => <option key={l.id} value={l.id}>{l.flag} {l.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
</FormGroup>
|
||||||
|
<Button type="submit" loading={loading}
|
||||||
|
disabled={loading || usernameState === 'taken' || usernameState === 'checking'}>
|
||||||
|
{loading ? 'Speichere…' : 'Profil erstellen'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── Auth Screen ──────────────────────────────────────────
|
||||||
|
'src/components/auth/AuthScreen.jsx': `import { useState } from 'react'
|
||||||
|
import LoginForm from './LoginForm'
|
||||||
|
import RegisterStep1 from './RegisterStep1'
|
||||||
|
import RegisterStep2 from './RegisterStep2'
|
||||||
|
import styles from './AuthScreen.module.css'
|
||||||
|
|
||||||
|
function Brand() {
|
||||||
|
return (
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<div className={styles.brandMark}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M8 12q2-5 4-4t4 4-4 4-4-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className={styles.brandTitle}>HejYou</h1>
|
||||||
|
<p className={styles.brandSub}>Sprachen lernen wie ein Kind</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeToggle({ mode, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.toggle}>
|
||||||
|
{['login', 'register'].map(m => (
|
||||||
|
<button key={m} onClick={() => onChange(m)}
|
||||||
|
className={mode === m ? \`\${styles.toggleBtn} \${styles.toggleBtnActive}\` : styles.toggleBtn}>
|
||||||
|
{m === 'login' ? 'Anmelden' : 'Registrieren'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessScreen({ username }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.success}>
|
||||||
|
<div className={styles.successCheck}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<strong className={styles.successTitle}>Willkommen{username ? \`, \${username}\` : ''}!</strong>
|
||||||
|
<p className={styles.successSub}>Dein Abenteuer beginnt jetzt.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthScreen() {
|
||||||
|
const [mode, setMode] = useState(() => localStorage.getItem('hejyou_last_mode') || 'login')
|
||||||
|
const [step, setStep] = useState('main')
|
||||||
|
const [pendingUserId, setPendingUserId] = useState(null)
|
||||||
|
const [pendingToken, setPendingToken] = useState(null)
|
||||||
|
const [successName, setSuccessName] = useState('')
|
||||||
|
const [showToggle, setShowToggle] = useState(true)
|
||||||
|
|
||||||
|
const handleModeChange = (m) => {
|
||||||
|
setMode(m)
|
||||||
|
localStorage.setItem('hejyou_last_mode', m)
|
||||||
|
setStep('main')
|
||||||
|
setShowToggle(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNeedsProfile = (userId, token) => {
|
||||||
|
setPendingUserId(userId); setPendingToken(token)
|
||||||
|
setShowToggle(false); setStep('profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<Brand />
|
||||||
|
{showToggle && step === 'main' && <ModeToggle mode={mode} onChange={handleModeChange} />}
|
||||||
|
{step === 'main' && mode === 'login' && <LoginForm onNeedsProfile={handleNeedsProfile} />}
|
||||||
|
{step === 'main' && mode === 'register' && <RegisterStep1 onSuccess={(id, t) => handleNeedsProfile(id, t)} />}
|
||||||
|
{step === 'profile' && <RegisterStep2 userId={pendingUserId} userToken={pendingToken} onSuccess={(name) => { setSuccessName(name); setStep('success') }} />}
|
||||||
|
{step === 'success' && <SuccessScreen username={successName} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── AuthScreen CSS Module ────────────────────────────────
|
||||||
|
'src/components/auth/AuthScreen.module.css': `.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg, #F5F0E8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface, #FFFCF7);
|
||||||
|
border: 1px solid var(--border, #E2DAD0);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px 44px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 2px 40px rgba(44,37,32,0.06);
|
||||||
|
animation: fadeUp 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { text-align: center; margin-bottom: 36px; }
|
||||||
|
.brandMark {
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
background: var(--accent, #5C7A5E);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.brandTitle {
|
||||||
|
font-family: 'Lora', serif;
|
||||||
|
font-size: 22px; font-weight: 500;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
color: var(--text, #2C2520);
|
||||||
|
}
|
||||||
|
.brandSub { font-size: 13px; color: var(--muted, #9A8F85); margin-top: 4px; }
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg, #F5F0E8);
|
||||||
|
border: 1px solid var(--border, #E2DAD0);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px; gap: 3px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.toggleBtn {
|
||||||
|
flex: 1; padding: 8px;
|
||||||
|
border: none; border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
color: var(--muted, #9A8F85);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.toggleBtnActive {
|
||||||
|
background: var(--surface, #FFFCF7);
|
||||||
|
color: var(--text, #2C2520);
|
||||||
|
box-shadow: 0 1px 4px rgba(44,37,32,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success { text-align: center; padding: 24px 0; }
|
||||||
|
.successCheck {
|
||||||
|
width: 52px; height: 52px;
|
||||||
|
background: var(--accent-lt, #EAF0EA);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
.successTitle {
|
||||||
|
font-family: 'Lora', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
display: block; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.successSub { font-size: 14px; color: var(--muted, #9A8F85); }
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── App.jsx (ersetzt bestehende) ─────────────────────────
|
||||||
|
'src/App.jsx': `import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
|
import AuthScreen from './components/auth/AuthScreen'
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { user, loading, logout } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '32px', height: '32px',
|
||||||
|
border: '2px solid #E2DAD0',
|
||||||
|
borderTopColor: '#5C7A5E',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 0.7s linear infinite',
|
||||||
|
}} />
|
||||||
|
<style>{\`@keyframes spin { to { transform: rotate(360deg); } }\`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht eingeloggt oder Profil unvollständig → Auth Screen
|
||||||
|
if (!user || !user.username || !user.language_native || !user.language_target) {
|
||||||
|
return <AuthScreen />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Eingeloggt → hier kommt dein bestehender App-Content ──
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* TODO: Deine bestehenden Pages/Routes hier einbauen */}
|
||||||
|
<p style={{ padding: 32, fontFamily: 'sans-serif' }}>
|
||||||
|
Eingeloggt ✓ — hier kommt der Feed.
|
||||||
|
<button onClick={logout} style={{ marginLeft: 16, cursor: 'pointer' }}>Logout</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle alle Verzeichnisse und schreibe Dateien
|
||||||
|
const dirs = new Set()
|
||||||
|
for (const path of Object.keys(files)) {
|
||||||
|
const parts = path.split('/')
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
dirs.add(join(ROOT, ...parts.slice(0, i)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try { mkdirSync(dir, { recursive: true }) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let written = 0
|
||||||
|
for (const [path, content] of Object.entries(files)) {
|
||||||
|
const fullPath = join(ROOT, path)
|
||||||
|
// .env nur schreiben wenn noch nicht vorhanden
|
||||||
|
if (path === '.env') {
|
||||||
|
try {
|
||||||
|
const existing = await import('fs').then(m => m.readFileSync(fullPath, 'utf8'))
|
||||||
|
console.log('⏭ .env bereits vorhanden – übersprungen')
|
||||||
|
continue
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
writeFileSync(fullPath, content, 'utf8')
|
||||||
|
console.log(`✓ ${path}`)
|
||||||
|
written++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(\`\nFertig! \${written} Dateien geschrieben.\`)
|
||||||
|
console.log('\nJetzt starten:')
|
||||||
|
console.log(' npm install (falls neue Abhängigkeiten fehlen)')
|
||||||
|
console.log(' npm run dev')
|
||||||
21
src/App.jsx
21
src/App.jsx
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import AuthScreen from './components/auth/AuthScreen'
|
import AuthScreen from './components/auth/AuthScreen'
|
||||||
import BottomNav from './BottomNav'
|
import BottomNav from './BottomNav'
|
||||||
@@ -6,6 +6,7 @@ import Feed from './pages/Feed'
|
|||||||
import Game from './pages/Game'
|
import Game from './pages/Game'
|
||||||
import Pro from './pages/Pro'
|
import Pro from './pages/Pro'
|
||||||
import Profil from './pages/Profil'
|
import Profil from './pages/Profil'
|
||||||
|
import { scheduleStreakReminder } from './utils/streakReminder'
|
||||||
|
|
||||||
const PAGES = { feed: Feed, game: Game, pro: Pro, profil: Profil }
|
const PAGES = { feed: Feed, game: Game, pro: Pro, profil: Profil }
|
||||||
|
|
||||||
@@ -13,24 +14,30 @@ function AppContent() {
|
|||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const [page, setPage] = useState('feed')
|
const [page, setPage] = useState('feed')
|
||||||
|
|
||||||
|
// Lokale Tages-Erinnerung planen, sobald ein eingeloggter Nutzer da ist (nativ; web no-op).
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.username) scheduleStreakReminder(user.streak_days || 0)
|
||||||
|
}, [user?.username, user?.streak_days])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#F5F0E8' }}>
|
<div style={{ height: '100%', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}>
|
||||||
<div style={{ width: '32px', height: '32px', border: '2px solid #E2DAD0', borderTopColor: '#5C7A5E', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
|
<div className="app-spinner" />
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !user.username || !user.language_native || !user.language_target) {
|
if (!user || !user.username || !user.language_native_id || !user.language_target_id) {
|
||||||
return <AuthScreen />
|
return <AuthScreen />
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageComponent = PAGES[page] || Feed
|
const PageComponent = PAGES[page] || Feed
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: '#F5F0E8', paddingBottom: '72px' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: 'var(--bg)', overflow: 'hidden' }}>
|
||||||
<PageComponent />
|
<div key={page} className="page-enter" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<PageComponent />
|
||||||
|
</div>
|
||||||
<BottomNav active={page} onNavigate={setPage} />
|
<BottomNav active={page} onNavigate={setPage} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #C4A882;
|
background: var(--surface);
|
||||||
border-top: 1px solid #D4B896;
|
border-top: 1px solid var(--border-soft);
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding: 6px 8px;
|
||||||
|
padding-bottom: calc(6px + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -2px 16px rgba(60, 40, 20, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@@ -11,34 +13,45 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px 0;
|
padding: 8px 0 6px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(74, 55, 40, 0.45);
|
color: var(--text-soft);
|
||||||
transition: color 0.2s;
|
transition: color var(--dur-fast) var(--ease);
|
||||||
gap: 3px;
|
gap: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
color: #7A5C3A;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
width: 24px;
|
width: 44px;
|
||||||
height: 24px;
|
height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
transition: background var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon svg {
|
.nav-icon svg {
|
||||||
width: 24px;
|
width: 23px;
|
||||||
height: 24px;
|
height: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .nav-icon {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:active .nav-icon {
|
||||||
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,57 @@
|
|||||||
|
// snakkimo-API client (Modulname bleibt directus.js aus historischen Gründen)
|
||||||
const BASE = import.meta.env.VITE_API_URL
|
const BASE = import.meta.env.VITE_API_URL
|
||||||
|
|
||||||
const json = { 'Content-Type': 'application/json' }
|
const json = { 'Content-Type': 'application/json' }
|
||||||
const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` })
|
const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` })
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function login(email, password) {
|
export async function login(email, password) {
|
||||||
const res = await fetch(`${BASE}/languparent/auth/login`, {
|
const res = await fetch(`${BASE}/auth/login`, {
|
||||||
method: 'POST', headers: json,
|
method: 'POST', headers: json,
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
// Kein Profil vorhanden → UI zum Profil-Setup leiten
|
|
||||||
if (res.status === 403 && data.needsProfile) {
|
|
||||||
return { access_token: null, needsProfile: true, userId: data.userId }
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Login fehlgeschlagen.')
|
if (!res.ok) throw new Error(data.error || 'Login fehlgeschlagen.')
|
||||||
return { access_token: data.token }
|
return { access_token: data.token, needsProfile: !!data.needsProfile, userId: data.user?.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe(userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/auth/me`, {
|
|
||||||
headers: auth(userToken),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
|
|
||||||
return data // bereits vom API-Server entpackt
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registriert den Directus-User; gibt { registrationToken } zurück (10 Min gültig)
|
|
||||||
export async function registerUser(email, password) {
|
export async function registerUser(email, password) {
|
||||||
const res = await fetch(`${BASE}/languparent/auth/register`, {
|
const res = await fetch(`${BASE}/auth/register`, {
|
||||||
method: 'POST', headers: json,
|
method: 'POST', headers: json,
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen.')
|
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen.')
|
||||||
return data // { registrationToken }
|
return { token: data.token, userId: data.user?.id, needsProfile: !!data.needsProfile }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUsername(username /*, _userToken — nicht mehr nötig */) {
|
export async function getMe(userToken) {
|
||||||
|
const res = await fetch(`${BASE}/auth/me`, { headers: auth(userToken) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Profil konnte nicht geladen werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkUsername(username, userToken) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${BASE}/languparent/auth/check-username?username=${encodeURIComponent(username)}`,
|
`${BASE}/auth/check-username?username=${encodeURIComponent(username)}`,
|
||||||
|
{ headers: auth(userToken) },
|
||||||
)
|
)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'Username-Check fehlgeschlagen.')
|
if (!res.ok) throw new Error(data.error || 'Username-Check fehlgeschlagen.')
|
||||||
return data.available
|
return data.available
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erstellt Profil über API-Server (Admin-Token bleibt server-seitig)
|
// Legt Profil an (user_names + users_public). userToken = JWT aus register/login.
|
||||||
// userToken ist das kurzlebige Registration-Token aus registerUser()
|
|
||||||
// Gibt { token, expiresIn } zurück — muss via saveToken gespeichert werden
|
|
||||||
export async function createProfile({ username, nativeLang, targetLang, userToken }) {
|
export async function createProfile({ username, nativeLang, targetLang, userToken }) {
|
||||||
const res = await fetch(`${BASE}/languparent/auth/profile`, {
|
const res = await fetch(`${BASE}/auth/profile`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: auth(userToken),
|
||||||
headers: { ...json, 'X-Registration-Token': userToken },
|
|
||||||
body: JSON.stringify({ username, nativeLang, targetLang }),
|
body: JSON.stringify({ username, nativeLang, targetLang }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'Profilerstellung fehlgeschlagen.')
|
if (!res.ok) throw new Error(data.error || 'Profilerstellung fehlgeschlagen.')
|
||||||
return data // { token, expiresIn }
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sprachen ──────────────────────────────────────────────────────────────────
|
// ── Sprachen ──────────────────────────────────────────────────────────────────
|
||||||
@@ -67,18 +59,18 @@ export async function createProfile({ username, nativeLang, targetLang, userToke
|
|||||||
const LANG_META = {
|
const LANG_META = {
|
||||||
de: { flag: '🇩🇪', speech: 'de-DE' },
|
de: { flag: '🇩🇪', speech: 'de-DE' },
|
||||||
en: { flag: '🇬🇧', speech: 'en-US' },
|
en: { flag: '🇬🇧', speech: 'en-US' },
|
||||||
se: { flag: '🇸🇪', speech: 'sv-SE' },
|
sv: { flag: '🇸🇪', speech: 'sv-SE' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLanguageOptions() {
|
export async function getLanguageOptions() {
|
||||||
const res = await fetch(`${BASE}/languparent/languages`)
|
const res = await fetch(`${BASE}/auth/languages`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error('Sprachen konnten nicht geladen werden.')
|
if (!res.ok) throw new Error('Sprachen konnten nicht geladen werden.')
|
||||||
return (data || []).map(l => ({
|
return (data || []).map(l => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
label: l.title_de,
|
label: l.titel_de,
|
||||||
suffix: l.short,
|
suffix: l.short_en,
|
||||||
...(LANG_META[l.short] || { flag: '🌐', speech: l.short }),
|
...(LANG_META[l.short_en] || { flag: '🌐', speech: l.short_en }),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,81 +78,74 @@ export function langById(id, options) {
|
|||||||
return (options || []).find(l => l.id === id) || null
|
return (options || []).find(l => l.id === id) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Learning Pair ─────────────────────────────────────────────────────────────
|
// ── Feed ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getActiveLearningPair(_profileId, userToken) {
|
export async function getFeedPairs(userToken, lang = 'de', limit = 20, exclude = []) {
|
||||||
// profileId steckt im JWT — der API-Server liest ihn von dort
|
const params = new URLSearchParams({ lang, limit: String(limit) })
|
||||||
const res = await fetch(`${BASE}/languparent/pair`, { headers: auth(userToken) })
|
if (exclude.length) params.set('exclude', exclude.join(','))
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) return null
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addPointsToPair(pairId, newPoints, userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/pair/${pairId}/points`, {
|
|
||||||
method: 'PATCH', headers: auth(userToken),
|
|
||||||
body: JSON.stringify({ points: newPoints }),
|
|
||||||
})
|
|
||||||
return res.ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Content ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWords(userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/words`, { headers: auth(userToken) })
|
|
||||||
const data = await res.json()
|
|
||||||
return data || []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQuestions(userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/questions`, { headers: auth(userToken) })
|
|
||||||
const data = await res.json()
|
|
||||||
return data || []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQAPairsAtLevel(level, userToken, langSuffix = 'de') {
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${BASE}/languparent/qa-pairs?level=${level}&lang=${langSuffix}`,
|
`${BASE}/auth/feed?${params}`,
|
||||||
{ headers: auth(userToken) },
|
{ headers: auth(userToken) }
|
||||||
)
|
)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data || []
|
if (!res.ok) throw new Error(data.error || 'Feed konnte nicht geladen werden.')
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fortschritt ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getUserProgress(_profileId, userToken, toLangId = null) {
|
|
||||||
// profileId steckt im JWT — der API-Server filtert danach
|
|
||||||
let url = `${BASE}/languparent/progress`
|
|
||||||
if (toLangId) url += `?lang=${toLangId}`
|
|
||||||
const res = await fetch(url, { headers: auth(userToken) })
|
|
||||||
const data = await res.json()
|
|
||||||
return data || []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveProgress(payload, userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/progress`, {
|
|
||||||
method: 'POST', headers: auth(userToken),
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) return null
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Assets ────────────────────────────────────────────────────────────────────
|
// ── Fortschritt / EP ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Bilder via API-Server proxied — kein Directus-Token im Browser nötig
|
// Verbucht eine gelöste Karte: aktualisiert EP, Streak und Pair-Statistik.
|
||||||
export function assetUrl(fileId /*, _userToken — nicht mehr nötig */) {
|
// Gibt { total_ep, streak_days, level } zurück.
|
||||||
if (!fileId) return null
|
export async function saveProgress({ pairId, correct, points, userToken }) {
|
||||||
return `${BASE}/languparent/assets/${fileId}`
|
const res = await fetch(`${BASE}/auth/progress`, {
|
||||||
|
method: 'POST', headers: auth(userToken),
|
||||||
|
body: JSON.stringify({ pair_id: pairId, correct: !!correct, points: points || 0 }),
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Fortschritt konnte nicht gespeichert werden.')
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Profil ────────────────────────────────────────────────────────────────────
|
// Aktueller Gesamtfortschritt des Users (EP, Streak, Level) via /auth/me.
|
||||||
|
export async function getUserProgress(userToken) {
|
||||||
export async function getProfilData(userToken) {
|
const me = await getMe(userToken)
|
||||||
return getMe(userToken)
|
return { total_ep: me.total_ep || 0, streak_days: me.streak_days || 0, level: me.level || 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stub — Punkte laufen über addPointsToPair
|
// Detaillierte Statistik fürs Profil: { daily[], today, totals, skills }.
|
||||||
|
export async function getStats(userToken) {
|
||||||
|
const res = await fetch(`${BASE}/auth/stats`, { headers: auth(userToken) })
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Statistik konnte nicht geladen werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Erfolge mit Freischalt-Status: [{ key, label, icon, unlocked, unlocked_at }].
|
||||||
|
export async function getAchievements(userToken) {
|
||||||
|
const res = await fetch(`${BASE}/auth/achievements`, { headers: auth(userToken) })
|
||||||
|
const data = await res.json().catch(() => [])
|
||||||
|
if (!res.ok) throw new Error('Erfolge konnten nicht geladen werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagesziel (EP/Tag) setzen. Gibt { daily_goal_ep } zurück.
|
||||||
|
export async function setDailyGoal(dailyGoalEp, userToken) {
|
||||||
|
const res = await fetch(`${BASE}/auth/goal`, {
|
||||||
|
method: 'PUT', headers: auth(userToken),
|
||||||
|
body: JSON.stringify({ daily_goal_ep: dailyGoalEp }),
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Tagesziel konnte nicht gespeichert werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stubs (content-Endpunkte kommen später) ───────────────────────────────────
|
||||||
|
|
||||||
|
export async function getActiveLearningPair() { return null }
|
||||||
|
export async function addPointsToPair() { return false }
|
||||||
|
export async function getWords() { return [] }
|
||||||
|
export async function getQuestions() { return [] }
|
||||||
|
export async function getQAPairsAtLevel() { return [] }
|
||||||
|
export function assetUrl(fileId) { return fileId || null }
|
||||||
|
export async function getProfilData(userToken) { return getMe(userToken) }
|
||||||
export async function addPointsToUser() { return true }
|
export async function addPointsToUser() { return true }
|
||||||
|
|||||||
34
src/assets/logo.svg
Normal file
34
src/assets/logo.svg
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" role="img">
|
||||||
|
<title>Snakkimo Logo</title>
|
||||||
|
<desc>Offenes Buch mit wachsendem Blatt und Sonne, in den App-Farben</desc>
|
||||||
|
<rect width="1024" height="1024" fill="#EDE0CE"/>
|
||||||
|
|
||||||
|
<path d="M220 400
|
||||||
|
C140 440 130 540 130 620
|
||||||
|
C130 740 230 800 380 810
|
||||||
|
L644 810
|
||||||
|
C794 800 894 740 894 620
|
||||||
|
C894 540 884 440 804 400"
|
||||||
|
fill="none" stroke="#7A5C3A" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<g fill="none" stroke="#5C3D22" stroke-width="12" stroke-linecap="round">
|
||||||
|
<path d="M500 800 C460 680 380 540 260 430"/>
|
||||||
|
<path d="M524 800 C580 680 680 540 800 430"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g>
|
||||||
|
<path d="M512 800
|
||||||
|
C560 620 680 380 820 200
|
||||||
|
C840 380 800 580 700 720
|
||||||
|
C640 790 560 800 512 800 Z" fill="#C4A85A"/>
|
||||||
|
<path d="M520 780 C580 620 700 400 820 220"
|
||||||
|
fill="none" stroke="#7A5C3A" stroke-width="14" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g stroke="#C4A85A" stroke-width="22" stroke-linecap="round">
|
||||||
|
<line x1="840" y1="30" x2="840" y2="75"/>
|
||||||
|
<line x1="790" y1="45" x2="815" y2="82"/>
|
||||||
|
<line x1="895" y1="50" x2="872" y2="88"/>
|
||||||
|
<line x1="945" y1="95" x2="908" y2="118"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
82
src/components/ComingSoon.css
Normal file
82
src/components/ComingSoon.css
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
.coming-soon {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: var(--sp-5);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: var(--sp-6) var(--sp-5);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-icon {
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
margin: 0 auto var(--sp-4);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.cs-icon svg { width: 32px; height: 32px; }
|
||||||
|
|
||||||
|
.cs-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold);
|
||||||
|
background: var(--gold-soft);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 4px 12px;
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-teaser {
|
||||||
|
list-style: none;
|
||||||
|
text-align: left;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.cs-teaser li {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
padding-left: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cs-teaser li::before {
|
||||||
|
content: '✦';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
20
src/components/ComingSoon.jsx
Normal file
20
src/components/ComingSoon.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import './ComingSoon.css'
|
||||||
|
|
||||||
|
// Gestaltetes "Bald verfügbar" für noch nicht gebaute Bereiche.
|
||||||
|
export default function ComingSoon({ icon, title, subtitle, teaser }) {
|
||||||
|
return (
|
||||||
|
<div className="coming-soon page-enter">
|
||||||
|
<div className="cs-card">
|
||||||
|
<div className="cs-icon">{icon}</div>
|
||||||
|
<span className="cs-badge">Bald verfügbar</span>
|
||||||
|
<h2 className="cs-title">{title}</h2>
|
||||||
|
<p className="cs-subtitle">{subtitle}</p>
|
||||||
|
{teaser && (
|
||||||
|
<ul className="cs-teaser">
|
||||||
|
{teaser.map((t, i) => <li key={i}>{t}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/components/EpFloat.jsx
Normal file
12
src/components/EpFloat.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import './Moments.css'
|
||||||
|
|
||||||
|
// Kurzes „+N EP", das vom Bestätigen-Button aufschwebt — Belohnung genau dort,
|
||||||
|
// wo das Auge ist. Eltern-Element braucht position: relative.
|
||||||
|
export default function EpFloat({ points, onDone }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => onDone?.(), 1000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [onDone])
|
||||||
|
return <span className="ep-float" aria-hidden="true">+{points} EP</span>
|
||||||
|
}
|
||||||
37
src/components/MilestoneOverlay.jsx
Normal file
37
src/components/MilestoneOverlay.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import confetti from 'canvas-confetti'
|
||||||
|
import './Moments.css'
|
||||||
|
|
||||||
|
const COLORS = ['#C4A85A', '#7A5C2E', '#3D7055', '#E8C9A8', '#fff']
|
||||||
|
|
||||||
|
function celebrate() {
|
||||||
|
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
|
||||||
|
if (reduce) return
|
||||||
|
confetti({ particleCount: 140, spread: 90, origin: { y: 0.5 }, colors: COLORS, scalar: 1 })
|
||||||
|
setTimeout(() => confetti({ particleCount: 70, spread: 110, origin: { y: 0.45 }, colors: COLORS, scalar: 0.8 }), 220)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Texte je Milestone-Art. value = Level-Nummer / Streak-Tage / Tagesziel-EP.
|
||||||
|
function content(m) {
|
||||||
|
const { kind, value } = m
|
||||||
|
if (kind === 'level') return { cls: '', icon: '🏆', title: `Level ${value} erreicht!`, sub: 'Du wächst spürbar — weiter so.' }
|
||||||
|
if (kind === 'streak') return { cls: 'streak', icon: '🔥', title: `${value} Tage am Stück!`, sub: 'Dranbleiben zahlt sich aus.' }
|
||||||
|
if (kind === 'goal') return { cls: 'goal', icon: '🎯', title: 'Tagesziel erreicht!', sub: 'Stark — heute hast du dein Pensum geschafft.' }
|
||||||
|
if (kind === 'achievement') return { cls: 'streak', icon: m.icon || '🎖️', title: m.label || 'Erfolg!', sub: 'Erfolg freigeschaltet! 🎉' }
|
||||||
|
return { cls: '', icon: '🎉', title: 'Geschafft!', sub: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MilestoneOverlay({ milestone, onClose }) {
|
||||||
|
useEffect(() => { celebrate() }, [milestone])
|
||||||
|
const { cls, icon, title, sub } = content(milestone)
|
||||||
|
return (
|
||||||
|
<div className="milestone-overlay" onClick={onClose} role="dialog" aria-label={title}>
|
||||||
|
<div className="milestone-card" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={`milestone-badge ${cls}`} aria-hidden="true">{icon}</div>
|
||||||
|
<h2 className="milestone-title">{title}</h2>
|
||||||
|
{sub && <p className="milestone-sub">{sub}</p>}
|
||||||
|
<button className="milestone-btn" onClick={onClose} autoFocus>Weiter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
src/components/Moments.css
Normal file
203
src/components/Moments.css
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/* ── EP-Float: „+3 EP" schwebt vom Button auf ───────────────── */
|
||||||
|
.ep-float {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 100%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--gold-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
animation: epFloatUp 1s var(--ease) forwards;
|
||||||
|
}
|
||||||
|
@keyframes epFloatUp {
|
||||||
|
0% { opacity: 0; transform: translateX(-50%) translateY(8px) scale(0.8); }
|
||||||
|
20% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.05); }
|
||||||
|
35% { transform: translateX(-50%) translateY(0) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translateX(-50%) translateY(-46px) scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Combo-Pill (oben rechts, neben dem zentrierten EP-Badge) ── */
|
||||||
|
.combo-pill {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(env(safe-area-inset-top, 0px) + 14px);
|
||||||
|
right: 14px;
|
||||||
|
z-index: 30;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #F5EFE6;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
animation: comboPop 0.32s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes comboPop {
|
||||||
|
from { opacity: 0; transform: translateY(-6px) scale(0.85); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Streak-Nudge (Loss-Aversion-Bar oben) ──────────────────── */
|
||||||
|
.streak-nudge {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
padding: calc(env(safe-area-inset-top, 0px) + 9px) 16px 9px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #F5EFE6;
|
||||||
|
font-size: 13px; font-weight: 700;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
animation: comboPop 0.3s var(--ease);
|
||||||
|
}
|
||||||
|
.streak-nudge-x {
|
||||||
|
flex: none;
|
||||||
|
background: rgba(255, 255, 255, 0.18); border: none; color: #F5EFE6;
|
||||||
|
width: 22px; height: 22px; border-radius: 50%;
|
||||||
|
font-size: 15px; line-height: 1; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Milestone-Overlay (Level-Up / Streak / Tagesziel) ──────── */
|
||||||
|
.milestone-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-5);
|
||||||
|
background: rgba(58, 37, 21, 0.55);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
-webkit-backdrop-filter: blur(3px);
|
||||||
|
animation: msFade 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes msFade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.milestone-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: var(--sp-6) var(--sp-5) var(--sp-5);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
animation: msPop 0.4s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes msPop {
|
||||||
|
from { opacity: 0; transform: translateY(14px) scale(0.92); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-badge {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
margin: 0 auto var(--sp-4);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40px;
|
||||||
|
background: var(--gold-soft);
|
||||||
|
animation: msBadge 0.6s var(--ease) 0.1s both;
|
||||||
|
}
|
||||||
|
@keyframes msBadge {
|
||||||
|
0% { transform: scale(0.4) rotate(-12deg); opacity: 0; }
|
||||||
|
60% { transform: scale(1.12) rotate(4deg); opacity: 1; }
|
||||||
|
100% { transform: scale(1) rotate(0); }
|
||||||
|
}
|
||||||
|
.milestone-badge.streak { background: #F6E0CB; }
|
||||||
|
.milestone-badge.goal { background: var(--success-soft); }
|
||||||
|
|
||||||
|
.milestone-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.milestone-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 var(--sp-5);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.milestone-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #F5EFE6;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.milestone-btn:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
/* ── Session-Summary (ersetzt die End-Sackgasse) ────────────── */
|
||||||
|
.session-summary {
|
||||||
|
margin: var(--sp-4) auto;
|
||||||
|
width: calc(100% - 2 * var(--sp-4));
|
||||||
|
max-width: 460px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: var(--sp-5) var(--sp-5) var(--sp-4);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.session-summary .ss-icon {
|
||||||
|
width: 64px; height: 64px;
|
||||||
|
margin: 0 auto var(--sp-3);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gold-soft);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.session-summary .ss-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 21px; font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
.session-summary .ss-sub {
|
||||||
|
font-size: 13px; color: var(--text-muted); margin: 0 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
.ss-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
.ss-stat {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: var(--sp-3) var(--sp-2);
|
||||||
|
}
|
||||||
|
.ss-stat .n { display: block; font-size: 20px; font-weight: 800; color: var(--text-strong); }
|
||||||
|
.ss-stat .c { display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
.ss-story {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ss-story .si { font-size: 18px; }
|
||||||
@@ -6,13 +6,10 @@
|
|||||||
.pair-card {
|
.pair-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 380px;
|
max-width: 380px;
|
||||||
border-radius: 22px;
|
border-radius: var(--r-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #FDFAF6;
|
background: var(--surface);
|
||||||
box-shadow:
|
box-shadow: var(--shadow-card);
|
||||||
0 1px 2px rgba(60, 40, 20, 0.06),
|
|
||||||
0 4px 16px rgba(60, 40, 20, 0.09),
|
|
||||||
0 12px 40px rgba(60, 40, 20, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header (sits below image) ── */
|
/* ── Header (sits below image) ── */
|
||||||
@@ -27,14 +24,14 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.09em;
|
letter-spacing: 0.09em;
|
||||||
color: #6B6556;
|
color: #6B6556;
|
||||||
font-family: 'DM Sans', 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.pair-points-pill {
|
.pair-points-pill {
|
||||||
color: #C4A85A;
|
color: var(--gold);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: 'DM Sans', 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -54,7 +51,7 @@
|
|||||||
letter-spacing: 0.10em;
|
letter-spacing: 0.10em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #A89F8C;
|
color: #A89F8C;
|
||||||
font-family: 'DM Sans', 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +68,31 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
|
color: #7A6E55;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.pair-icon-btn:hover { background: #E0DAC8; }
|
.pair-icon-btn:hover { background: #E0DAC8; }
|
||||||
.pair-icon-btn.active { background: #E0DAC8; }
|
.pair-icon-btn.active { background: #C4A85A22; color: #B07840; }
|
||||||
|
|
||||||
|
/* Hold-to-translate button */
|
||||||
|
.pair-hold-wrap {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ring fill animation — 2 seconds */
|
||||||
|
@keyframes holdRing {
|
||||||
|
from { stroke-dashoffset: 100.53; }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Image area ── */
|
/* ── Image area ── */
|
||||||
.pair-image-wrap {
|
.pair-image-wrap {
|
||||||
@@ -135,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
.pair-chip-highlight-target {
|
.pair-chip-highlight-target {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Lora', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3A2515;
|
color: #3A2515;
|
||||||
@@ -144,7 +160,7 @@
|
|||||||
}
|
}
|
||||||
.pair-chip-highlight-native {
|
.pair-chip-highlight-native {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #9A7D60;
|
color: #9A7D60;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -179,7 +195,7 @@
|
|||||||
background: #F0EDE3;
|
background: #F0EDE3;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
border: 0.5px solid #D8D3C5;
|
border: 0.5px solid #D8D3C5;
|
||||||
font-family: 'Lora', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #7A5C2E;
|
color: #7A5C2E;
|
||||||
@@ -196,6 +212,12 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clickable sentence — tap to play audio */
|
||||||
|
.pair-sentence-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Word chips inside sentences — underline italic style */
|
/* Word chips inside sentences — underline italic style */
|
||||||
.pair-word-chip {
|
.pair-word-chip {
|
||||||
display: inline;
|
display: inline;
|
||||||
@@ -227,7 +249,7 @@
|
|||||||
|
|
||||||
/* Sentence text (text-type cards) */
|
/* Sentence text (text-type cards) */
|
||||||
.pair-sentence {
|
.pair-sentence {
|
||||||
font-family: 'Lora', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: #3A2515;
|
color: #3A2515;
|
||||||
@@ -236,7 +258,7 @@
|
|||||||
|
|
||||||
/* Question text */
|
/* Question text */
|
||||||
.pair-question {
|
.pair-question {
|
||||||
font-family: 'Lora', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
@@ -246,7 +268,7 @@
|
|||||||
|
|
||||||
/* Hint — native language translation */
|
/* Hint — native language translation */
|
||||||
.pair-hint {
|
.pair-hint {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #A08868;
|
color: #A08868;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -263,7 +285,7 @@
|
|||||||
padding: 14px 10px;
|
padding: 14px 10px;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
border: none;
|
border: none;
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -275,19 +297,24 @@
|
|||||||
.pair-btn:disabled { opacity: 0.75; cursor: default; }
|
.pair-btn:disabled { opacity: 0.75; cursor: default; }
|
||||||
|
|
||||||
.pair-btn-primary {
|
.pair-btn-primary {
|
||||||
background: #5C3D22;
|
background: var(--accent-strong);
|
||||||
color: #F5EDE0;
|
color: #F5EDE0;
|
||||||
}
|
}
|
||||||
|
.pair-btn-locked {
|
||||||
|
background: #E0DDD5;
|
||||||
|
color: #B0A898;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.pair-btn-yes {
|
.pair-btn-yes {
|
||||||
background: #3D7055;
|
background: var(--success);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.pair-btn-no {
|
.pair-btn-no {
|
||||||
background: #A84040;
|
background: var(--danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.pair-btn-correct { background: #3D7055; color: #fff; }
|
.pair-btn-correct { background: var(--success); color: #fff; }
|
||||||
.pair-btn-wrong { background: #A84040; color: #fff; }
|
.pair-btn-wrong { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
/* ── Word option buttons ── */
|
/* ── Word option buttons ── */
|
||||||
.pair-options {
|
.pair-options {
|
||||||
@@ -302,7 +329,7 @@
|
|||||||
border: 1.5px solid #DDD0BF;
|
border: 1.5px solid #DDD0BF;
|
||||||
background: #FDFAF6;
|
background: #FDFAF6;
|
||||||
color: #3A2515;
|
color: #3A2515;
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -313,17 +340,18 @@
|
|||||||
border-color: #C8956A;
|
border-color: #C8956A;
|
||||||
}
|
}
|
||||||
.pair-option-btn:active:not(:disabled) { transform: scale(0.95); }
|
.pair-option-btn:active:not(:disabled) { transform: scale(0.95); }
|
||||||
.pair-option-btn.correct { background: #3D7055; color: #fff; border-color: #3D7055; }
|
.pair-option-btn.selected { background: #FFF0DC; border-color: #C4A85A; color: #5C3D22; }
|
||||||
.pair-option-btn.wrong { background: #A84040; color: #fff; border-color: #A84040; }
|
.pair-option-btn.correct { background: var(--success); color: #fff; border-color: var(--success); }
|
||||||
|
.pair-option-btn.wrong { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||||
.pair-option-btn:disabled { cursor: default; }
|
.pair-option-btn:disabled { cursor: default; }
|
||||||
|
|
||||||
/* ── Feedback ── */
|
/* ── Feedback ── */
|
||||||
.pair-feedback {
|
.pair-feedback {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 12px 0 2px;
|
padding: 12px 0 2px;
|
||||||
color: #3A2515;
|
color: #3A2515;
|
||||||
}
|
}
|
||||||
.pair-feedback.correct { color: #3D7055; }
|
.pair-feedback.correct { color: var(--success); }
|
||||||
.pair-feedback.wrong { color: #A84040; }
|
.pair-feedback.wrong { color: var(--danger); }
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useMemo } from 'react'
|
||||||
import confetti from 'canvas-confetti'
|
import confetti from 'canvas-confetti'
|
||||||
|
import usePairAudio from '../hooks/usePairAudio'
|
||||||
|
import SelectionOverlay from './SelectionOverlay'
|
||||||
|
import EpFloat from './EpFloat'
|
||||||
|
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
||||||
|
import { speak } from '../utils/speak'
|
||||||
import './PairCards.css'
|
import './PairCards.css'
|
||||||
|
|
||||||
function triggerConfetti() {
|
function triggerConfetti() {
|
||||||
@@ -13,45 +18,11 @@ function triggerConfetti() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectionOverlay({ chip }) {
|
|
||||||
const sels = chip?.selections
|
|
||||||
if (!sels?.length) return null
|
|
||||||
const maskId = `selmask-${chip.id.slice(0, 8)}`
|
|
||||||
const label = chip.label || ''
|
|
||||||
const toPoints = pts => pts.map(p => `${p.x * 100},${p.y * 100}`).join(' ')
|
|
||||||
const firstPts = sels[0].points
|
|
||||||
const xs = firstPts.map(p => p.x * 100)
|
|
||||||
const ys = firstPts.map(p => p.y * 100)
|
|
||||||
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
|
|
||||||
const labelY = Math.min(Math.max(...ys) + 6, 94)
|
|
||||||
return (
|
|
||||||
<svg className="pair-bbox-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
|
||||||
<defs>
|
|
||||||
<mask id={maskId}>
|
|
||||||
<rect width="100" height="100" fill="white" />
|
|
||||||
{sels.map((s, i) => <polygon key={i} points={toPoints(s.points)} fill="black" />)}
|
|
||||||
</mask>
|
|
||||||
</defs>
|
|
||||||
<rect width="100" height="100" fill="rgba(0,0,0,0.5)" mask={`url(#${maskId})`} />
|
|
||||||
{sels.map((s, i) => (
|
|
||||||
<polygon key={i} points={toPoints(s.points)}
|
|
||||||
fill="rgba(255,215,100,0.08)" stroke="rgba(255,215,100,0.92)"
|
|
||||||
strokeWidth="0.8" strokeLinejoin="round" />
|
|
||||||
))}
|
|
||||||
{label && (
|
|
||||||
<text x={cx} y={labelY} textAnchor="middle"
|
|
||||||
fill="white" fontSize="5.5" fontWeight="700" fontFamily="Nunito, sans-serif"
|
|
||||||
style={{ filter: 'drop-shadow(0 1px 4px rgba(0,0,0,1))' }}>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
|
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
|
||||||
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
||||||
if (!sentence) return null
|
if (!sentence) return null
|
||||||
|
// Unaufgelöste Pipeline-Tokens (⟦PHn:wort⟧) defensiv als blankes Wort anzeigen
|
||||||
|
sentence = sentence.replace(/[⟦〚]PH\d+:([^⟧〛]*)[⟧〛]/g, '$1')
|
||||||
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
|
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
|
||||||
return parts.map((part, i) => {
|
return parts.map((part, i) => {
|
||||||
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
|
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
|
||||||
@@ -84,20 +55,20 @@ function extractVocab(sentence) {
|
|||||||
.map(m => ({ label: m[1], id: m[2] }))
|
.map(m => ({ label: m[1], id: m[2] }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip placeholders to plain text for TTS
|
|
||||||
function toPlainText(sentence) {
|
|
||||||
if (!sentence) return ''
|
|
||||||
return sentence.replace(/\{\{([^.]+)\.[wo]:[0-9a-f-]{36}\}\}/g, '$1')
|
|
||||||
}
|
|
||||||
|
|
||||||
const LANG_LABELS = { sv: 'Svenska', en: 'English', de: 'Deutsch' }
|
const LANG_LABELS = { sv: 'Svenska', en: 'English', de: 'Deutsch' }
|
||||||
const LANG_TTS = { sv: 'sv-SE', en: 'en-US', de: 'de-DE' }
|
|
||||||
|
// Circumference of r=16 circle ≈ 100.53
|
||||||
|
const RING_C = 2 * Math.PI * 16
|
||||||
|
|
||||||
export default function PairSentenceCard({ card, onComplete }) {
|
export default function PairSentenceCard({ card, onComplete }) {
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [activeChip, setActiveChip] = useState(null)
|
const [activeChip, setActiveChip] = useState(null)
|
||||||
const [showTranslation, setShowTranslation] = useState(false)
|
const [showTranslation, setShowTranslation] = useState(false)
|
||||||
const holdTimer = useRef(null)
|
const [holding, setHolding] = useState(false)
|
||||||
|
const [unlocked, setUnlocked] = useState(false)
|
||||||
|
const [showFloat, setShowFloat] = useState(false)
|
||||||
|
const holdCompleted = useRef(false)
|
||||||
|
const points = card.meta?.points ?? 2
|
||||||
|
|
||||||
const lang = card.lang || 'de'
|
const lang = card.lang || 'de'
|
||||||
const native = lang === 'de' ? 'en' : 'de'
|
const native = lang === 'de' ? 'en' : 'de'
|
||||||
@@ -107,6 +78,18 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
const hint = stmt?.[`sentence_${native}`] || null
|
const hint = stmt?.[`sentence_${native}`] || null
|
||||||
const pic = card.picture?.url
|
const pic = card.picture?.url
|
||||||
|
|
||||||
|
const { play, playing, currentTime } = usePairAudio(stmt?.audio_url)
|
||||||
|
|
||||||
|
// Karaoke: aktives Chip aus den Audio-Timestamps ableiten, solange vorgelesen wird.
|
||||||
|
const timings = useMemo(
|
||||||
|
() => buildChipTimings(sentence, stmt?.audio_alignment),
|
||||||
|
[sentence, stmt?.audio_alignment],
|
||||||
|
)
|
||||||
|
const audioChipId = playing ? activeChipIdAt(timings, currentTime) : null
|
||||||
|
const audioObject = audioChipId && card.placeholders?.[audioChipId]?.type === 'object'
|
||||||
|
? { id: audioChipId, ...card.placeholders[audioChipId] }
|
||||||
|
: null
|
||||||
|
|
||||||
const isWord = activeChip && activeChip.type === 'word'
|
const isWord = activeChip && activeChip.type === 'word'
|
||||||
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
|
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
|
||||||
|
|
||||||
@@ -117,27 +100,33 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
|
if (done || !unlocked) return
|
||||||
setDone(true)
|
setDone(true)
|
||||||
setActiveChip(null)
|
setActiveChip(null)
|
||||||
|
setShowFloat(true)
|
||||||
triggerConfetti()
|
triggerConfetti()
|
||||||
setTimeout(() => onComplete('correct'), 900)
|
setTimeout(() => onComplete('correct'), 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTTS() {
|
function handlePlay() {
|
||||||
if (!window.speechSynthesis || !sentence) return
|
// play() liefert false, wenn kein Audio-File da ist ODER es nicht geladen
|
||||||
window.speechSynthesis.cancel()
|
// werden konnte → stiller TTS-Fallback.
|
||||||
const utt = new SpeechSynthesisUtterance(toPlainText(sentence))
|
if (play()) { setUnlocked(true); return }
|
||||||
utt.lang = LANG_TTS[lang] || 'de-DE'
|
if (speak(sentence, lang)) setUnlocked(true)
|
||||||
utt.rate = 0.9
|
|
||||||
window.speechSynthesis.speak(utt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTranslation() {
|
function startHold() {
|
||||||
holdTimer.current = setTimeout(() => setShowTranslation(true), 150)
|
holdCompleted.current = false
|
||||||
|
setHolding(true)
|
||||||
|
setShowTranslation(true)
|
||||||
}
|
}
|
||||||
function endTranslation() {
|
function endHold() {
|
||||||
clearTimeout(holdTimer.current)
|
setHolding(false)
|
||||||
setShowTranslation(false)
|
if (!holdCompleted.current) setShowTranslation(false)
|
||||||
|
}
|
||||||
|
function onHoldComplete() {
|
||||||
|
holdCompleted.current = true
|
||||||
|
setUnlocked(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,26 +146,18 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isObject && <SelectionOverlay chip={activeChip} />}
|
{isObject && <SelectionOverlay chip={activeChip} />}
|
||||||
|
{!isObject && audioObject && <SelectionOverlay chip={audioObject} outlineOnly />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header below image */}
|
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
|
||||||
<div className="pair-card-header">
|
|
||||||
<span className="pair-lang-pill">{LANG_LABELS[lang] || lang}</span>
|
|
||||||
<span className="pair-points-pill">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
|
||||||
+{card.meta?.points ?? 2} Punkte
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pair-header-divider" />
|
|
||||||
|
|
||||||
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
|
|
||||||
|
|
||||||
{/* Sentence + action buttons */}
|
{/* Sentence + action buttons */}
|
||||||
<p className="pair-section-label">Satz</p>
|
|
||||||
<div className="pair-sentence-row">
|
<div className="pair-sentence-row">
|
||||||
<div className="pair-sentence-text">
|
<div className="pair-sentence-text">
|
||||||
<p className="pair-sentence" style={{ opacity: showTranslation ? 0 : 1, transition: 'opacity 0.18s', margin: 0 }}>
|
<p className="pair-sentence pair-sentence-clickable" onClick={handlePlay}
|
||||||
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
style={{ opacity: showTranslation ? 0 : 1, transition: 'opacity 0.18s', margin: 0 }}>
|
||||||
|
{resolveSentence(sentence, card.placeholders, handleChipClick, audioChipId ?? activeChip?.id)}
|
||||||
</p>
|
</p>
|
||||||
{hint && (
|
{hint && (
|
||||||
<p className="pair-sentence" style={{
|
<p className="pair-sentence" style={{
|
||||||
@@ -184,7 +165,7 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
color: '#7A7060',
|
color: '#7A7060',
|
||||||
transition: 'opacity 0.18s',
|
transition: 'opacity 0.18s',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
marginTop: showTranslation ? 0 : '-1.7em', /* overlay effect */
|
marginTop: showTranslation ? 0 : '-1.7em',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}>
|
}}>
|
||||||
{resolveSentence(hint, card.placeholders, null, null)}
|
{resolveSentence(hint, card.placeholders, null, null)}
|
||||||
@@ -193,30 +174,52 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}>
|
||||||
{/* TTS */}
|
{/* Vorlesen — playing unlocks "Verstanden" */}
|
||||||
<button className="pair-icon-btn" onClick={handleTTS} title="Vorlesen">
|
<button className={`pair-icon-btn${unlocked || playing ? ' active' : ''}`} onClick={handlePlay} title="Vorlesen">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7A6E55" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* Hold-to-translate */}
|
|
||||||
|
{/* Hold-to-translate: 2 s hold unlocks "Verstanden" */}
|
||||||
{hint && (
|
{hint && (
|
||||||
<button
|
<div className="pair-hold-wrap"
|
||||||
className={`pair-icon-btn${showTranslation ? ' active' : ''}`}
|
onMouseDown={startHold}
|
||||||
onMouseDown={startTranslation}
|
onMouseUp={endHold}
|
||||||
onMouseUp={endTranslation}
|
onMouseLeave={endHold}
|
||||||
onMouseLeave={endTranslation}
|
onTouchStart={e => { e.preventDefault(); startHold() }}
|
||||||
onTouchStart={e => { e.preventDefault(); startTranslation() }}
|
onTouchEnd={endHold}
|
||||||
onTouchEnd={endTranslation}
|
title="2 s halten zum Übersetzen"
|
||||||
title="Übersetzung halten"
|
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7A6E55" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="38" height="38" viewBox="0 0 38 38" style={{ display: 'block' }}>
|
||||||
<path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/>
|
{/* Button fill */}
|
||||||
<path d="M22 22l-5-10-5 10"/><path d="M14 18h6"/>
|
<rect x="1" y="1" width="36" height="36" rx="9" ry="9"
|
||||||
|
fill="#F0EDE3" stroke="#D8D3C5" strokeWidth="0.5"/>
|
||||||
|
{/* Progress ring track */}
|
||||||
|
<circle cx="19" cy="19" r="16" fill="none" stroke="#E0DDD5" strokeWidth="2.5"/>
|
||||||
|
{/* Progress ring — animates when holding */}
|
||||||
|
{holding && (
|
||||||
|
<circle
|
||||||
|
cx="19" cy="19" r="16"
|
||||||
|
fill="none"
|
||||||
|
stroke="#C4A85A"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeDasharray={RING_C}
|
||||||
|
strokeDashoffset={RING_C}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ transformOrigin: '19px 19px', transform: 'rotate(-90deg)', animation: 'holdRing 2s linear forwards' }}
|
||||||
|
onAnimationEnd={onHoldComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Translate icon */}
|
||||||
|
<g transform="translate(10, 10)" fill="none" stroke="#7A6E55" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/>
|
||||||
|
<path d="M14 14l-3-6-3 6"/><path d="M6 12h4" transform="translate(8,0)"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +227,7 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
{/* Vocabulary */}
|
{/* Vocabulary */}
|
||||||
{vocab.length > 0 && (
|
{vocab.length > 0 && (
|
||||||
<div className="pair-vocab-section">
|
<div className="pair-vocab-section">
|
||||||
<p className="pair-section-label">Vokabeln</p>
|
|
||||||
<div className="pair-vocab-chips">
|
<div className="pair-vocab-chips">
|
||||||
{vocab.map(v => (
|
{vocab.map(v => (
|
||||||
<span key={v.id} className="pair-vocab-word">{v.label}</span>
|
<span key={v.id} className="pair-vocab-word">{v.label}</span>
|
||||||
@@ -233,11 +236,12 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pair-btn-row" style={{ marginTop: 20 }}>
|
<div className="pair-btn-row" style={{ marginTop: 20, position: 'relative' }}>
|
||||||
|
{showFloat && <EpFloat points={points} onDone={() => setShowFloat(false)} />}
|
||||||
<button
|
<button
|
||||||
className={`pair-btn ${done ? 'pair-btn-correct' : 'pair-btn-primary'}`}
|
className={`pair-btn ${done ? 'pair-btn-correct' : unlocked ? 'pair-btn-primary' : 'pair-btn-locked'}`}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={done}
|
disabled={done || !unlocked}
|
||||||
>
|
>
|
||||||
{done ? '✓ Verstanden' : 'Verstanden'}
|
{done ? '✓ Verstanden' : 'Verstanden'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import confetti from 'canvas-confetti'
|
import confetti from 'canvas-confetti'
|
||||||
|
import usePairAudio from '../hooks/usePairAudio'
|
||||||
|
import SelectionOverlay from './SelectionOverlay'
|
||||||
|
import EpFloat from './EpFloat'
|
||||||
|
import { praise, encourage } from '../utils/praise'
|
||||||
|
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
||||||
|
import { speak } from '../utils/speak'
|
||||||
import './PairCards.css'
|
import './PairCards.css'
|
||||||
|
|
||||||
function triggerConfetti() {
|
function triggerConfetti() {
|
||||||
@@ -13,45 +19,11 @@ function triggerConfetti() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectionOverlay({ chip }) {
|
|
||||||
const sels = chip?.selections
|
|
||||||
if (!sels?.length) return null
|
|
||||||
const maskId = `selmask-${chip.id.slice(0, 8)}`
|
|
||||||
const label = chip.label || ''
|
|
||||||
const toPoints = pts => pts.map(p => `${p.x * 100},${p.y * 100}`).join(' ')
|
|
||||||
const firstPts = sels[0].points
|
|
||||||
const xs = firstPts.map(p => p.x * 100)
|
|
||||||
const ys = firstPts.map(p => p.y * 100)
|
|
||||||
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
|
|
||||||
const labelY = Math.min(Math.max(...ys) + 6, 94)
|
|
||||||
return (
|
|
||||||
<svg className="pair-bbox-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
|
||||||
<defs>
|
|
||||||
<mask id={maskId}>
|
|
||||||
<rect width="100" height="100" fill="white" />
|
|
||||||
{sels.map((s, i) => <polygon key={i} points={toPoints(s.points)} fill="black" />)}
|
|
||||||
</mask>
|
|
||||||
</defs>
|
|
||||||
<rect width="100" height="100" fill="rgba(0,0,0,0.5)" mask={`url(#${maskId})`} />
|
|
||||||
{sels.map((s, i) => (
|
|
||||||
<polygon key={i} points={toPoints(s.points)}
|
|
||||||
fill="rgba(255,215,100,0.08)" stroke="rgba(255,215,100,0.92)"
|
|
||||||
strokeWidth="0.8" strokeLinejoin="round" />
|
|
||||||
))}
|
|
||||||
{label && (
|
|
||||||
<text x={cx} y={labelY} textAnchor="middle"
|
|
||||||
fill="white" fontSize="5.5" fontWeight="700" fontFamily="Nunito, sans-serif"
|
|
||||||
style={{ filter: 'drop-shadow(0 1px 4px rgba(0,0,0,1))' }}>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
|
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
|
||||||
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
||||||
if (!sentence) return null
|
if (!sentence) return null
|
||||||
|
// Unaufgelöste Pipeline-Tokens (⟦PHn:wort⟧) defensiv als blankes Wort anzeigen
|
||||||
|
sentence = sentence.replace(/[⟦〚]PH\d+:([^⟧〛]*)[⟧〛]/g, '$1')
|
||||||
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
|
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
|
||||||
return parts.map((part, i) => {
|
return parts.map((part, i) => {
|
||||||
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
|
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
|
||||||
@@ -84,8 +56,14 @@ function shuffle(arr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PairWordCard({ card, onComplete }) {
|
export default function PairWordCard({ card, onComplete }) {
|
||||||
const [picked, setPicked] = useState(null)
|
const [selectedIds, setSelectedIds] = useState(new Set()) // toggled, not yet confirmed
|
||||||
const [activeChip, setActiveChip] = useState(null)
|
const [confirmed, setConfirmed] = useState(false) // after Bestätigen
|
||||||
|
const [isCorrect, setIsCorrect] = useState(false)
|
||||||
|
const [activeChip, setActiveChip] = useState(null)
|
||||||
|
const [showFloat, setShowFloat] = useState(false)
|
||||||
|
const [praiseWord] = useState(() => praise())
|
||||||
|
const [encourageWord] = useState(() => encourage())
|
||||||
|
const points = card.meta?.points ?? 3
|
||||||
|
|
||||||
const lang = card.lang || 'de'
|
const lang = card.lang || 'de'
|
||||||
const native = lang === 'de' ? 'en' : 'de'
|
const native = lang === 'de' ? 'en' : 'de'
|
||||||
@@ -101,6 +79,18 @@ export default function PairWordCard({ card, onComplete }) {
|
|||||||
const hint = q?.[`sentence_${native}`] || null
|
const hint = q?.[`sentence_${native}`] || null
|
||||||
const pic = card.picture?.url
|
const pic = card.picture?.url
|
||||||
|
|
||||||
|
const { play, playing, currentTime } = usePairAudio(q?.audio_url)
|
||||||
|
|
||||||
|
// Karaoke: aktives Chip aus den Audio-Timestamps der Frage ableiten.
|
||||||
|
const timings = useMemo(
|
||||||
|
() => buildChipTimings(sentence, q?.audio_alignment),
|
||||||
|
[sentence, q?.audio_alignment],
|
||||||
|
)
|
||||||
|
const audioChipId = playing ? activeChipIdAt(timings, currentTime) : null
|
||||||
|
const audioObject = audioChipId && card.placeholders?.[audioChipId]?.type === 'object'
|
||||||
|
? { id: audioChipId, ...card.placeholders[audioChipId] }
|
||||||
|
: null
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const pos = (stmt?.positive_words || []).map(w => ({ ...w, correct: true }))
|
const pos = (stmt?.positive_words || []).map(w => ({ ...w, correct: true }))
|
||||||
const neg2 = (neg?.negative_words || []).map(w => ({ ...w, correct: false }))
|
const neg2 = (neg?.negative_words || []).map(w => ({ ...w, correct: false }))
|
||||||
@@ -111,13 +101,30 @@ export default function PairWordCard({ card, onComplete }) {
|
|||||||
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
|
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePick(opt) {
|
// Audio-File abspielen; bei fehlendem/nicht ladbarem File still auf TTS zurückfallen.
|
||||||
if (picked) return
|
function handlePlay() {
|
||||||
|
if (!play()) speak(sentence, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(opt) {
|
||||||
|
if (confirmed) return
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(opt.id) ? next.delete(opt.id) : next.add(opt.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (selectedIds.size === 0 || confirmed) return
|
||||||
setActiveChip(null)
|
setActiveChip(null)
|
||||||
setPicked(opt)
|
const correctIds = new Set(options.filter(o => o.correct).map(o => o.id))
|
||||||
const r = opt.correct ? 'correct' : 'wrong'
|
const noWrongSelected = [...selectedIds].every(id => correctIds.has(id))
|
||||||
if (opt.correct) triggerConfetti()
|
const ok = noWrongSelected
|
||||||
setTimeout(() => onComplete(r), 900)
|
setIsCorrect(ok)
|
||||||
|
setConfirmed(true)
|
||||||
|
if (ok) { setShowFloat(true); triggerConfetti() }
|
||||||
|
setTimeout(() => onComplete(ok ? 'correct' : 'wrong'), 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -137,43 +144,46 @@ export default function PairWordCard({ card, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isObject && <SelectionOverlay chip={activeChip} />}
|
{isObject && <SelectionOverlay chip={activeChip} />}
|
||||||
|
{!isObject && audioObject && <SelectionOverlay chip={audioObject} outlineOnly />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header below image */}
|
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
|
||||||
<div className="pair-card-header">
|
|
||||||
<span className="pair-lang-pill">{lang === 'sv' ? 'Svenska' : lang === 'en' ? 'English' : 'Deutsch'}</span>
|
|
||||||
<span className="pair-points-pill">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
|
||||||
+{card.meta?.points ?? 3} Punkte
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pair-header-divider" />
|
|
||||||
|
|
||||||
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
|
<div className="pair-sentence-row">
|
||||||
<p className="pair-section-label">Frage</p>
|
<div className="pair-sentence-text">
|
||||||
<p className="pair-question">
|
<p className="pair-question pair-sentence-clickable" onClick={handlePlay}>
|
||||||
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
{resolveSentence(sentence, card.placeholders, handleChipClick, audioChipId ?? activeChip?.id)}
|
||||||
</p>
|
</p>
|
||||||
{hint && !picked && (
|
{hint && !confirmed && (
|
||||||
<p className="pair-hint">
|
<p className="pair-hint">
|
||||||
{resolveSentence(hint, card.placeholders, null, null)}
|
{resolveSentence(hint, card.placeholders, null, null)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<button className={`pair-icon-btn${playing ? ' active' : ''}`} onClick={handlePlay} title="Vorlesen">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="pair-section-label" style={{ marginTop: 14 }}>Antwort</p>
|
|
||||||
<div className="pair-options">
|
<div className="pair-options">
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const label = opt[lang] || opt.de || '…'
|
const label = opt[lang] || opt.de || '…'
|
||||||
const hint2 = opt[native] || null
|
const hint2 = opt[native] || null
|
||||||
let cls = 'pair-option-btn'
|
let cls = 'pair-option-btn'
|
||||||
if (picked) {
|
if (confirmed) {
|
||||||
if (opt.id === picked.id) cls += opt.correct ? ' correct' : ' wrong'
|
if (selectedIds.has(opt.id)) cls += opt.correct ? ' correct' : ' wrong'
|
||||||
else if (opt.correct) cls += ' correct'
|
else if (opt.correct) cls += ' correct'
|
||||||
|
} else if (selectedIds.has(opt.id)) {
|
||||||
|
cls += ' selected'
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button key={opt.id} className={cls} onClick={() => handlePick(opt)} disabled={!!picked}>
|
<button key={opt.id} className={cls} onClick={() => handleSelect(opt)} disabled={confirmed}>
|
||||||
{label}
|
{label}
|
||||||
{picked && hint2 && opt.correct && (
|
{confirmed && hint2 && opt.correct && (
|
||||||
<span style={{ display: 'block', fontSize: '11px', opacity: 0.85, marginTop: 2 }}>
|
<span style={{ display: 'block', fontSize: '11px', opacity: 0.85, marginTop: 2 }}>
|
||||||
{hint2}
|
{hint2}
|
||||||
</span>
|
</span>
|
||||||
@@ -183,13 +193,28 @@ export default function PairWordCard({ card, onComplete }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{picked && (
|
{confirmed && (
|
||||||
<p className={`pair-feedback ${picked.correct ? 'correct' : 'wrong'}`}>
|
<div style={{ position: 'relative' }}>
|
||||||
{picked.correct
|
{isCorrect && showFloat && <EpFloat points={points} onDone={() => setShowFloat(false)} />}
|
||||||
? '✓ Richtig!'
|
<p className={`pair-feedback ${isCorrect ? 'correct' : 'wrong'}`}>
|
||||||
: `✗ Richtig wäre: ${options.find(o => o.correct)?.[lang] || options.find(o => o.correct)?.de || '?'}`
|
{isCorrect
|
||||||
}
|
? `✓ ${praiseWord}`
|
||||||
</p>
|
: `${encourageWord} Richtig wären: ${options.filter(o => o.correct).map(o => o[lang] || o.de).join(', ')}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!confirmed && (
|
||||||
|
<div className="pair-btn-row" style={{ marginTop: 12 }}>
|
||||||
|
<button
|
||||||
|
className={`pair-btn ${selectedIds.size > 0 ? 'pair-btn-primary' : 'pair-btn-locked'}`}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
>
|
||||||
|
Bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import confetti from 'canvas-confetti'
|
import confetti from 'canvas-confetti'
|
||||||
|
import usePairAudio from '../hooks/usePairAudio'
|
||||||
|
import SelectionOverlay from './SelectionOverlay'
|
||||||
|
import EpFloat from './EpFloat'
|
||||||
|
import { praise, encourage } from '../utils/praise'
|
||||||
|
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
||||||
|
import { speak } from '../utils/speak'
|
||||||
import './PairCards.css'
|
import './PairCards.css'
|
||||||
|
|
||||||
function triggerConfetti() {
|
function triggerConfetti() {
|
||||||
@@ -13,45 +19,11 @@ function triggerConfetti() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectionOverlay({ chip }) {
|
|
||||||
const sels = chip?.selections
|
|
||||||
if (!sels?.length) return null
|
|
||||||
const maskId = `selmask-${chip.id.slice(0, 8)}`
|
|
||||||
const label = chip.label || ''
|
|
||||||
const toPoints = pts => pts.map(p => `${p.x * 100},${p.y * 100}`).join(' ')
|
|
||||||
const firstPts = sels[0].points
|
|
||||||
const xs = firstPts.map(p => p.x * 100)
|
|
||||||
const ys = firstPts.map(p => p.y * 100)
|
|
||||||
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
|
|
||||||
const labelY = Math.min(Math.max(...ys) + 6, 94)
|
|
||||||
return (
|
|
||||||
<svg className="pair-bbox-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
|
||||||
<defs>
|
|
||||||
<mask id={maskId}>
|
|
||||||
<rect width="100" height="100" fill="white" />
|
|
||||||
{sels.map((s, i) => <polygon key={i} points={toPoints(s.points)} fill="black" />)}
|
|
||||||
</mask>
|
|
||||||
</defs>
|
|
||||||
<rect width="100" height="100" fill="rgba(0,0,0,0.5)" mask={`url(#${maskId})`} />
|
|
||||||
{sels.map((s, i) => (
|
|
||||||
<polygon key={i} points={toPoints(s.points)}
|
|
||||||
fill="rgba(255,215,100,0.08)" stroke="rgba(255,215,100,0.92)"
|
|
||||||
strokeWidth="0.8" strokeLinejoin="round" />
|
|
||||||
))}
|
|
||||||
{label && (
|
|
||||||
<text x={cx} y={labelY} textAnchor="middle"
|
|
||||||
fill="white" fontSize="5.5" fontWeight="700" fontFamily="Nunito, sans-serif"
|
|
||||||
style={{ filter: 'drop-shadow(0 1px 4px rgba(0,0,0,1))' }}>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
|
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
|
||||||
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
||||||
if (!sentence) return null
|
if (!sentence) return null
|
||||||
|
// Unaufgelöste Pipeline-Tokens (⟦PHn:wort⟧) defensiv als blankes Wort anzeigen
|
||||||
|
sentence = sentence.replace(/[⟦〚]PH\d+:([^⟧〛]*)[⟧〛]/g, '$1')
|
||||||
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
|
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
|
||||||
return parts.map((part, i) => {
|
return parts.map((part, i) => {
|
||||||
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
|
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
|
||||||
@@ -77,6 +49,10 @@ function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
|||||||
export default function PairYesNoCard({ card, onComplete }) {
|
export default function PairYesNoCard({ card, onComplete }) {
|
||||||
const [result, setResult] = useState(null)
|
const [result, setResult] = useState(null)
|
||||||
const [activeChip, setActiveChip] = useState(null)
|
const [activeChip, setActiveChip] = useState(null)
|
||||||
|
const [showFloat, setShowFloat] = useState(false)
|
||||||
|
const [praiseWord] = useState(() => praise())
|
||||||
|
const [encourageWord] = useState(() => encourage())
|
||||||
|
const points = card.meta?.points ?? 2
|
||||||
|
|
||||||
const lang = card.lang || 'de'
|
const lang = card.lang || 'de'
|
||||||
const native = lang === 'de' ? 'en' : 'de'
|
const native = lang === 'de' ? 'en' : 'de'
|
||||||
@@ -91,17 +67,34 @@ export default function PairYesNoCard({ card, onComplete }) {
|
|||||||
const sentence = q?.[`sentence_${lang}`] || q?.sentence_de
|
const sentence = q?.[`sentence_${lang}`] || q?.sentence_de
|
||||||
const hint = q?.[`sentence_${native}`] || null
|
const hint = q?.[`sentence_${native}`] || null
|
||||||
|
|
||||||
|
const { play, playing, currentTime } = usePairAudio(q?.audio_url)
|
||||||
|
|
||||||
|
// Karaoke: aktives Chip aus den Audio-Timestamps der Frage ableiten.
|
||||||
|
const timings = useMemo(
|
||||||
|
() => buildChipTimings(sentence, q?.audio_alignment),
|
||||||
|
[sentence, q?.audio_alignment],
|
||||||
|
)
|
||||||
|
const audioChipId = playing ? activeChipIdAt(timings, currentTime) : null
|
||||||
|
const audioObject = audioChipId && card.placeholders?.[audioChipId]?.type === 'object'
|
||||||
|
? { id: audioChipId, ...card.placeholders[audioChipId] }
|
||||||
|
: null
|
||||||
|
|
||||||
function handleChipClick(id, entry) {
|
function handleChipClick(id, entry) {
|
||||||
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
|
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio-File abspielen; bei fehlendem/nicht ladbarem File still auf TTS zurückfallen.
|
||||||
|
function handlePlay() {
|
||||||
|
if (!play()) speak(sentence, lang)
|
||||||
|
}
|
||||||
|
|
||||||
function handleAnswer(answer) {
|
function handleAnswer(answer) {
|
||||||
if (result) return
|
if (result) return
|
||||||
setActiveChip(null)
|
setActiveChip(null)
|
||||||
const isCorrect = answer === correct
|
const isCorrect = answer === correct
|
||||||
const r = isCorrect ? 'correct' : 'wrong'
|
const r = isCorrect ? 'correct' : 'wrong'
|
||||||
setResult(r)
|
setResult(r)
|
||||||
if (isCorrect) triggerConfetti()
|
if (isCorrect) { setShowFloat(true); triggerConfetti() }
|
||||||
setTimeout(() => onComplete(r), 900)
|
setTimeout(() => onComplete(r), 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,33 +115,38 @@ export default function PairYesNoCard({ card, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isObject && <SelectionOverlay chip={activeChip} />}
|
{isObject && <SelectionOverlay chip={activeChip} />}
|
||||||
|
{!isObject && audioObject && <SelectionOverlay chip={audioObject} outlineOnly />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header below image */}
|
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
|
||||||
<div className="pair-card-header">
|
|
||||||
<span className="pair-lang-pill">{lang === 'sv' ? 'Svenska' : lang === 'en' ? 'English' : 'Deutsch'}</span>
|
|
||||||
<span className="pair-points-pill">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
|
||||||
+{card.meta?.points ?? 2} Punkte
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pair-header-divider" />
|
|
||||||
|
|
||||||
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
|
<div className="pair-sentence-row">
|
||||||
<p className="pair-section-label">Frage</p>
|
<div className="pair-sentence-text">
|
||||||
<p className="pair-question">
|
<p className="pair-question pair-sentence-clickable" onClick={() => play()}>
|
||||||
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
{resolveSentence(sentence, card.placeholders, handleChipClick, audioChipId ?? activeChip?.id)}
|
||||||
</p>
|
</p>
|
||||||
{hint && !result && (
|
{hint && !result && (
|
||||||
<p className="pair-hint">
|
<p className="pair-hint">
|
||||||
{resolveSentence(hint, card.placeholders, null, null)}
|
{resolveSentence(hint, card.placeholders, null, null)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<button className={`pair-icon-btn${playing ? ' active' : ''}`} onClick={handlePlay} title="Vorlesen">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<p className={`pair-feedback ${result}`}>
|
<div style={{ position: 'relative' }}>
|
||||||
{result === 'correct' ? '✓ Richtig!' : `✗ Die Antwort war: ${correct ? 'Ja' : 'Nein'}`}
|
{result === 'correct' && showFloat && <EpFloat points={points} onDone={() => setShowFloat(false)} />}
|
||||||
</p>
|
<p className={`pair-feedback ${result}`}>
|
||||||
|
{result === 'correct' ? `✓ ${praiseWord}` : `${encourageWord} Die Antwort war: ${correct ? 'Ja' : 'Nein'}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pair-btn-row" style={{ marginTop: result ? '12px' : '6px' }}>
|
<div className="pair-btn-row" style={{ marginTop: result ? '12px' : '6px' }}>
|
||||||
|
|||||||
35
src/components/ProgressRing.jsx
Normal file
35
src/components/ProgressRing.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Ruhiger SVG-Donut für Tagesziel-Fortschritt. Wiederverwendet in Feed (klein) und Profil (groß).
|
||||||
|
export default function ProgressRing({
|
||||||
|
value = 0, // 0..1
|
||||||
|
size = 26,
|
||||||
|
stroke = 4,
|
||||||
|
track = 'var(--surface-2)',
|
||||||
|
color = 'var(--gold)',
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const r = (size - stroke) / 2
|
||||||
|
const c = 2 * Math.PI * r
|
||||||
|
const pct = Math.max(0, Math.min(1, value))
|
||||||
|
const dash = c * pct
|
||||||
|
return (
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex', width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ transform: 'rotate(-90deg)' }}>
|
||||||
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={track} strokeWidth={stroke} />
|
||||||
|
<circle
|
||||||
|
cx={size / 2} cy={size / 2} r={r} fill="none"
|
||||||
|
stroke={color} strokeWidth={stroke} strokeLinecap="round"
|
||||||
|
strokeDasharray={`${dash} ${c}`}
|
||||||
|
style={{ transition: 'stroke-dasharray 0.6s var(--ease)' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{children != null && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/components/SelectionOverlay.jsx
Normal file
42
src/components/SelectionOverlay.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Bildregion-Overlay für Objekt-Chips.
|
||||||
|
// Standard: Rest des Bildes verdunkeln + Region umranden + Label (Klick-Highlight).
|
||||||
|
// outlineOnly: nur die Region umranden, kein Verdunkeln, kein Label (Karaoke-Vorlesen).
|
||||||
|
export default function SelectionOverlay({ chip, outlineOnly = false }) {
|
||||||
|
const sels = chip?.selections
|
||||||
|
if (!sels?.length) return null
|
||||||
|
const maskId = `selmask-${chip.id.slice(0, 8)}`
|
||||||
|
const label = chip.label || ''
|
||||||
|
const toPoints = pts => pts.map(p => `${p.x * 100},${p.y * 100}`).join(' ')
|
||||||
|
const firstPts = sels[0].points
|
||||||
|
const xs = firstPts.map(p => p.x * 100)
|
||||||
|
const ys = firstPts.map(p => p.y * 100)
|
||||||
|
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
|
||||||
|
const labelY = Math.min(Math.max(...ys) + 6, 94)
|
||||||
|
return (
|
||||||
|
<svg className="pair-bbox-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
{!outlineOnly && (
|
||||||
|
<>
|
||||||
|
<defs>
|
||||||
|
<mask id={maskId}>
|
||||||
|
<rect width="100" height="100" fill="white" />
|
||||||
|
{sels.map((s, i) => <polygon key={i} points={toPoints(s.points)} fill="black" />)}
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" fill="rgba(0,0,0,0.5)" mask={`url(#${maskId})`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{sels.map((s, i) => (
|
||||||
|
<polygon key={i} points={toPoints(s.points)}
|
||||||
|
fill="rgba(255,215,100,0.08)" stroke="rgba(255,215,100,0.92)"
|
||||||
|
strokeWidth="0.8" strokeLinejoin="round" />
|
||||||
|
))}
|
||||||
|
{!outlineOnly && label && (
|
||||||
|
<text x={cx} y={labelY} textAnchor="middle"
|
||||||
|
fill="white" fontSize="5.5" fontWeight="700" fontFamily="Nunito, sans-serif"
|
||||||
|
style={{ filter: 'drop-shadow(0 1px 4px rgba(0,0,0,1))' }}>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/components/SessionSummary.jsx
Normal file
47
src/components/SessionSummary.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import './Moments.css'
|
||||||
|
import { categoryTier } from '../utils/praise'
|
||||||
|
|
||||||
|
// Ersetzt die End-Sackgasse („Super! Alle Karten…") durch einen echten
|
||||||
|
// Abschluss-Moment: Zahlen dieser Session + 1–3 Story-Zeilen.
|
||||||
|
export default function SessionSummary({ cards, ep, correct, streak, topCategory, onReload }) {
|
||||||
|
const stories = []
|
||||||
|
if (cards > 0) {
|
||||||
|
stories.push({ icon: '✅', text: `${correct} von ${cards} Karten auf Anhieb richtig` })
|
||||||
|
}
|
||||||
|
if (streak > 0) {
|
||||||
|
stories.push({ icon: '🔥', text: `Tag ${streak} in Folge — Streak gehalten` })
|
||||||
|
}
|
||||||
|
if (topCategory?.label) {
|
||||||
|
const tier = categoryTier(topCategory.points)
|
||||||
|
stories.push({ icon: '📚', text: `„${topCategory.label}" — Stufe ${tier.label}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="session-summary">
|
||||||
|
<div className="ss-icon" aria-hidden="true">🌟</div>
|
||||||
|
<h3 className="ss-title">Stark gemacht!</h3>
|
||||||
|
<p className="ss-sub">Session beendet · {ep} EP gesammelt</p>
|
||||||
|
|
||||||
|
<div className="ss-stats">
|
||||||
|
<div className="ss-stat"><span className="n">{cards}</span><span className="c">Karten</span></div>
|
||||||
|
<div className="ss-stat"><span className="n">{ep}</span><span className="c">EP heute</span></div>
|
||||||
|
<div className="ss-stat"><span className="n">{cards ? Math.round((correct / cards) * 100) : 0}%</span><span className="c">richtig</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stories.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||||
|
{stories.map((s, i) => (
|
||||||
|
<div key={i} className="ss-story">
|
||||||
|
<span className="si" aria-hidden="true">{s.icon}</span>
|
||||||
|
<span>{s.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onReload && (
|
||||||
|
<button className="milestone-btn" onClick={onReload}>Nach neuen Karten suchen</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,26 +3,27 @@ import LoginForm from './LoginForm'
|
|||||||
import RegisterStep1 from './RegisterStep1'
|
import RegisterStep1 from './RegisterStep1'
|
||||||
import RegisterStep2 from './RegisterStep2'
|
import RegisterStep2 from './RegisterStep2'
|
||||||
|
|
||||||
|
// Legacy-Variablennamen der Auth-Formulare auf die globalen Design-Tokens mappen.
|
||||||
|
// Auf .auth-root gescoped, damit nichts ins :root der App leakt.
|
||||||
const css = `
|
const css = `
|
||||||
:root {
|
.auth-root {
|
||||||
--bg: #F5F0E8; --surface: #FFFCF7; --border: #E2DAD0;
|
--muted: var(--text-muted);
|
||||||
--text: #2C2520; --muted: #9A8F85; --accent: #5C7A5E;
|
--accent-lt: var(--accent-soft);
|
||||||
--accent-lt: #EAF0EA; --danger: #C0544A; --danger-lt: #FBF0EF;
|
--danger-lt: var(--danger-soft);
|
||||||
--radius: 14px;
|
--radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Lora:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap');
|
|
||||||
@keyframes fadeUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
|
@keyframes fadeUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
|
||||||
`
|
`
|
||||||
|
|
||||||
export default function AuthScreen() {
|
export default function AuthScreen() {
|
||||||
const [mode, setMode] = useState(() => localStorage.getItem('hejyou_last_mode') || 'login')
|
const [mode, setMode] = useState(() => localStorage.getItem('snakkimo_last_mode') || 'login')
|
||||||
const [step, setStep] = useState('main')
|
const [step, setStep] = useState('main')
|
||||||
const [pendingUserId, setPendingUserId] = useState(null)
|
const [pendingUserId, setPendingUserId] = useState(null)
|
||||||
const [pendingToken, setPendingToken] = useState(null)
|
const [pendingToken, setPendingToken] = useState(null)
|
||||||
const [successName, setSuccessName] = useState('')
|
const [successName, setSuccessName] = useState('')
|
||||||
|
|
||||||
const handleModeChange = (m) => {
|
const handleModeChange = (m) => {
|
||||||
setMode(m); localStorage.setItem('hejyou_last_mode', m); setStep('main')
|
setMode(m); localStorage.setItem('snakkimo_last_mode', m); setStep('main')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNeedsProfile = (userId, token) => {
|
const handleNeedsProfile = (userId, token) => {
|
||||||
@@ -32,7 +33,7 @@ export default function AuthScreen() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{css}</style>
|
<style>{css}</style>
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px', background: 'var(--bg)' }}>
|
<div className="auth-root" style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px', background: 'var(--bg)' }}>
|
||||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: '24px', padding: '48px 44px', width: '100%', maxWidth: '420px', boxShadow: '0 2px 40px rgba(44,37,32,0.06)', animation: 'fadeUp 0.3s ease' }}>
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: '24px', padding: '48px 44px', width: '100%', maxWidth: '420px', boxShadow: '0 2px 40px rgba(44,37,32,0.06)', animation: 'fadeUp 0.3s ease' }}>
|
||||||
|
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
@@ -42,7 +43,7 @@ export default function AuthScreen() {
|
|||||||
<circle cx="12" cy="12" r="10"/><path d="M8 12q2-5 4-4t4 4-4 4-4-4"/>
|
<circle cx="12" cy="12" r="10"/><path d="M8 12q2-5 4-4t4 4-4 4-4-4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ fontFamily: 'Lora, serif', fontSize: '22px', fontWeight: 500, letterSpacing: '-0.3px', color: 'var(--text)' }}>HejYou</h1>
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '22px', fontWeight: 500, letterSpacing: '-0.3px', color: 'var(--text)' }}>Snakkimo</h1>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--muted)', marginTop: '4px' }}>Sprachen lernen wie ein Kind</p>
|
<p style={{ fontSize: '13px', color: 'var(--muted)', marginTop: '4px' }}>Sprachen lernen wie ein Kind</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export default function AuthScreen() {
|
|||||||
<button key={m} onClick={() => handleModeChange(m)} style={{
|
<button key={m} onClick={() => handleModeChange(m)} style={{
|
||||||
flex: 1, padding: '8px', border: 'none', borderRadius: '8px',
|
flex: 1, padding: '8px', border: 'none', borderRadius: '8px',
|
||||||
background: mode === m ? 'var(--surface)' : 'transparent',
|
background: mode === m ? 'var(--surface)' : 'transparent',
|
||||||
fontFamily: 'DM Sans, sans-serif', fontSize: '13px', fontWeight: 500,
|
fontFamily: 'var(--font-ui)', fontSize: '13px', fontWeight: 700,
|
||||||
color: mode === m ? 'var(--text)' : 'var(--muted)', cursor: 'pointer',
|
color: mode === m ? 'var(--text)' : 'var(--muted)', cursor: 'pointer',
|
||||||
boxShadow: mode === m ? '0 1px 4px rgba(44,37,32,0.08)' : 'none',
|
boxShadow: mode === m ? '0 1px 4px rgba(44,37,32,0.08)' : 'none',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
@@ -77,7 +78,7 @@ export default function AuthScreen() {
|
|||||||
<polyline points="20 6 9 17 4 12"/>
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<strong style={{ fontFamily: 'Lora, serif', fontSize: '18px', display: 'block', marginBottom: '8px' }}>
|
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '18px', display: 'block', marginBottom: '8px' }}>
|
||||||
Willkommen, {successName}!
|
Willkommen, {successName}!
|
||||||
</strong>
|
</strong>
|
||||||
<p style={{ fontSize: '14px', color: 'var(--muted)' }}>Dein Abenteuer beginnt jetzt.</p>
|
<p style={{ fontSize: '14px', color: 'var(--muted)' }}>Dein Abenteuer beginnt jetzt.</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { login, getMe } from '../../api/directus'
|
import { login, getMe } from '../../api/directus'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { FormGroup, Input, Button, Alert } from './ui'
|
import { FormGroup, Input, Button, Alert } from './ui'
|
||||||
@@ -9,15 +9,19 @@ export default function LoginForm({ onNeedsProfile }) {
|
|||||||
const [pw, setPw] = useState('')
|
const [pw, setPw] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const submittingRef = useRef(false) // race-fester Schutz gegen Doppel-Submit
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
|
if (submittingRef.current) return
|
||||||
if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return }
|
if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return }
|
||||||
|
submittingRef.current = true
|
||||||
setError(''); setLoading(true)
|
setError(''); setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await login(email, pw)
|
const result = await login(email, pw)
|
||||||
if (result.needsProfile) {
|
if (result.needsProfile) {
|
||||||
onNeedsProfile(result.userId, null)
|
// JWT mitgeben — Profil-Step braucht es als Auth
|
||||||
|
onNeedsProfile(result.userId, result.access_token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
saveToken(result.access_token)
|
saveToken(result.access_token)
|
||||||
@@ -26,6 +30,7 @@ export default function LoginForm({ onNeedsProfile }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
submittingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { registerUser } from '../../api/directus'
|
import { registerUser } from '../../api/directus'
|
||||||
import { FormGroup, Input, Button, Alert, StepDots } from './ui'
|
import { FormGroup, Input, Button, Alert, StepDots } from './ui'
|
||||||
|
|
||||||
@@ -7,18 +7,22 @@ export default function RegisterStep1({ onSuccess }) {
|
|||||||
const [pw, setPw] = useState('')
|
const [pw, setPw] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const submittingRef = useRef(false) // race-fester Schutz gegen Doppel-Submit
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
|
if (submittingRef.current) return
|
||||||
if (!email || !pw) { setError('Bitte alle Felder ausfüllen.'); return }
|
if (!email || !pw) { setError('Bitte alle Felder ausfüllen.'); return }
|
||||||
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
|
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
|
||||||
|
submittingRef.current = true
|
||||||
setError(''); setLoading(true)
|
setError(''); setLoading(true)
|
||||||
try {
|
try {
|
||||||
const { registrationToken } = await registerUser(email, pw)
|
const { token, userId } = await registerUser(email, pw)
|
||||||
onSuccess(null, registrationToken) // Token statt userId — AuthScreen speichert es als pendingToken
|
onSuccess(userId, token) // JWT direkt aus Register
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
submittingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { checkUsername, createProfile, getLanguageOptions } from '../../api/directus'
|
import { checkUsername, createProfile, getLanguageOptions, getMe } from '../../api/directus'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
|
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ export default function RegisterStep2({ userId, userToken, onSuccess }) {
|
|||||||
const [languages, setLanguages] = useState([])
|
const [languages, setLanguages] = useState([])
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const submittingRef = useRef(false) // race-fester Schutz gegen Doppel-Submit
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getLanguageOptions()
|
getLanguageOptions()
|
||||||
@@ -20,6 +21,7 @@ export default function RegisterStep2({ userId, userToken, onSuccess }) {
|
|||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
|
if (submittingRef.current) return
|
||||||
if (!username || !nativeLang || !targetLang) {
|
if (!username || !nativeLang || !targetLang) {
|
||||||
setError('Bitte alle Felder ausfüllen.'); return
|
setError('Bitte alle Felder ausfüllen.'); return
|
||||||
}
|
}
|
||||||
@@ -29,20 +31,23 @@ export default function RegisterStep2({ userId, userToken, onSuccess }) {
|
|||||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
||||||
setError('Username: 3–20 Zeichen, nur Buchstaben, Zahlen und _'); return
|
setError('Username: 3–20 Zeichen, nur Buchstaben, Zahlen und _'); return
|
||||||
}
|
}
|
||||||
|
submittingRef.current = true
|
||||||
setError(''); setLoading(true)
|
setError(''); setLoading(true)
|
||||||
try {
|
try {
|
||||||
const available = await checkUsername(username, userToken)
|
const available = await checkUsername(username, userToken)
|
||||||
if (!available) {
|
if (!available) {
|
||||||
setError('Dieser Username ist bereits vergeben.'); setLoading(false); return
|
setError('Dieser Username ist bereits vergeben.'); setLoading(false); return
|
||||||
}
|
}
|
||||||
// userToken ist das kurzlebige Registration-Token aus Schritt 1
|
// userToken = JWT aus register/login. Profil anlegen, dann Token persistieren.
|
||||||
const { token } = await createProfile({ username, nativeLang, targetLang, userToken })
|
await createProfile({ username, nativeLang, targetLang, userToken })
|
||||||
saveToken(token)
|
saveToken(userToken)
|
||||||
setUser({ id: userId, username, language_native: nativeLang, language_target: targetLang })
|
const me = await getMe(userToken)
|
||||||
|
setUser(me)
|
||||||
onSuccess(username)
|
onSuccess(username)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
submittingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
font-family: 'DM Sans', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
}
|
}
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(92,122,94,0.12);
|
box-shadow: 0 0 0 3px rgba(122, 92, 58, 0.14);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +46,12 @@
|
|||||||
width: 100%; padding: 13px; margin-top: 8px;
|
width: 100%; padding: 13px; margin-top: 8px;
|
||||||
background: var(--accent); color: #fff;
|
background: var(--accent); color: #fff;
|
||||||
border: none; border-radius: var(--radius);
|
border: none; border-radius: var(--radius);
|
||||||
font-family: 'DM Sans', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 15px; font-weight: 500; cursor: pointer;
|
font-size: 15px; font-weight: 500; cursor: pointer;
|
||||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
transition: background 0.2s, transform 0.1s;
|
transition: background 0.2s, transform 0.1s;
|
||||||
}
|
}
|
||||||
.btn:hover:not(:disabled) { background: #4a6650; }
|
.btn:hover:not(:disabled) { background: var(--accent-strong); }
|
||||||
.btn:active:not(:disabled) { transform: scale(0.98); }
|
.btn:active:not(:disabled) { transform: scale(0.98); }
|
||||||
.btn:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
|
.btn:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react'
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
import { getMe } from '../api/directus'
|
import { getMe } from '../api/directus'
|
||||||
|
import { getStoredToken, setStoredToken, clearStoredToken } from '../utils/secureToken'
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [token, setToken] = useState(() => localStorage.getItem('hejyou_token'))
|
const [token, setToken] = useState(null)
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [bootstrapped, setBootstrapped] = useState(false)
|
||||||
|
|
||||||
|
// Token einmalig aus dem sicheren Speicher laden (inkl. Migration aus localStorage).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
getStoredToken()
|
||||||
|
.then(setToken)
|
||||||
|
.catch(() => setToken(null))
|
||||||
|
.finally(() => setBootstrapped(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Profil laden, sobald der Token bekannt ist.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bootstrapped) return
|
||||||
if (!token) { setLoading(false); return }
|
if (!token) { setLoading(false); return }
|
||||||
getMe(token)
|
getMe(token)
|
||||||
.then(setUser)
|
.then(setUser)
|
||||||
.catch(() => { localStorage.removeItem('hejyou_token'); setToken(null) })
|
.catch(() => { clearStoredToken(); setToken(null) })
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [token])
|
}, [token, bootstrapped])
|
||||||
|
|
||||||
const saveToken = (t) => { localStorage.setItem('hejyou_token', t); setToken(t) }
|
const saveToken = async (t) => { await setStoredToken(t); setToken(t) }
|
||||||
const logout = () => { localStorage.removeItem('hejyou_token'); setToken(null); setUser(null) }
|
const logout = async () => { await clearStoredToken(); setToken(null); setUser(null) }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ token, user, setUser, saveToken, logout, loading }}>
|
<AuthContext.Provider value={{ token, user, setUser, saveToken, logout, loading }}>
|
||||||
|
|||||||
32
src/hooks/useCountUp.js
Normal file
32
src/hooks/useCountUp.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Zählt eine Zahl sanft vom alten auf den neuen Wert hoch (EP-Badge etc.),
|
||||||
|
// damit Belohnung nicht stumm „umspringt". Respektiert prefers-reduced-motion.
|
||||||
|
export default function useCountUp(target, { duration = 600 } = {}) {
|
||||||
|
const [display, setDisplay] = useState(target ?? 0)
|
||||||
|
const fromRef = useRef(target ?? 0)
|
||||||
|
const rafRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (target == null) return
|
||||||
|
const from = fromRef.current
|
||||||
|
const to = target
|
||||||
|
if (from === to) { setDisplay(to); return }
|
||||||
|
|
||||||
|
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
|
||||||
|
if (reduce) { fromRef.current = to; setDisplay(to); return }
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
const tick = (now) => {
|
||||||
|
const t = Math.min(1, (now - start) / duration)
|
||||||
|
const eased = 1 - Math.pow(1 - t, 3)
|
||||||
|
setDisplay(Math.round(from + (to - from) * eased))
|
||||||
|
if (t < 1) rafRef.current = requestAnimationFrame(tick)
|
||||||
|
else fromRef.current = to
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(rafRef.current)
|
||||||
|
}, [target, duration])
|
||||||
|
|
||||||
|
return display
|
||||||
|
}
|
||||||
75
src/hooks/usePairAudio.js
Normal file
75
src/hooks/usePairAudio.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Nur eine Stimme gleichzeitig im Feed
|
||||||
|
let currentAudio = null
|
||||||
|
|
||||||
|
// Reines Vorlesen wird gedrosselt (User-Wunsch). Pitch bleibt durch den
|
||||||
|
// Browser-Default `preservesPitch` erhalten.
|
||||||
|
const PLAYBACK_RATE = 0.7
|
||||||
|
|
||||||
|
export default function usePairAudio(url) {
|
||||||
|
const audioRef = useRef(null)
|
||||||
|
const rafRef = useRef(null)
|
||||||
|
const failedRef = useRef(false)
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
const markFailed = () => { failedRef.current = true; setFailed(true) }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) { audioRef.current = null; return }
|
||||||
|
failedRef.current = false
|
||||||
|
setFailed(false)
|
||||||
|
const audio = new Audio(url)
|
||||||
|
audio.preload = 'auto'
|
||||||
|
audio.playbackRate = PLAYBACK_RATE
|
||||||
|
|
||||||
|
// currentTime ~30fps mitschreiben, solange das Audio läuft (für Karaoke-Sync).
|
||||||
|
let lastTick = 0
|
||||||
|
const tick = (ts) => {
|
||||||
|
if (ts - lastTick >= 33) { lastTick = ts; setCurrentTime(audio.currentTime) }
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
const stopLoop = () => {
|
||||||
|
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPlay = () => { setPlaying(true); stopLoop(); rafRef.current = requestAnimationFrame(tick) }
|
||||||
|
const onStop = () => { setPlaying(false); stopLoop(); setCurrentTime(audio.currentTime) }
|
||||||
|
const onEnded = () => { setPlaying(false); stopLoop(); setCurrentTime(0) }
|
||||||
|
// Netz-/Decode-Fehler eines vorhandenen Files → File gilt als nicht abspielbar,
|
||||||
|
// damit play() den TTS-Fallback der Karte greifen lässt.
|
||||||
|
const onError = () => { setPlaying(false); stopLoop(); markFailed() }
|
||||||
|
audio.addEventListener('play', onPlay)
|
||||||
|
audio.addEventListener('pause', onStop)
|
||||||
|
audio.addEventListener('ended', onEnded)
|
||||||
|
audio.addEventListener('error', onError)
|
||||||
|
audioRef.current = audio
|
||||||
|
return () => {
|
||||||
|
stopLoop()
|
||||||
|
audio.pause()
|
||||||
|
audio.removeEventListener('play', onPlay)
|
||||||
|
audio.removeEventListener('pause', onStop)
|
||||||
|
audio.removeEventListener('ended', onEnded)
|
||||||
|
audio.removeEventListener('error', onError)
|
||||||
|
if (currentAudio === audio) currentAudio = null
|
||||||
|
audio.src = ''
|
||||||
|
audioRef.current = null
|
||||||
|
}
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || failedRef.current) return false
|
||||||
|
if (currentAudio && currentAudio !== audio) currentAudio.pause()
|
||||||
|
currentAudio = audio
|
||||||
|
audio.currentTime = 0
|
||||||
|
audio.playbackRate = PLAYBACK_RATE
|
||||||
|
// AbortError = von einem pause() unterbrochen (harmlos); echte Quell-/Decode-Fehler
|
||||||
|
// markieren das File als nicht abspielbar → Karte fällt auf TTS zurück.
|
||||||
|
audio.play().catch((err) => { if (err?.name !== 'AbortError') markFailed() })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { play, playing, currentTime, failed }
|
||||||
|
}
|
||||||
118
src/index.css
118
src/index.css
@@ -1,4 +1,65 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Lora:wght@700&family=Nunito:wght@400;500;600;700;800&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;0,600;0,700;1,500&family=Nunito:wght@400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
|
/* ── Design Tokens ──────────────────────────────────────────────
|
||||||
|
Single source of truth. Warmes, ruhiges Konzept – nur diszipliniert.
|
||||||
|
Bestehende Palette normalisiert (ein Grün, ein Rot, klare Skalen). */
|
||||||
|
:root {
|
||||||
|
/* Flächen & Hintergrund */
|
||||||
|
--bg: #EDE0CE; /* warmes Creme – App-Hintergrund */
|
||||||
|
--bg-2: #F2E8DA; /* leicht heller, für Sektionen */
|
||||||
|
--surface: #FBF7F0; /* Karten / Panels */
|
||||||
|
--surface-2: #F4EEE3; /* eingelassene Flächen, Tracks */
|
||||||
|
--surface-sunk: #ECE3D4; /* Vertiefungen / Heatmap-Leerzelle */
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #4A3728; /* primärer warmer Braunton */
|
||||||
|
--text-strong: #3A2515; /* Überschriften / Betonung */
|
||||||
|
--text-muted: #8C7A65; /* sekundär */
|
||||||
|
--text-soft: #A89F8C; /* tertiär / Hints */
|
||||||
|
|
||||||
|
/* Akzente */
|
||||||
|
--accent: #7A5C3A; /* Aktion / aktiv */
|
||||||
|
--accent-strong: #5C3D22;
|
||||||
|
--accent-soft: #EFE6D6; /* heller Akzent-Hintergrund (Pills) */
|
||||||
|
--gold: #C4A85A; /* EP / Belohnung */
|
||||||
|
--gold-soft: #F0E4C4;
|
||||||
|
--success: #3D7055; /* genau ein Grün */
|
||||||
|
--success-soft: #E4EDE5;
|
||||||
|
--danger: #C0544A; /* genau ein warmes Rot */
|
||||||
|
--danger-soft: #FBF0EF;
|
||||||
|
|
||||||
|
/* Linien */
|
||||||
|
--border: #DDD0BF;
|
||||||
|
--border-soft: #E7DDCD;
|
||||||
|
|
||||||
|
/* Spacing-Skala */
|
||||||
|
--sp-1: 4px;
|
||||||
|
--sp-2: 8px;
|
||||||
|
--sp-3: 12px;
|
||||||
|
--sp-4: 16px;
|
||||||
|
--sp-5: 24px;
|
||||||
|
--sp-6: 32px;
|
||||||
|
|
||||||
|
/* Radien */
|
||||||
|
--r-sm: 12px;
|
||||||
|
--r-md: 16px;
|
||||||
|
--r-lg: 22px;
|
||||||
|
--r-pill: 999px;
|
||||||
|
|
||||||
|
/* Schatten – warm getönt, nie hartes Schwarz */
|
||||||
|
--shadow-soft: 0 1px 2px rgba(60, 40, 20, 0.05), 0 2px 8px rgba(60, 40, 20, 0.06);
|
||||||
|
--shadow-card: 0 1px 2px rgba(60, 40, 20, 0.06), 0 4px 16px rgba(60, 40, 20, 0.09), 0 12px 40px rgba(60, 40, 20, 0.06);
|
||||||
|
--shadow-pop: 0 6px 24px rgba(60, 40, 20, 0.16);
|
||||||
|
|
||||||
|
/* Typografie */
|
||||||
|
--font-ui: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-display: 'Lora', Georgia, serif;
|
||||||
|
|
||||||
|
/* Bewegung */
|
||||||
|
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
--dur-fast: 0.15s;
|
||||||
|
--dur: 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -6,13 +67,31 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html {
|
||||||
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
|
height: 100%;
|
||||||
background: #EDE0CE;
|
width: 100%;
|
||||||
color: #4A3728;
|
position: fixed;
|
||||||
height: 100dvh;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
height: 100dvh;
|
||||||
|
width: 100%;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@@ -20,3 +99,30 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gemeinsamer, ruhiger Loader/Spinner */
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.app-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 2px solid var(--border-soft);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dezenter Seitenwechsel */
|
||||||
|
@keyframes pageEnter {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.page-enter { animation: pageEnter var(--dur) var(--ease); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,74 @@
|
|||||||
.feed {
|
.feed {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #EDE0CE;
|
background: var(--bg);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
scroll-snap-type: y mandatory;
|
scroll-snap-type: y mandatory;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-top: calc(var(--sp-4) + env(safe-area-inset-top));
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-slot {
|
.feed-slot {
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: center;
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px 20px;
|
padding: var(--sp-4) var(--sp-5);
|
||||||
|
padding-bottom: var(--sp-5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-empty {
|
||||||
|
padding: 60px var(--sp-5);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trigger für Infinite Scroll – unsichtbar, kein eigener Snap-Punkt. */
|
||||||
|
.feed-sentinel {
|
||||||
|
height: 1px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* EP-Badge + Tagesziel-Ring */
|
||||||
|
.ep-badge {
|
||||||
|
position: sticky;
|
||||||
|
top: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
align-self: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 5px 14px 5px 7px;
|
||||||
|
margin: 8px auto 10px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-strong);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
animation: epPop var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes epPop {
|
||||||
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-badge .ep-ring { display: block; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.ep-badge .ep-value {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ep-badge .ep-value small {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,324 +1,293 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
import './Feed.css'
|
import './Feed.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import {
|
import { getFeedPairs, saveProgress, getUserProgress, getStats } from '../api/directus'
|
||||||
getActiveLearningPair, getWords, getQuestions, getUserProgress,
|
import ProgressRing from '../components/ProgressRing'
|
||||||
getLanguageOptions, langById,
|
import PairSentenceCard from '../components/PairSentenceCard'
|
||||||
saveProgress, addPointsToPair,
|
import PairYesNoCard from '../components/PairYesNoCard'
|
||||||
getQAPairsAtLevel, assetUrl,
|
import PairWordCard from '../components/PairWordCard'
|
||||||
} from '../api/directus'
|
import MilestoneOverlay from '../components/MilestoneOverlay'
|
||||||
import NewWordTextCard from '../components/NewWordTextCard'
|
import SessionSummary from '../components/SessionSummary'
|
||||||
import NewWordVoiceCard from '../components/NewWordVoiceCard'
|
import useCountUp from '../hooks/useCountUp'
|
||||||
import LetterOrderCard from '../components/LetterOrderCard'
|
import { levelForEp } from '../utils/leveling'
|
||||||
import SentenceFillCard from '../components/SentenceFillCard'
|
import { playCorrect, playMilestone } from '../utils/sound'
|
||||||
import LanguageParentCard from '../components/LanguageParentCard'
|
import { streakState } from '../utils/streak'
|
||||||
|
import { cancelStreakReminder } from '../utils/streakReminder'
|
||||||
|
|
||||||
// Ein Wort gilt als gemeistert, wenn es in der aktiven Sprachrichtung
|
// Points per answer_type
|
||||||
// mindestens MASTERY_THRESHOLD korrekt beantwortete Kacheln gesammelt hat.
|
const POINTS = { text: 2, yes_no: 2, word: 3, question: 3 }
|
||||||
const MASTERY_THRESHOLD = 3
|
const PAGE_SIZE = 20
|
||||||
// Wie viele verschiedene Wörter gleichzeitig im Feed erscheinen
|
const STREAK_MILESTONES = [3, 7, 14, 30, 50, 100, 200, 365]
|
||||||
const FEED_WORD_BUDGET = 6
|
|
||||||
|
|
||||||
// Punkteformel: selbes/niedrigeres Level = 1 Punkt, jeder Level höher = +1 Punkt
|
function buildCard(pair) {
|
||||||
function computePoints(cardLevel, userLevel) {
|
return {
|
||||||
return Math.max(1, 1 + ((cardLevel || 1) - (userLevel || 1)))
|
type: pair.answer_type,
|
||||||
}
|
meta: { pairId: pair.id, points: pair.difficulty_level || POINTS[pair.answer_type] || 2, cardType: pair.answer_type },
|
||||||
|
card: pair,
|
||||||
function shuffle(arr) {
|
|
||||||
const a = [...arr]
|
|
||||||
for (let i = a.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
;[a[i], a[j]] = [a[j], a[i]]
|
|
||||||
}
|
}
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickN = (arr, n) => shuffle(arr).slice(0, Math.max(0, n))
|
|
||||||
|
|
||||||
function pickWordsToLearn(unmastered, userLevel, budget) {
|
|
||||||
if (unmastered.length === 0) return []
|
|
||||||
const same = unmastered.filter(w => (w.level || 1) === userLevel)
|
|
||||||
const higher = unmastered.filter(w => (w.level || 1) > userLevel && (w.level || 1) <= userLevel + 2)
|
|
||||||
const farHigher = unmastered.filter(w => (w.level || 1) > userLevel + 2)
|
|
||||||
const lower = unmastered.filter(w => (w.level || 1) < userLevel)
|
|
||||||
|
|
||||||
if (same.length > 0) {
|
|
||||||
const sameN = Math.ceil(budget * 0.8)
|
|
||||||
const higherN = budget - sameN
|
|
||||||
let result = [
|
|
||||||
...pickN(same, Math.min(sameN, same.length)),
|
|
||||||
...pickN(higher, Math.min(higherN, higher.length)),
|
|
||||||
]
|
|
||||||
if (result.length < budget) {
|
|
||||||
const used = new Set(result.map(w => w.id))
|
|
||||||
const rest = unmastered.filter(w => !used.has(w.id))
|
|
||||||
result = [...result, ...pickN(rest, budget - result.length)]
|
|
||||||
}
|
|
||||||
return shuffle(result)
|
|
||||||
}
|
|
||||||
if (higher.length > 0) return pickN(higher, budget)
|
|
||||||
if (farHigher.length > 0) return pickN(farHigher, budget)
|
|
||||||
return pickN(lower, budget)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWordCards(words, userLevel, fromLang, toLang) {
|
|
||||||
const cards = []
|
|
||||||
words.forEach(w => {
|
|
||||||
const word = w[`title_${toLang.suffix}`]
|
|
||||||
const translation = w[`title_${fromLang.suffix}`]
|
|
||||||
if (!word || !translation) return
|
|
||||||
const level = w.level || 1
|
|
||||||
const points = computePoints(level, userLevel)
|
|
||||||
|
|
||||||
const base = { language: toLang.label, points, word, translation, level }
|
|
||||||
|
|
||||||
cards.push({
|
|
||||||
type: 'text',
|
|
||||||
meta: { wordId: w.id, cardType: 'write', points, level },
|
|
||||||
card: { ...base, baseForm: word, prompt: `Schreib das Wort auf ${toLang.label}` },
|
|
||||||
})
|
|
||||||
cards.push({
|
|
||||||
type: 'voice',
|
|
||||||
meta: { wordId: w.id, cardType: 'speak', points, level },
|
|
||||||
card: { ...base, baseForm: word, prompt: `Sprich das Wort auf ${toLang.label}`, speechLang: toLang.speech },
|
|
||||||
})
|
|
||||||
if (word.length >= 4) {
|
|
||||||
cards.push({
|
|
||||||
type: 'letter',
|
|
||||||
meta: { wordId: w.id, cardType: 'write', points, level },
|
|
||||||
card: { ...base, prompt: 'Tippe die Buchstaben in der richtigen Reihenfolge' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return cards
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLanguageParentCards(qaPairs, userLevel, toLang, token) {
|
|
||||||
return qaPairs.map(qp => {
|
|
||||||
const points = computePoints(qp.level, userLevel)
|
|
||||||
return {
|
|
||||||
type: 'languparent',
|
|
||||||
meta: { pairId: qp.pairId, cardType: 'speak', points, level: qp.level },
|
|
||||||
card: {
|
|
||||||
language: toLang.label,
|
|
||||||
points,
|
|
||||||
level: qp.level,
|
|
||||||
statement: qp.statement,
|
|
||||||
imageUrl: assetUrl(qp.pictureFileId, token),
|
|
||||||
primaryWord: qp.primaryWord,
|
|
||||||
speechLang: toLang.speech,
|
|
||||||
selections: qp.selections,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQuestionCardsFor(questions, masteredWordIds, userLevel, toLang) {
|
|
||||||
const cards = []
|
|
||||||
questions.forEach(q => {
|
|
||||||
const wordIds = (q.related_words || []).map(rw => rw.words_id).filter(Boolean)
|
|
||||||
if (wordIds.length === 0) return
|
|
||||||
if (!wordIds.every(id => masteredWordIds.has(id))) return
|
|
||||||
|
|
||||||
const qText = q[`question_${toLang.suffix}`]
|
|
||||||
const answer = q[`answer_${toLang.suffix}`]
|
|
||||||
if (!qText || !answer) return
|
|
||||||
|
|
||||||
const level = q.level || 1
|
|
||||||
const points = computePoints(level, userLevel)
|
|
||||||
|
|
||||||
cards.push({
|
|
||||||
type: 'sentence',
|
|
||||||
meta: { questionId: q.id, wordIds, cardType: 'sentence_fill', points, level },
|
|
||||||
card: {
|
|
||||||
language: toLang.label, points, level,
|
|
||||||
word: answer,
|
|
||||||
translation: qText,
|
|
||||||
prompt: 'Antworte auf die Frage',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return cards
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Feed() {
|
export default function Feed() {
|
||||||
const { user, token } = useAuth()
|
const { user, token } = useAuth()
|
||||||
const [cards, setCards] = useState([])
|
const [cards, setCards] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [done, setDone] = useState(new Set())
|
||||||
const [ctx, setCtx] = useState(null)
|
const [loading, setLoading] = useState(true)
|
||||||
const [runningPoints, setRunningPoints] = useState(0)
|
const [empty, setEmpty] = useState(false)
|
||||||
const [empty, setEmpty] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [exhausted, setExhausted] = useState(false)
|
||||||
|
const [totalEp, setTotalEp] = useState(null)
|
||||||
|
const [daily, setDaily] = useState(null) // { ep, daily_goal_ep } – wenn /auth/stats verfügbar
|
||||||
|
const [combo, setCombo] = useState(0) // richtige Antworten in Folge (diese Session)
|
||||||
|
const [milestones, setMilestones] = useState([]) // Queue: Level-Up / Streak / Tagesziel
|
||||||
|
const [topCat, setTopCat] = useState(null) // stärkste Kategorie für die Session-Summary
|
||||||
|
const [reloadKey, setReloadKey] = useState(0) // erneutes Laden nach der Summary
|
||||||
|
const [practiced, setPracticed] = useState(false) // heute in dieser Session geübt?
|
||||||
|
const [streakDismissed, setStreakDismissed] = useState(false)
|
||||||
|
|
||||||
// Laufende Mastery-Verwaltung für diese Session
|
// Session-Zähler (lokal, für die Abschluss-Summary) + zuletzt bekannter Fortschritt,
|
||||||
const correctsRef = useRef({}) // wordId -> Anzahl korrekter Antworten (persistiert + session)
|
// um Level-Up/Streak-Up im saveProgress-Response zu erkennen.
|
||||||
const masteredRef = useRef(new Set())
|
const session = useRef({ cards: 0, correct: 0, ep: 0 })
|
||||||
const questionsRef = useRef([])
|
const progress = useRef({ level: 0, streak: 0 })
|
||||||
const appendedQRef = useRef(new Set()) // bereits angehängte questionIds
|
|
||||||
const userLevelRef = useRef(1)
|
// Sanft hochzählender EP-Wert fürs Badge (statt stummem Umspringen).
|
||||||
const toLangRef = useRef(null)
|
const displayEp = useCountUp(totalEp ?? 0)
|
||||||
const pointsQueueRef = useRef(Promise.resolve()) // serialisiert Punkte-Updates
|
|
||||||
|
// Refs für den Nachlade-Pfad: Re-Entrancy-Schutz + immer aktuelle Kartenliste
|
||||||
|
// (Closure im IntersectionObserver wäre sonst veraltet).
|
||||||
|
const loadingMoreRef = useRef(false)
|
||||||
|
const exhaustedRef = useRef(false)
|
||||||
|
const cardsRef = useRef(cards)
|
||||||
|
cardsRef.current = cards
|
||||||
|
const sentinelRef = useRef(null)
|
||||||
|
|
||||||
|
// Target language from user profile, fall back to 'de'
|
||||||
|
const lang = user?.language_target_short || 'de'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
getFeedPairs(token, lang, PAGE_SIZE)
|
||||||
try {
|
.then(pairs => {
|
||||||
const [pair, langs] = await Promise.all([
|
const built = pairs.map(buildCard)
|
||||||
getActiveLearningPair(user.username, token),
|
setCards(built)
|
||||||
getLanguageOptions(),
|
setEmpty(built.length === 0)
|
||||||
])
|
if (built.length < PAGE_SIZE) { exhaustedRef.current = true; setExhausted(true) }
|
||||||
if (!pair) { setEmpty(true); setLoading(false); return }
|
|
||||||
|
|
||||||
const fromLang = langById(pair.language_from, langs)
|
|
||||||
const toLang = langById(pair.language_to, langs)
|
|
||||||
if (!fromLang || !toLang) { setEmpty(true); setLoading(false); return }
|
|
||||||
|
|
||||||
const userLevel = pair.current_level || 1
|
|
||||||
|
|
||||||
const [words, questions, progress, qaPairs] = await Promise.all([
|
|
||||||
getWords(token),
|
|
||||||
getQuestions(token),
|
|
||||||
getUserProgress(user.username, token, pair.language_to),
|
|
||||||
getQAPairsAtLevel(userLevel, token, toLang.suffix),
|
|
||||||
])
|
|
||||||
|
|
||||||
const correctsByWord = {}
|
|
||||||
progress.forEach(p => {
|
|
||||||
if (p.result === 'correct' && p.word) {
|
|
||||||
correctsByWord[p.word] = (correctsByWord[p.word] || 0) + 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const mastered = new Set(
|
|
||||||
Object.entries(correctsByWord)
|
|
||||||
.filter(([, c]) => c >= MASTERY_THRESHOLD)
|
|
||||||
.map(([id]) => id)
|
|
||||||
)
|
|
||||||
|
|
||||||
correctsRef.current = correctsByWord
|
|
||||||
masteredRef.current = mastered
|
|
||||||
questionsRef.current = questions
|
|
||||||
userLevelRef.current = userLevel
|
|
||||||
toLangRef.current = toLang
|
|
||||||
|
|
||||||
const unmastered = words.filter(w => !mastered.has(w.id))
|
|
||||||
const chosen = pickWordsToLearn(unmastered, userLevel, FEED_WORD_BUDGET)
|
|
||||||
|
|
||||||
const wordCards = buildWordCards(chosen, userLevel, fromLang, toLang)
|
|
||||||
const questionCards = buildQuestionCardsFor(questions, mastered, userLevel, toLang)
|
|
||||||
const lpCards = buildLanguageParentCards(qaPairs, userLevel, toLang, token)
|
|
||||||
|
|
||||||
// bereits angehängte Frage-IDs merken, damit wir sie nicht doppelt einstreuen
|
|
||||||
questionCards.forEach(c => appendedQRef.current.add(c.meta.questionId))
|
|
||||||
|
|
||||||
const allCards = [...lpCards, ...wordCards, ...questionCards]
|
|
||||||
setCards(allCards)
|
|
||||||
setEmpty(allCards.length === 0)
|
|
||||||
setCtx({
|
|
||||||
pair,
|
|
||||||
fromLangId: pair.language_from,
|
|
||||||
toLangId: pair.language_to,
|
|
||||||
profileId: user.username,
|
|
||||||
})
|
|
||||||
setRunningPoints(pair.points || 0)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Feed load error', err)
|
|
||||||
setEmpty(true)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}, [user.username, token])
|
|
||||||
|
|
||||||
async function handleComplete(item, result) {
|
|
||||||
if (!ctx) return
|
|
||||||
const earned = result === 'correct' ? (item.meta.points || 1) : 0
|
|
||||||
|
|
||||||
saveProgress({
|
|
||||||
user: ctx.profileId,
|
|
||||||
word: item.meta.wordId || null,
|
|
||||||
question: item.meta.questionId || null,
|
|
||||||
card_type: item.meta.cardType,
|
|
||||||
result,
|
|
||||||
points_earned: earned,
|
|
||||||
language_from: ctx.fromLangId,
|
|
||||||
language_to: ctx.toLangId,
|
|
||||||
}, token).catch(() => {})
|
|
||||||
|
|
||||||
// Punkte serialisiert patchen, damit parallele Karten nicht denselben Basiswert überschreiben
|
|
||||||
if (earned > 0) {
|
|
||||||
setRunningPoints(p => p + earned)
|
|
||||||
pointsQueueRef.current = pointsQueueRef.current.then(async () => {
|
|
||||||
ctx.pair.points = (ctx.pair.points || 0) + earned
|
|
||||||
try { await addPointsToPair(ctx.pair.id, ctx.pair.points, token) } catch {}
|
|
||||||
})
|
})
|
||||||
|
.catch(err => { console.error('Feed load error', err); setEmpty(true) })
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [token, lang, reloadKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserProgress(token)
|
||||||
|
.then(p => {
|
||||||
|
setTotalEp(p.total_ep)
|
||||||
|
// Level aus EP über die (mit dem Backend identische) Kurve ableiten, damit Level-Up
|
||||||
|
// unabhängig vom Backend-Deploy korrekt erkannt wird.
|
||||||
|
progress.current = { level: levelForEp(p.total_ep), streak: p.streak_days ?? 0 }
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
// Tagesziel-Fortschritt + stärkste Kategorie – degradiert lautlos, falls /auth/stats fehlt
|
||||||
|
getStats(token)
|
||||||
|
.then(s => {
|
||||||
|
if (s?.today) setDaily(s.today)
|
||||||
|
if (s?.categories?.length) setTopCat(s.categories[0])
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
// Weitere Karten nachladen: schon geladene (inkl. erledigte) Pair-IDs ausschließen.
|
||||||
|
// Leere Antwort → Server hat keine weiteren Karten → erschöpft.
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (loadingMoreRef.current || exhaustedRef.current) return
|
||||||
|
loadingMoreRef.current = true
|
||||||
|
setLoadingMore(true)
|
||||||
|
try {
|
||||||
|
const known = new Set(cardsRef.current.map(c => c.meta.pairId))
|
||||||
|
const pairs = await getFeedPairs(token, lang, PAGE_SIZE, [...known])
|
||||||
|
const fresh = pairs.filter(p => !known.has(p.id)).map(buildCard)
|
||||||
|
if (fresh.length) setCards(prev => [...prev, ...fresh])
|
||||||
|
if (!fresh.length || pairs.length < PAGE_SIZE) { exhaustedRef.current = true; setExhausted(true) }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Feed loadMore error', err)
|
||||||
|
} finally {
|
||||||
|
loadingMoreRef.current = false
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}, [token, lang])
|
||||||
|
|
||||||
|
// Infinite Scroll: lädt nach, sobald der Sentinel in die Nähe des Sichtbereichs kommt.
|
||||||
|
// Großzügiger rootMargin, weil scroll-snap-mandatory einen winzigen End-Sentinel
|
||||||
|
// sonst schwer erreichbar macht.
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || exhausted) return
|
||||||
|
const el = sentinelRef.current
|
||||||
|
if (!el) return
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => { if (entries[0].isIntersecting) loadMore() },
|
||||||
|
{ root: el.closest('.feed'), rootMargin: '300px' },
|
||||||
|
)
|
||||||
|
io.observe(el)
|
||||||
|
return () => io.disconnect()
|
||||||
|
}, [loading, exhausted, loadMore])
|
||||||
|
|
||||||
|
function handleComplete(item, result) {
|
||||||
|
setDone(prev => new Set([...prev, item.meta.pairId]))
|
||||||
|
const correct = result === 'correct'
|
||||||
|
const earned = correct ? item.meta.points : 0
|
||||||
|
|
||||||
|
// Heute geübt → Serie gesichert: Nudge weg + geplante Erinnerung abbrechen.
|
||||||
|
if (!practiced) { setPracticed(true); cancelStreakReminder() }
|
||||||
|
|
||||||
|
// Session-Zähler + Combo + Sound
|
||||||
|
session.current.cards += 1
|
||||||
|
if (correct) {
|
||||||
|
session.current.correct += 1
|
||||||
|
session.current.ep += earned
|
||||||
|
setCombo(c => c + 1)
|
||||||
|
playCorrect()
|
||||||
|
} else {
|
||||||
|
setCombo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-Session-Mastery: korrekte Wort-Antwort erhöht Zähler; neu gemasterte
|
// Optimistische Tagesziel-Erkennung als Fallback, falls die API kein
|
||||||
// Wörter können Frage-Kacheln freischalten.
|
// goal_just_reached liefert (älteres Backend).
|
||||||
if (result === 'correct' && item.meta.wordId) {
|
const dayBefore = daily?.ep ?? null
|
||||||
const wid = item.meta.wordId
|
const goalEp = daily?.daily_goal_ep ?? null
|
||||||
const newCount = (correctsRef.current[wid] || 0) + 1
|
const optimisticGoal = earned > 0 && dayBefore != null && goalEp != null &&
|
||||||
correctsRef.current[wid] = newCount
|
dayBefore < goalEp && dayBefore + earned >= goalEp
|
||||||
|
|
||||||
if (!masteredRef.current.has(wid) && newCount >= MASTERY_THRESHOLD) {
|
saveProgress({ pairId: item.meta.pairId, correct, points: earned, userToken: token })
|
||||||
masteredRef.current.add(wid)
|
.then(res => {
|
||||||
|
if (res?.total_ep != null) setTotalEp(res.total_ep)
|
||||||
const newQuestions = questionsRef.current.filter(q => {
|
if (res?.daily_ep != null && res?.daily_goal_ep != null) {
|
||||||
if (appendedQRef.current.has(q.id)) return false
|
setDaily(d => ({ ...(d || {}), ep: res.daily_ep, daily_goal_ep: res.daily_goal_ep }))
|
||||||
const wordIds = (q.related_words || []).map(rw => rw.words_id).filter(Boolean)
|
|
||||||
if (wordIds.length === 0) return false
|
|
||||||
return wordIds.every(id => masteredRef.current.has(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
if (newQuestions.length > 0) {
|
|
||||||
const extra = buildQuestionCardsFor(
|
|
||||||
newQuestions,
|
|
||||||
masteredRef.current,
|
|
||||||
userLevelRef.current,
|
|
||||||
toLangRef.current,
|
|
||||||
)
|
|
||||||
extra.forEach(c => appendedQRef.current.add(c.meta.questionId))
|
|
||||||
setCards(prev => [...prev, ...extra])
|
|
||||||
setEmpty(false)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
const queued = []
|
||||||
|
// Level aus dem aktuellen EP-Stand ableiten (deploy-unabhängig, identische Kurve);
|
||||||
|
// prev = zuletzt bekannter Level vor dieser Karte.
|
||||||
|
const newLevel = res?.total_ep != null ? levelForEp(res.total_ep) : progress.current.level
|
||||||
|
const prevLevel = progress.current.level
|
||||||
|
if (newLevel > prevLevel) queued.push({ kind: 'level', value: newLevel })
|
||||||
|
|
||||||
|
const newStreak = res?.streak_days ?? progress.current.streak
|
||||||
|
const streakUp = res?.streak_increased ?? (newStreak > progress.current.streak)
|
||||||
|
if (streakUp && STREAK_MILESTONES.includes(newStreak)) queued.push({ kind: 'streak', value: newStreak })
|
||||||
|
|
||||||
|
if (res?.goal_just_reached ?? optimisticGoal) {
|
||||||
|
queued.push({ kind: 'goal', value: res?.daily_goal_ep ?? goalEp })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neu freigeschaltete Erfolge zuletzt feiern
|
||||||
|
for (const a of (res?.unlocked_achievements || [])) {
|
||||||
|
queued.push({ kind: 'achievement', key: a.key, label: a.label, icon: a.icon })
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.current = { level: newLevel, streak: newStreak }
|
||||||
|
if (queued.length) { setMilestones(q => [...q, ...queued]); playMilestone() }
|
||||||
|
})
|
||||||
|
.catch(err => console.error('saveProgress error', err))
|
||||||
|
|
||||||
|
// Tagesziel optimistisch hochzählen (wird vom Server-Response ggf. überschrieben)
|
||||||
|
if (earned > 0) setDaily(d => d ? { ...d, ep: (d.ep || 0) + earned } : d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nach der Summary erneut nach Karten suchen (Server schließt erledigte Pairs aus).
|
||||||
|
const handleReload = () => {
|
||||||
|
setDone(new Set())
|
||||||
|
setExhausted(false); exhaustedRef.current = false
|
||||||
|
session.current = { cards: 0, correct: 0, ep: 0 }
|
||||||
|
setCombo(0)
|
||||||
|
setLoading(true)
|
||||||
|
setReloadKey(k => k + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = cards.filter(c => !done.has(c.meta.pairId))
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed page-enter">
|
||||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
<div className="feed-empty">Lade Karten…</div>
|
||||||
Lade Karten…
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty) {
|
if (empty) {
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed page-enter">
|
||||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
<div className="feed-empty">Noch keine Inhalte verfügbar.</div>
|
||||||
Super! Du hast alle Wörter deines Levels gemeistert. Neue Wörter kommen bald.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goalPct = daily && daily.daily_goal_ep ? (daily.ep || 0) / daily.daily_goal_ep : 0
|
||||||
|
|
||||||
|
// Loss-Aversion-Nudge: Serie läuft heute ab (oder ist gerissen) und heute noch nicht geübt.
|
||||||
|
const streak = streakState(user?.last_practice_at, user?.streak_days || 0)
|
||||||
|
const showStreakNudge = !practiced && !streakDismissed &&
|
||||||
|
(streak.state === 'at_risk' || streak.state === 'broken')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed page-enter">
|
||||||
{cards.map((item, i) => {
|
{showStreakNudge && (
|
||||||
const enrichedCard = { ...item.card, totalPoints: runningPoints }
|
<div className="streak-nudge" role="status">
|
||||||
const handler = (r) => handleComplete(item, r)
|
<span>
|
||||||
|
{streak.state === 'at_risk'
|
||||||
|
? `🔥 ${streak.streakDays}-Tage-Serie — nur noch ${streak.hoursLeft} Std heute!`
|
||||||
|
: '🌱 Starte heute deine Serie neu'}
|
||||||
|
</span>
|
||||||
|
<button className="streak-nudge-x" onClick={() => setStreakDismissed(true)} aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{combo >= 3 && (
|
||||||
|
<div key={combo} className="combo-pill" aria-hidden="true">🔥 {combo} in Folge</div>
|
||||||
|
)}
|
||||||
|
{milestones.length > 0 && (
|
||||||
|
<MilestoneOverlay
|
||||||
|
milestone={milestones[0]}
|
||||||
|
onClose={() => setMilestones(q => q.slice(1))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{totalEp != null && (
|
||||||
|
<div className="ep-badge">
|
||||||
|
<ProgressRing
|
||||||
|
value={daily ? goalPct : 1}
|
||||||
|
size={26} stroke={4}
|
||||||
|
color={goalPct >= 1 ? 'var(--success)' : 'var(--gold)'}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 11 }}>{goalPct >= 1 ? '✓' : '⭐'}</span>
|
||||||
|
</ProgressRing>
|
||||||
|
<span className="ep-value">
|
||||||
|
{displayEp}<small>EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visible.map((item) => {
|
||||||
|
const cardWithMeta = { ...item.card, meta: item.meta }
|
||||||
|
const handler = (result) => handleComplete(item, result)
|
||||||
return (
|
return (
|
||||||
<div key={i} className="feed-slot">
|
<div key={item.meta.pairId} className="feed-slot">
|
||||||
{item.type === 'text' && <NewWordTextCard card={enrichedCard} onComplete={handler} />}
|
{item.type === 'text' && <PairSentenceCard card={cardWithMeta} onComplete={handler} />}
|
||||||
{item.type === 'voice' && <NewWordVoiceCard card={enrichedCard} onComplete={handler} />}
|
{item.type === 'yes_no' && <PairYesNoCard card={cardWithMeta} onComplete={handler} />}
|
||||||
{item.type === 'letter' && <LetterOrderCard card={enrichedCard} onComplete={handler} />}
|
{item.type === 'word' && <PairWordCard card={cardWithMeta} onComplete={handler} />}
|
||||||
{item.type === 'sentence' && <SentenceFillCard card={enrichedCard} onComplete={handler} />}
|
{item.type === 'question'&& <PairWordCard card={cardWithMeta} onComplete={handler} />}
|
||||||
{item.type === 'languparent' && <LanguageParentCard card={enrichedCard} onComplete={handler} />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Nachlade-Bereich */}
|
||||||
|
{!exhausted && <div ref={sentinelRef} className="feed-sentinel" aria-hidden="true" />}
|
||||||
|
{loadingMore && <div className="feed-empty">Lade weitere Karten…</div>}
|
||||||
|
{exhausted && (
|
||||||
|
visible.length === 0
|
||||||
|
? <SessionSummary
|
||||||
|
cards={session.current.cards}
|
||||||
|
ep={session.current.ep}
|
||||||
|
correct={session.current.correct}
|
||||||
|
streak={progress.current.streak}
|
||||||
|
topCategory={topCat}
|
||||||
|
onReload={handleReload}
|
||||||
|
/>
|
||||||
|
: <div className="feed-empty">Super! Alle Karten abgeschlossen. 🎉</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
|
import ComingSoon from '../components/ComingSoon'
|
||||||
|
|
||||||
|
const IconGame = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="6" width="20" height="12" rx="4" />
|
||||||
|
<line x1="7" y1="12" x2="11" y2="12" /><line x1="9" y1="10" x2="9" y2="14" />
|
||||||
|
<circle cx="16" cy="11" r="0.6" fill="currentColor" /><circle cx="18" cy="13" r="0.6" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
export default function Game() {
|
export default function Game() {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#EDE0CE' }}>
|
<ComingSoon
|
||||||
<p style={{ color: '#8C7A65', fontSize: '15px', fontFamily: 'Nunito, sans-serif' }}>Dieser Bereich wird später kommen.</p>
|
icon={<IconGame />}
|
||||||
</div>
|
title="Spielend lernen"
|
||||||
|
subtitle="Kurze, verspielte Übungsrunden, die dein Wissen auf die Probe stellen – entspannt und mit Belohnung."
|
||||||
|
teaser={['Tägliche Mini-Challenges', 'Wiederholung schwacher Wörter', 'Extra-EP für Bestzeiten']}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import ComingSoon from '../components/ComingSoon'
|
||||||
|
|
||||||
|
const IconPro = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 17l4-9 5 6 5-9 4 12z" /><path d="M3 20h18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
export default function Pro() {
|
export default function Pro() {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#EDE0CE' }}>
|
<ComingSoon
|
||||||
<p style={{ color: '#8C7A65', fontSize: '15px', fontFamily: 'Nunito, sans-serif' }}>Dieser Bereich wird später kommen.</p>
|
icon={<IconPro />}
|
||||||
</div>
|
title="Snakkimo Pro"
|
||||||
|
subtitle="Lerne ohne Grenzen – mit unbegrenzten Karten, Offline-Modus und persönlichen Lernpfaden."
|
||||||
|
teaser={['Unbegrenzte tägliche Karten', 'Persönliche Lernpfade', 'Detaillierte Statistiken']}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,194 +1,236 @@
|
|||||||
/* ── Layout ────────────────────────────────────────────────── */
|
/* ── Layout ────────────────────────────────────────────────── */
|
||||||
.profil {
|
.profil {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: #EDE0CE;
|
background: var(--bg);
|
||||||
padding: 0 16px 32px;
|
padding: env(safe-area-inset-top) var(--sp-4) var(--sp-6);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profil-logout {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(env(safe-area-inset-top) + 20px);
|
||||||
|
right: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-soft);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color var(--dur-fast), background var(--dur-fast);
|
||||||
|
}
|
||||||
|
.profil-logout:hover { color: var(--danger); background: var(--danger-soft); }
|
||||||
|
|
||||||
/* ── Header ────────────────────────────────────────────────── */
|
/* ── Header ────────────────────────────────────────────────── */
|
||||||
.profil-header {
|
.profil-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: var(--sp-3);
|
||||||
padding: 20px 4px 16px;
|
padding: 20px 4px var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-wrap {
|
.avatar-wrap { position: relative; flex-shrink: 0; }
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated ring */
|
|
||||||
.avatar-ring {
|
.avatar-ring {
|
||||||
width: 64px;
|
width: 64px; height: 64px;
|
||||||
height: 64px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: conic-gradient(from 0deg, #EDE0CE, #C4A882, #7A5C3A, #D4B896, #EDE0CE);
|
background: var(--border-soft);
|
||||||
animation: spin-ring 4s linear infinite;
|
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin-ring {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-inner {
|
.avatar-inner {
|
||||||
width: 100%;
|
width: 100%; height: 100%;
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #EDE0CE;
|
background: var(--bg);
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 100%;
|
width: 100%; height: 100%;
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #7A5C3A, #4A3728);
|
background: linear-gradient(135deg, var(--accent), var(--text));
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
font-size: 18px; font-weight: 800; color: #F5EFE6; letter-spacing: 1px;
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #F5EFE6;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Online dot */
|
|
||||||
.online-dot {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2px;
|
|
||||||
right: 2px;
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
background: #7A5C3A;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid #EDE0CE;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.online-dot::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid #7A5C3A;
|
|
||||||
animation: pulse-ring 1.8s ease-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-ring {
|
|
||||||
0% { opacity: 0.7; transform: scale(0.8); }
|
|
||||||
100% { opacity: 0; transform: scale(1.8); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Level badge on avatar */
|
|
||||||
.avatar-level-badge {
|
.avatar-level-badge {
|
||||||
position: absolute;
|
position: absolute; top: -4px; right: -6px; z-index: 3;
|
||||||
top: -4px;
|
|
||||||
right: -6px;
|
|
||||||
z-index: 3;
|
|
||||||
filter: drop-shadow(0 1px 3px rgba(74, 55, 40, 0.3));
|
filter: drop-shadow(0 1px 3px rgba(74, 55, 40, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.profil-info {
|
.profil-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
display: flex;
|
.profil-name { font-family: var(--font-display); font-size: 19px; font-weight: 700; color: var(--text); }
|
||||||
flex-direction: column;
|
.profil-learning { font-size: 12px; color: var(--text-muted); font-weight: 600; }
|
||||||
gap: 2px;
|
.profil-streak { font-size: 12px; color: #C4853A; margin-top: 4px; font-weight: 700; }
|
||||||
}
|
.streak-warn { color: var(--danger); }
|
||||||
|
.streak-ok { color: var(--success); }
|
||||||
.profil-name {
|
|
||||||
font-family: 'Lora', Georgia, serif;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #4A3728;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profil-handle {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #7A5C3A;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cards ──────────────────────────────────────────────────── */
|
/* ── Cards ──────────────────────────────────────────────────── */
|
||||||
.progress-card,
|
.card {
|
||||||
.skills-card {
|
background: var(--surface);
|
||||||
background: #F5EFE6;
|
border: 1px solid var(--border-soft);
|
||||||
border: 0.5px solid #D4B896;
|
border-radius: var(--r-md);
|
||||||
border-radius: 16px;
|
padding: var(--sp-4);
|
||||||
padding: 16px;
|
margin-bottom: var(--sp-3);
|
||||||
margin-bottom: 12px;
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 800;
|
||||||
color: #8C7A65;
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.09em;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tagesziel ── */
|
||||||
|
.goal-card { display: flex; align-items: center; gap: var(--sp-4); }
|
||||||
|
.goal-ring-label { font-size: 14px; font-weight: 800; color: var(--text-strong); }
|
||||||
|
.goal-text { flex: 1; }
|
||||||
|
.goal-text .card-title { margin-bottom: 4px; }
|
||||||
|
.goal-value { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--text-strong); line-height: 1.2; }
|
||||||
|
.goal-value small { font-family: var(--font-ui); font-size: 13px; font-weight: 700; color: var(--text-muted); }
|
||||||
|
.goal-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
|
||||||
/* ── XP Section ─────────────────────────────────────────────── */
|
/* ── XP Section ─────────────────────────────────────────────── */
|
||||||
.xp-row {
|
.xp-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--sp-2); }
|
||||||
display: flex;
|
.lang-label { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||||
justify-content: space-between;
|
.xp-value { font-size: 13px; font-weight: 700; color: var(--accent); }
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-label {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #4A3728;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-value {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #7A5C3A;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-bar {
|
|
||||||
background: #D4B896;
|
|
||||||
border-radius: 99px;
|
|
||||||
height: 8px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.xp-bar { background: var(--surface-2); border-radius: var(--r-pill); height: 9px; width: 100%; margin-bottom: var(--sp-2); overflow: hidden; }
|
||||||
.xp-fill {
|
.xp-fill {
|
||||||
height: 100%;
|
height: 100%; border-radius: var(--r-pill);
|
||||||
border-radius: 99px;
|
background: linear-gradient(90deg, var(--accent), var(--gold));
|
||||||
background: #7A5C3A;
|
transition: width 0.7s var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Level row */
|
.level-row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.level-row {
|
.level-pill { background: var(--accent); color: var(--bg); font-size: 11px; font-weight: 800; padding: 3px 11px; border-radius: var(--r-pill); }
|
||||||
|
.level-hint { font-size: 11px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Capability-Satz: „was du jetzt kannst" statt nur Zahlen */
|
||||||
|
.capability-line {
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wochenvergleich */
|
||||||
|
.week-compare { font-size: 12px; font-weight: 700; margin-bottom: var(--sp-3); }
|
||||||
|
.week-compare.up { color: var(--success); }
|
||||||
|
.week-compare.down { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Sound-Toggle (spiegelt den Logout-Button, oben links) */
|
||||||
|
.profil-sound {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(env(safe-area-inset-top) + 20px);
|
||||||
|
left: 4px;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
padding: 6px; border-radius: var(--r-sm);
|
||||||
|
color: var(--text-soft);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: color var(--dur-fast), background var(--dur-fast);
|
||||||
|
}
|
||||||
|
.profil-sound:hover { color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
/* ── Wochen-Graph ── */
|
||||||
|
.weekbars { display: flex; align-items: flex-end; gap: var(--sp-2); height: 96px; }
|
||||||
|
.weekbar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; }
|
||||||
|
.weekbar-track { flex: 1; width: 100%; display: flex; align-items: flex-end; }
|
||||||
|
.weekbar-fill {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 4px;
|
||||||
|
border-radius: var(--r-sm) var(--r-sm) 4px 4px;
|
||||||
|
background: var(--gold);
|
||||||
|
transition: height 0.6s var(--ease);
|
||||||
|
}
|
||||||
|
.weekbar-fill.empty { background: var(--surface-sunk); }
|
||||||
|
.weekbar-fill.today { background: var(--accent); }
|
||||||
|
.weekbar-label { font-size: 10px; font-weight: 700; color: var(--text-soft); }
|
||||||
|
.weekbar-label.today { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Heatmap (kompakt) ── */
|
||||||
|
.heatmap { display: flex; gap: 3px; justify-content: center; }
|
||||||
|
.heatmap-col { display: flex; flex-direction: column; gap: 3px; flex: 0 0 auto; }
|
||||||
|
.heatmap-cell {
|
||||||
|
display: block;
|
||||||
|
width: 13px; height: 13px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--surface-sunk);
|
||||||
|
}
|
||||||
|
.heatmap-cell.lvl-0 { background: var(--surface-sunk); }
|
||||||
|
.heatmap-cell.lvl-1 { background: #E4D3B0; }
|
||||||
|
.heatmap-cell.lvl-2 { background: #D4B36E; }
|
||||||
|
.heatmap-cell.lvl-3 { background: var(--gold); }
|
||||||
|
.heatmap-cell.lvl-4 { background: var(--accent); }
|
||||||
|
|
||||||
|
.heatmap-legend {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
font-size: 10px; color: var(--text-soft); font-weight: 600;
|
||||||
|
}
|
||||||
|
.heatmap-legend .heatmap-cell { width: 11px; height: 11px; }
|
||||||
|
|
||||||
|
/* ── Kategorien ── */
|
||||||
|
.cat-list { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.cat-row { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.cat-head { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.cat-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.cat-label { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); }
|
||||||
|
.cat-tier { font-size: 11px; font-weight: 600; color: var(--text-soft); }
|
||||||
|
.cat-points { font-size: 12px; font-weight: 800; color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Erfolge ── */
|
||||||
|
.ach-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.ach-tile {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: var(--sp-3) var(--sp-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.ach-tile.on { background: var(--gold-soft); }
|
||||||
|
.ach-tile.off { background: var(--surface-2); opacity: 0.7; }
|
||||||
|
.ach-icon { font-size: 24px; line-height: 1; }
|
||||||
|
.ach-tile.off .ach-icon { filter: grayscale(1); opacity: 0.6; }
|
||||||
|
.ach-label { font-size: 11px; font-weight: 700; color: var(--text); line-height: 1.25; }
|
||||||
|
.ach-tile.off .ach-label { color: var(--text-muted); }
|
||||||
|
.cat-bar { height: 6px; width: 100%; background: var(--surface-2); border-radius: var(--r-pill); overflow: hidden; }
|
||||||
|
.cat-bar-fill { height: 100%; border-radius: var(--r-pill); transition: width 0.6s var(--ease); }
|
||||||
|
|
||||||
.level-pill {
|
/* ── Eckdaten ── */
|
||||||
background: #7A5C3A;
|
.stat-grid { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); }
|
||||||
color: #EDE0CE;
|
.stat-tile {
|
||||||
font-size: 11px;
|
flex: 1;
|
||||||
font-weight: 500;
|
background: var(--surface);
|
||||||
padding: 3px 10px;
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: 99px;
|
border-radius: var(--r-md);
|
||||||
|
padding: var(--sp-3) var(--sp-2);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
.stat-num { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--text-strong); }
|
||||||
|
.stat-cap { font-size: 10px; font-weight: 700; color: var(--text-muted); letter-spacing: 0.04em; text-align: center; }
|
||||||
|
|
||||||
.level-hint {
|
/* ── Radar ── */
|
||||||
font-size: 11px;
|
.radar-wrap { display: flex; justify-content: center; padding: var(--sp-2) 0 4px; }
|
||||||
color: #8C7A65;
|
.skills-empty { font-size: 13px; color: var(--text-muted); text-align: center; padding: var(--sp-4) 0; }
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Radar ───────────────────────────────────────────────────── */
|
.tracking-hint {
|
||||||
.radar-wrap {
|
font-size: 12px; color: var(--text-muted); text-align: center;
|
||||||
display: flex;
|
padding: var(--sp-2) var(--sp-4) var(--sp-4);
|
||||||
justify-content: center;
|
|
||||||
padding: 8px 0 4px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,153 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import './Profil.css'
|
import './Profil.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getProfilData, getActiveLearningPair, getLanguageOptions, langById } from '../api/directus'
|
import { getProfilData, getStats, getLanguageOptions, langById, getAchievements } from '../api/directus'
|
||||||
|
import ProgressRing from '../components/ProgressRing'
|
||||||
|
import { levelInfo } from '../utils/leveling'
|
||||||
|
import { categoryTier, capabilitySentence } from '../utils/praise'
|
||||||
|
import { isMuted, setMuted } from '../utils/sound'
|
||||||
|
import { streakState } from '../utils/streak'
|
||||||
|
|
||||||
const SKILLS = [
|
// Erdige Palette für die Kategorie-Dots/Balken (harmoniert mit dem Profil-Theme)
|
||||||
{ label: 'Vokabular', value: 0.78 },
|
const CAT_COLORS = ['#C4A85A', '#7A5C3A', '#3D7055', '#B5732E', '#5B7DB1', '#9C5A8A']
|
||||||
{ label: 'Grammatik', value: 0.65 },
|
|
||||||
{ label: 'Sprechen', value: 0.60 },
|
|
||||||
{ label: 'Hören', value: 0.52 },
|
|
||||||
{ label: 'Lesen', value: 0.62 },
|
|
||||||
]
|
|
||||||
|
|
||||||
/* ── Radar Chart ─────────────────────────────────────────────── */
|
function LogoutButton() {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
return (
|
||||||
|
<button onClick={logout} title="Abmelden" className="profil-logout">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundToggle() {
|
||||||
|
const [muted, setM] = useState(isMuted())
|
||||||
|
const toggle = () => { const next = !muted; setMuted(next); setM(next) }
|
||||||
|
return (
|
||||||
|
<button onClick={toggle} title={muted ? 'Töne an' : 'Töne aus'} className="profil-sound">
|
||||||
|
{muted ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Radar Chart (echte Skill-Daten) ─────────────────────────── */
|
||||||
function RadarChart({ skills, animate }) {
|
function RadarChart({ skills, animate }) {
|
||||||
const size = 220
|
const size = 220, cx = 110, cy = 105, r = 70, n = skills.length
|
||||||
const cx = 110
|
|
||||||
const cy = 105
|
|
||||||
const r = 70
|
|
||||||
const n = skills.length
|
|
||||||
|
|
||||||
const angle = (i) => (Math.PI * 2 * i) / n - Math.PI / 2
|
const angle = (i) => (Math.PI * 2 * i) / n - Math.PI / 2
|
||||||
|
const point = (i, ratio) => ({ x: cx + r * ratio * Math.cos(angle(i)), y: cy + r * ratio * Math.sin(angle(i)) })
|
||||||
const point = (i, ratio) => ({
|
const gridPoly = (ratio) => skills.map((_, i) => `${point(i, ratio).x},${point(i, ratio).y}`).join(' ')
|
||||||
x: cx + r * ratio * Math.cos(angle(i)),
|
const dataPoly = skills.map((s, i) => `${point(i, animate ? s.value : 0).x},${point(i, animate ? s.value : 0).y}`).join(' ')
|
||||||
y: cy + r * ratio * Math.sin(angle(i)),
|
const labelAnchor = (i) => { const x = Math.cos(angle(i)); return x > 0.1 ? 'start' : x < -0.1 ? 'end' : 'middle' }
|
||||||
})
|
const labelOffset = (i) => { const y = Math.sin(angle(i)); return y > 0.1 ? 10 : y < -0.1 ? -4 : 4 }
|
||||||
|
|
||||||
const gridPoly = (ratio) =>
|
|
||||||
skills.map((_, i) => `${point(i, ratio).x},${point(i, ratio).y}`).join(' ')
|
|
||||||
|
|
||||||
const dataPoly = skills
|
|
||||||
.map((s, i) => `${point(i, animate ? s.value : 0).x},${point(i, animate ? s.value : 0).y}`)
|
|
||||||
.join(' ')
|
|
||||||
|
|
||||||
const labelAnchor = (i) => {
|
|
||||||
const x = Math.cos(angle(i))
|
|
||||||
if (x > 0.1) return 'start'
|
|
||||||
if (x < -0.1) return 'end'
|
|
||||||
return 'middle'
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelOffset = (i) => {
|
|
||||||
const y = Math.sin(angle(i))
|
|
||||||
return y > 0.1 ? 10 : y < -0.1 ? -4 : 4
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} overflow="visible">
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} overflow="visible">
|
||||||
{[1, 0.8, 0.6, 0.4].map((lvl, idx) => (
|
{[1, 0.8, 0.6, 0.4].map((lvl, idx) => (
|
||||||
<polygon key={lvl} points={gridPoly(lvl)}
|
<polygon key={lvl} points={gridPoly(lvl)} fill="none" stroke="#D4B896" strokeWidth={idx === 0 ? 1 : 0.7} />
|
||||||
fill="none" stroke="#D4B896" strokeWidth={idx === 0 ? 1 : 0.7} />
|
|
||||||
))}
|
))}
|
||||||
{skills.map((_, i) => {
|
{skills.map((_, i) => {
|
||||||
const p = point(i, 1)
|
const p = point(i, 1)
|
||||||
return <line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y}
|
return <line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#D4B896" strokeWidth="0.7" />
|
||||||
stroke="#D4B896" strokeWidth="0.7" />
|
|
||||||
})}
|
})}
|
||||||
<polygon points={dataPoly}
|
<polygon points={dataPoly} fill="#C4A85A" fillOpacity="0.4" stroke="#7A5C3A" strokeWidth="1.5" strokeLinejoin="round"
|
||||||
fill="#C4A882" fillOpacity="0.45"
|
style={{ transition: animate ? 'all 0.7s ease-out' : 'none' }} />
|
||||||
stroke="#7A5C3A" strokeWidth="1.5" strokeLinejoin="round"
|
|
||||||
style={{ transition: animate ? 'all 0.7s ease-out' : 'none' }}
|
|
||||||
/>
|
|
||||||
{skills.map((s, i) => {
|
{skills.map((s, i) => {
|
||||||
const p = point(i, animate ? s.value : 0)
|
const p = point(i, animate ? s.value : 0)
|
||||||
return <circle key={i} cx={p.x} cy={p.y} r="4" fill="#7A5C3A"
|
return <circle key={i} cx={p.x} cy={p.y} r="4" fill="#7A5C3A"
|
||||||
style={{ transition: animate ? `all 0.7s ease-out ${i * 0.06}s` : 'none' }} />
|
style={{ transition: animate ? `all 0.7s ease-out ${i * 0.06}s` : 'none' }} />
|
||||||
})}
|
})}
|
||||||
{skills.map((s, i) => {
|
{skills.map((s, i) => {
|
||||||
const p = point(i, 1.28)
|
const p = point(i, 1.3)
|
||||||
return (
|
return (
|
||||||
<text key={i}
|
<text key={i} x={p.x} y={p.y + labelOffset(i)} textAnchor={labelAnchor(i)} dominantBaseline="middle"
|
||||||
x={p.x} y={p.y + labelOffset(i)}
|
fontSize="11" fontWeight="700" fill="#4A3728" fontFamily="var(--font-ui)">{s.label}</text>
|
||||||
textAnchor={labelAnchor(i)}
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize="11" fill="#4A3728" fontFamily="Nunito, sans-serif">
|
|
||||||
{s.label}
|
|
||||||
</text>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main Component ──────────────────────────────────────────── */
|
/* ── Streak-Heatmap (letzte 12 Wochen) ───────────────────────── */
|
||||||
|
function StreakHeatmap({ daily }) {
|
||||||
|
const byDate = new Map(daily.map(d => [d.date, d.ep]))
|
||||||
|
const WEEKS = 12, DAYS = WEEKS * 7
|
||||||
|
const today = new Date()
|
||||||
|
// Start so, dass die letzte Spalte mit heute endet; auf Wochenraster (Mo–So) ausrichten
|
||||||
|
const cells = []
|
||||||
|
for (let i = DAYS - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(today.getDate() - i)
|
||||||
|
const key = d.toISOString().slice(0, 10)
|
||||||
|
const ep = byDate.get(key) || 0
|
||||||
|
const level = ep === 0 ? 0 : ep < 10 ? 1 : ep < 25 ? 2 : ep < 50 ? 3 : 4
|
||||||
|
cells.push({ key, ep, level })
|
||||||
|
}
|
||||||
|
// In Spalten zu je 7 Tagen gruppieren
|
||||||
|
const cols = []
|
||||||
|
for (let c = 0; c < WEEKS; c++) cols.push(cells.slice(c * 7, c * 7 + 7))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="heatmap">
|
||||||
|
{cols.map((col, ci) => (
|
||||||
|
<div key={ci} className="heatmap-col">
|
||||||
|
{col.map((cell) => (
|
||||||
|
<span key={cell.key} className={`heatmap-cell lvl-${cell.level}`}
|
||||||
|
title={`${cell.key}: ${cell.ep} EP`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Wochen-Graph (letzte 7 Tage) ────────────────────────────── */
|
||||||
|
function WeekBars({ daily, goal }) {
|
||||||
|
const byDate = new Map(daily.map(d => [d.date, d.ep]))
|
||||||
|
const today = new Date()
|
||||||
|
const days = []
|
||||||
|
const NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(today.getDate() - i)
|
||||||
|
const key = d.toISOString().slice(0, 10)
|
||||||
|
days.push({ key, ep: byDate.get(key) || 0, name: NAMES[d.getDay()], isToday: i === 0 })
|
||||||
|
}
|
||||||
|
const max = Math.max(goal || 0, ...days.map(d => d.ep), 1)
|
||||||
|
return (
|
||||||
|
<div className="weekbars">
|
||||||
|
{days.map((d) => (
|
||||||
|
<div key={d.key} className="weekbar-col">
|
||||||
|
<div className="weekbar-track">
|
||||||
|
<div className={`weekbar-fill ${d.ep > 0 ? '' : 'empty'} ${d.isToday ? 'today' : ''}`}
|
||||||
|
style={{ height: `${Math.round((d.ep / max) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`weekbar-label ${d.isToday ? 'today' : ''}`}>{d.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main ────────────────────────────────────────────────────── */
|
||||||
export default function Profil() {
|
export default function Profil() {
|
||||||
const { user, token } = useAuth()
|
const { user, token } = useAuth()
|
||||||
const [radarReady, setRadarReady] = useState(false)
|
const [radarReady, setRadarReady] = useState(false)
|
||||||
const [profil, setProfil] = useState(null)
|
const [profil, setProfil] = useState(null)
|
||||||
const [pair, setPair] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
const [langs, setLangs] = useState([])
|
const [langs, setLangs] = useState([])
|
||||||
|
const [achievements, setAchievements] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setRadarReady(true), 120)
|
const t = setTimeout(() => setRadarReady(true), 120)
|
||||||
@@ -98,98 +157,234 @@ export default function Profil() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [p, lp, langs] = await Promise.all([
|
const [p, langs] = await Promise.all([getProfilData(token), getLanguageOptions()])
|
||||||
getProfilData(token),
|
setProfil(p); setLangs(langs)
|
||||||
getActiveLearningPair(user.username, token),
|
} catch { /* Fallback unten */ }
|
||||||
getLanguageOptions(),
|
// Stats getrennt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
||||||
])
|
try { setStats(await getStats(token)) } catch { /* kein Tracking verfügbar */ }
|
||||||
setProfil(p)
|
// Erfolge – degradiert lautlos, falls /auth/achievements noch nicht deployed ist
|
||||||
setPair(lp)
|
try { setAchievements(await getAchievements(token)) } catch { /* keine Erfolge verfügbar */ }
|
||||||
setLangs(langs)
|
|
||||||
} catch {
|
|
||||||
// Profildaten nicht ladbar – zeige Fallback
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [token, user.username])
|
}, [token])
|
||||||
|
|
||||||
const displayName = profil?.username?.username_public || user?.username || '…'
|
const displayName = profil?.username || user?.username || '…'
|
||||||
const initials = displayName.slice(0, 2).toUpperCase()
|
const initials = displayName.slice(0, 2).toUpperCase()
|
||||||
const points = pair?.points ?? profil?.points_total ?? 0
|
const greeting = profil?.language_target_greeting || 'Hallo'
|
||||||
const level = pair?.current_level ?? 1
|
const points = profil?.total_ep ?? user?.total_ep ?? 0
|
||||||
const xpMax = level * 500
|
const li = levelInfo(points)
|
||||||
const xpPct = Math.min((points / xpMax) * 100, 100)
|
// Level + Progress immer als Set aus EINER Quelle (sonst „Level 0 / 33 % bis Level 1"-
|
||||||
const toLang = pair ? langById(pair.language_to, langs) : null
|
// Mischmasch, solange das Backend die neue Kurve noch nicht deployed hat).
|
||||||
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : 'Zielsprache'
|
const hasApiLevel = profil?.ep_to_next_level != null
|
||||||
const streak = profil?.streak_days ?? 0
|
const level = hasApiLevel ? profil.level : li.level
|
||||||
|
const epIntoLevel = hasApiLevel ? profil.ep_into_level : li.epIntoLevel
|
||||||
|
const epToNext = hasApiLevel ? profil.ep_to_next_level : li.epToNextLevel
|
||||||
|
const epPerLevel = Math.max(1, epIntoLevel + epToNext)
|
||||||
|
const xpPct = Math.min((epIntoLevel / epPerLevel) * 100, 100)
|
||||||
|
const toLang = profil?.language_target_id ? langById(profil.language_target_id, langs) : null
|
||||||
|
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : (profil?.language_target_titel || 'Zielsprache')
|
||||||
|
const streak = profil?.streak_days ?? user?.streak_days ?? 0
|
||||||
|
const streakSt = streakState(profil?.last_practice_at ?? user?.last_practice_at, streak)
|
||||||
|
|
||||||
|
const today = stats?.today
|
||||||
|
const goal = today?.daily_goal_ep || 30
|
||||||
|
const todayEp = today?.ep || 0
|
||||||
|
const goalPct = Math.min(todayEp / goal, 1)
|
||||||
|
const daily = stats?.daily || []
|
||||||
|
const totals = stats?.totals
|
||||||
|
const skills = stats?.skills || []
|
||||||
|
const hasSkillData = skills.some(s => s.seen > 0)
|
||||||
|
const accuracyPct = totals ? Math.round((totals.accuracy || 0) * 100) : null
|
||||||
|
const categories = stats?.categories || []
|
||||||
|
const maxCatPoints = Math.max(1, ...categories.map(c => c.points))
|
||||||
|
const capability = capabilitySentence(categories)
|
||||||
|
|
||||||
|
// Wochenvergleich (soziale/zeitliche Validierung) aus dem Tagesverlauf.
|
||||||
|
const weekCompare = useMemo(() => {
|
||||||
|
const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0)
|
||||||
|
let thisW = 0, lastW = 0
|
||||||
|
for (const d of daily) {
|
||||||
|
const dt = new Date(d.date); dt.setHours(0, 0, 0, 0)
|
||||||
|
const diff = Math.round((startOfToday - dt) / 86400000)
|
||||||
|
if (diff >= 0 && diff < 7) thisW += d.ep || 0
|
||||||
|
else if (diff >= 7 && diff < 14) lastW += d.ep || 0
|
||||||
|
}
|
||||||
|
const delta = lastW > 0 ? Math.round(((thisW - lastW) / lastW) * 100) : null
|
||||||
|
return { thisW, lastW, delta }
|
||||||
|
}, [daily])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="profil">
|
<div className="profil page-enter">
|
||||||
|
<SoundToggle />
|
||||||
|
<LogoutButton />
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="profil-header">
|
<div className="profil-header">
|
||||||
<div className="avatar-wrap">
|
<div className="avatar-wrap">
|
||||||
<div className="avatar-ring">
|
<div className="avatar-ring">
|
||||||
<div className="avatar-inner">
|
<div className="avatar-inner"><div className="avatar">{initials}</div></div>
|
||||||
<div className="avatar">{initials}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="online-dot" />
|
|
||||||
<div className="avatar-level-badge">
|
<div className="avatar-level-badge">
|
||||||
<svg viewBox="0 0 48 54" width="28" height="32">
|
<svg viewBox="0 0 48 54" width="28" height="32">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="hexGold2" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="hexGold2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stopColor="#C4A882" />
|
<stop offset="0%" stopColor="#C4A882" /><stop offset="50%" stopColor="#7A5C3A" /><stop offset="100%" stopColor="#4A3728" />
|
||||||
<stop offset="50%" stopColor="#7A5C3A" />
|
|
||||||
<stop offset="100%" stopColor="#4A3728" />
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<polygon points="24,2 46,14 46,40 24,52 2,40 2,14"
|
<polygon points="24,2 46,14 46,40 24,52 2,40 2,14" fill="url(#hexGold2)" stroke="rgba(255,220,100,0.2)" strokeWidth="1" />
|
||||||
fill="url(#hexGold2)" stroke="rgba(255,220,100,0.2)" strokeWidth="1" />
|
<text x="24" y="30" textAnchor="middle" dominantBaseline="middle" fontSize="16" fontWeight="800" fill="#F5EFE6" fontFamily="inherit">{level}</text>
|
||||||
<text x="24" y="30" textAnchor="middle" dominantBaseline="middle"
|
|
||||||
fontSize="16" fontWeight="800" fill="#F5EFE6" fontFamily="inherit">
|
|
||||||
{level}
|
|
||||||
</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="profil-info">
|
<div className="profil-info">
|
||||||
<h2 className="profil-name">{displayName}</h2>
|
<h2 className="profil-name">{greeting}, {displayName}</h2>
|
||||||
<p className="profil-handle">@{displayName.toLowerCase()}</p>
|
<p className="profil-learning">lernt {langLabel}</p>
|
||||||
{streak > 0 && (
|
{streak > 0 && (
|
||||||
<p style={{ fontSize: '12px', color: '#C4853A', marginTop: '4px' }}>
|
<p className="profil-streak">
|
||||||
🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak
|
🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak
|
||||||
|
{streakSt.state === 'at_risk' && <span className="streak-warn"> · noch {streakSt.hoursLeft} Std heute</span>}
|
||||||
|
{streakSt.state === 'safe' && <span className="streak-ok"> · heute gesichert ✓</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Progress Card ── */}
|
{/* ── Tagesziel ── */}
|
||||||
<div className="progress-card">
|
<div className="card goal-card">
|
||||||
|
<ProgressRing value={goalPct} size={72} stroke={8}
|
||||||
|
color={goalPct >= 1 ? 'var(--success)' : 'var(--gold)'}>
|
||||||
|
<span className="goal-ring-label">{Math.round(goalPct * 100)}%</span>
|
||||||
|
</ProgressRing>
|
||||||
|
<div className="goal-text">
|
||||||
|
<p className="card-title">TAGESZIEL</p>
|
||||||
|
<p className="goal-value">{todayEp} <small>/ {goal} EP heute</small></p>
|
||||||
|
<p className="goal-hint">
|
||||||
|
{goalPct >= 1 ? 'Geschafft – stark! 🎉' : `Noch ${goal - todayEp} EP bis zum Tagesziel`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Fortschritt (Level/EP) – führt mit Momentum statt nackter Zahl ── */}
|
||||||
|
<div className="card">
|
||||||
<p className="card-title">DEIN FORTSCHRITT</p>
|
<p className="card-title">DEIN FORTSCHRITT</p>
|
||||||
|
|
||||||
<div className="xp-row">
|
<div className="xp-row">
|
||||||
<span className="lang-label">{langLabel}</span>
|
|
||||||
<span className="xp-value">{points.toLocaleString('de')} / {xpMax.toLocaleString('de')} XP</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="xp-bar">
|
|
||||||
<div className="xp-fill" style={{ width: `${xpPct}%` }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="level-row">
|
|
||||||
<span className="level-pill">Level {level}</span>
|
<span className="level-pill">Level {level}</span>
|
||||||
<span className="level-hint">{(xpMax - points).toLocaleString('de')} XP bis Level {level + 1}</span>
|
<span className="xp-value">{Math.round(xpPct)} % bis Level {level + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="xp-bar"><div className="xp-fill" style={{ width: `${xpPct}%` }} /></div>
|
||||||
|
<div className="level-row">
|
||||||
|
<span className="level-hint">noch {epToNext.toLocaleString('de')} EP</span>
|
||||||
|
<span className="level-hint">{points.toLocaleString('de')} EP gesamt · {langLabel}</span>
|
||||||
|
</div>
|
||||||
|
{capability && <p className="capability-line">{capability}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Skills Card ── */}
|
{/* ── Wochen-Aktivität ── */}
|
||||||
<div className="skills-card">
|
{stats && (
|
||||||
<p className="card-title">FÄHIGKEITEN</p>
|
<div className="card">
|
||||||
<div className="radar-wrap">
|
<p className="card-title">DIESE WOCHE</p>
|
||||||
<RadarChart skills={SKILLS} animate={radarReady} />
|
{weekCompare.delta != null && (
|
||||||
|
<p className={`week-compare ${weekCompare.delta >= 0 ? 'up' : 'down'}`}>
|
||||||
|
{weekCompare.thisW} EP · {weekCompare.delta >= 0 ? '▲' : '▼'} {Math.abs(weekCompare.delta)} % {weekCompare.delta >= 0 ? 'mehr' : 'weniger'} als letzte Woche
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<WeekBars daily={daily} goal={goal} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Kategorien (Punkte je Thema) ── */}
|
||||||
|
{stats && (
|
||||||
|
<div className="card">
|
||||||
|
<p className="card-title">KATEGORIEN</p>
|
||||||
|
{categories.length ? (
|
||||||
|
<div className="cat-list">
|
||||||
|
{categories.map((c, i) => {
|
||||||
|
const color = CAT_COLORS[i % CAT_COLORS.length]
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="cat-row">
|
||||||
|
<div className="cat-head">
|
||||||
|
<span className="cat-dot" style={{ background: color }} />
|
||||||
|
<span className="cat-label">
|
||||||
|
{c.label || 'Allgemein'}
|
||||||
|
<span className="cat-tier"> · {categoryTier(c.points).label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="cat-points">{c.points} P</span>
|
||||||
|
</div>
|
||||||
|
<div className="cat-bar">
|
||||||
|
<div className="cat-bar-fill"
|
||||||
|
style={{ width: `${Math.round((c.points / maxCatPoints) * 100)}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="skills-empty">Sammle Punkte — deine Themen erscheinen hier.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Erfolge ── */}
|
||||||
|
{achievements.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<p className="card-title">ERFOLGE · {achievements.filter(a => a.unlocked).length}/{achievements.length}</p>
|
||||||
|
<div className="ach-grid">
|
||||||
|
{achievements.map(a => (
|
||||||
|
<div key={a.key} className={`ach-tile ${a.unlocked ? 'on' : 'off'}`} title={a.label}>
|
||||||
|
<span className="ach-icon">{a.unlocked ? a.icon : '🔒'}</span>
|
||||||
|
<span className="ach-label">{a.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Streak-Kalender ── */}
|
||||||
|
{stats && (
|
||||||
|
<div className="card">
|
||||||
|
<p className="card-title">AKTIVITÄT · 12 WOCHEN</p>
|
||||||
|
<StreakHeatmap daily={daily} />
|
||||||
|
<div className="heatmap-legend">
|
||||||
|
<span>weniger</span>
|
||||||
|
<span className="heatmap-cell lvl-0" /><span className="heatmap-cell lvl-1" />
|
||||||
|
<span className="heatmap-cell lvl-2" /><span className="heatmap-cell lvl-3" />
|
||||||
|
<span className="heatmap-cell lvl-4" />
|
||||||
|
<span>mehr</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Eckdaten ── */}
|
||||||
|
{totals && (
|
||||||
|
<div className="stat-grid">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span className="stat-num">{totals.pairs_practiced}</span>
|
||||||
|
<span className="stat-cap">Karten geübt</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span className="stat-num">{accuracyPct}%</span>
|
||||||
|
<span className="stat-cap">Genauigkeit</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span className="stat-num">{streak}</span>
|
||||||
|
<span className="stat-cap">Tage Streak</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Skills ── */}
|
||||||
|
<div className="card">
|
||||||
|
<p className="card-title">FÄHIGKEITEN</p>
|
||||||
|
{hasSkillData ? (
|
||||||
|
<div className="radar-wrap"><RadarChart skills={skills} animate={radarReady} /></div>
|
||||||
|
) : (
|
||||||
|
<p className="skills-empty">Leg los — deine Stärken erscheinen, sobald du Karten löst.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!stats && (
|
||||||
|
<p className="tracking-hint">Dein Lernverlauf wird ab jetzt aufgezeichnet — komm morgen wieder! 🌱</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/utils/chipTimings.js
Normal file
48
src/utils/chipTimings.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Karaoke-Timing: aus dem Satz + ElevenLabs-`alignment` pro Wort-/Objekt-Chip
|
||||||
|
// ein Zeitfenster (Medienzeit in Sekunden) berechnen, damit beim Vorlesen das
|
||||||
|
// gerade gesprochene Chip markiert werden kann.
|
||||||
|
//
|
||||||
|
// alignment-Format (ElevenLabs /with-timestamps):
|
||||||
|
// { characters: string[],
|
||||||
|
// character_start_times_seconds: number[],
|
||||||
|
// character_end_times_seconds: number[] }
|
||||||
|
// `characters.join('')` entspricht dem vertonten Klartext (Platzhalter durch Labels ersetzt).
|
||||||
|
|
||||||
|
const CHIP_RE = /\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}/g
|
||||||
|
|
||||||
|
// Liefert [{ id, label, start, end }] – ein Eintrag pro Chip-Vorkommen im Satz.
|
||||||
|
export function buildChipTimings(sentence, alignment) {
|
||||||
|
if (!sentence || !alignment) return []
|
||||||
|
const chars = alignment.characters
|
||||||
|
const starts = alignment.character_start_times_seconds
|
||||||
|
const ends = alignment.character_end_times_seconds
|
||||||
|
if (!Array.isArray(chars) || !Array.isArray(starts) || !Array.isArray(ends) || !chars.length) return []
|
||||||
|
|
||||||
|
const plain = chars.join('')
|
||||||
|
// Satz in derselben Reihenfolge nach Chip-Tokens absuchen; Labels sequenziell
|
||||||
|
// im Klartext lokalisieren (toleriert kleine Whitespace-Unterschiede).
|
||||||
|
const timings = []
|
||||||
|
let cursor = 0
|
||||||
|
for (const m of sentence.matchAll(CHIP_RE)) {
|
||||||
|
const label = m[1]
|
||||||
|
const id = m[3]
|
||||||
|
const startIdx = plain.indexOf(label, cursor)
|
||||||
|
if (startIdx === -1) continue
|
||||||
|
const endIdx = startIdx + label.length - 1
|
||||||
|
cursor = endIdx + 1
|
||||||
|
const start = starts[startIdx]
|
||||||
|
const end = ends[Math.min(endIdx, ends.length - 1)]
|
||||||
|
if (typeof start !== 'number' || typeof end !== 'number') continue
|
||||||
|
timings.push({ id, label, start, end })
|
||||||
|
}
|
||||||
|
return timings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Id des Chips, dessen Zeitfenster t (Sekunden) enthält – sonst null.
|
||||||
|
export function activeChipIdAt(timings, t) {
|
||||||
|
if (!timings?.length || typeof t !== 'number') return null
|
||||||
|
for (const c of timings) {
|
||||||
|
if (t >= c.start && t < c.end) return c.id
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
24
src/utils/leveling.js
Normal file
24
src/utils/leveling.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Spiegelt die Backend-Kurve (snakkimo-API/src/lib/leveling.js).
|
||||||
|
// Dient als Fallback, falls die API noch keine level/ep_into_level-Felder liefert,
|
||||||
|
// und für die %-Anzeige innerhalb eines Levels.
|
||||||
|
export function epForLevel(level) {
|
||||||
|
return level <= 0 ? 0 : 5 * level * (level + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelForEp(ep) {
|
||||||
|
const e = Math.max(0, ep || 0)
|
||||||
|
return Math.floor((-15 + Math.sqrt(225 + 20 * e)) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelInfo(ep) {
|
||||||
|
const e = Math.max(0, ep || 0)
|
||||||
|
const level = levelForEp(e)
|
||||||
|
const base = epForLevel(level)
|
||||||
|
const next = epForLevel(level + 1)
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
epIntoLevel: e - base,
|
||||||
|
epToNextLevel: next - e,
|
||||||
|
epForNextLevel: next - base,
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/utils/praise.js
Normal file
42
src/utils/praise.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Variables Erfolgs-Feedback statt immer „✓ Richtig!" — kleine Abwechslung
|
||||||
|
// hält den Belohnungsmoment frisch.
|
||||||
|
const PRAISE = ['Stark!', 'Genau!', 'Sitzt!', 'Perfekt!', 'Bravo!', 'Sauber!', 'Weiter so!']
|
||||||
|
export function praise() {
|
||||||
|
return PRAISE[Math.floor(Math.random() * PRAISE.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ermutigende Einleitung für falsche Antworten — Fehler als Lernschritt rahmen,
|
||||||
|
// nicht als Bestrafung.
|
||||||
|
const ENCOURAGE = ['Fast!', 'Kein Problem —', 'Gleich hast du\'s!', 'Daraus lernst du:']
|
||||||
|
export function encourage() {
|
||||||
|
return ENCOURAGE[Math.floor(Math.random() * ENCOURAGE.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kategorie-Meisterungsstufen (MVP, client-seitig). Idealerweise später
|
||||||
|
// backend-/content-getrieben mit echten Schwellen je Thema → Plan C3.
|
||||||
|
const TIERS = [
|
||||||
|
{ min: 0, label: 'Erste Schritte' },
|
||||||
|
{ min: 5, label: 'Vertraut' },
|
||||||
|
{ min: 12, label: 'Sicher' },
|
||||||
|
{ min: 25, label: 'Gemeistert' },
|
||||||
|
]
|
||||||
|
export function categoryTier(points) {
|
||||||
|
let tier = TIERS[0]
|
||||||
|
for (const t of TIERS) if ((points || 0) >= t.min) tier = t
|
||||||
|
return tier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Punkte bis zur nächsten Stufe (für „2 P bis Vertraut").
|
||||||
|
export function pointsToNextTier(points) {
|
||||||
|
const p = points || 0
|
||||||
|
for (const t of TIERS) if (p < t.min) return { label: t.label, remaining: t.min - p }
|
||||||
|
return null // bereits höchste Stufe
|
||||||
|
}
|
||||||
|
|
||||||
|
// Story-Satz für die stärkste Kategorie — „was du jetzt kannst", nicht nur Zahlen.
|
||||||
|
export function capabilitySentence(categories) {
|
||||||
|
if (!categories?.length) return null
|
||||||
|
const top = categories[0]
|
||||||
|
const tier = categoryTier(top.points)
|
||||||
|
return `Dein stärkstes Thema: „${top.label}" — Stufe ${tier.label}.`
|
||||||
|
}
|
||||||
40
src/utils/secureToken.js
Normal file
40
src/utils/secureToken.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Sicherer Token-Speicher: iOS-Keychain (nativ, nicht aus dem WebView lesbar),
|
||||||
|
// im Browser-Dev fällt das Plugin auf localStorage zurück.
|
||||||
|
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin'
|
||||||
|
|
||||||
|
const KEY = 'snakkimo_token'
|
||||||
|
const LEGACY_LS_KEY = 'hejyou_token' // Alt-Token aus der localStorage-Ära
|
||||||
|
|
||||||
|
// get() wirft, wenn der Key fehlt → als "kein Token" behandeln.
|
||||||
|
async function rawGet() {
|
||||||
|
try {
|
||||||
|
const { value } = await SecureStoragePlugin.get({ key: KEY })
|
||||||
|
return value || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStoredToken(token) {
|
||||||
|
await SecureStoragePlugin.set({ key: KEY, value: token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearStoredToken() {
|
||||||
|
try { await SecureStoragePlugin.remove({ key: KEY }) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token aus dem sicheren Speicher lesen. Migriert dabei einmalig einen evtl. noch
|
||||||
|
// in localStorage liegenden Alt-Token, damit eingeloggte Nutzer eingeloggt bleiben.
|
||||||
|
export async function getStoredToken() {
|
||||||
|
const existing = await rawGet()
|
||||||
|
if (existing) return existing
|
||||||
|
try {
|
||||||
|
const legacy = localStorage.getItem(LEGACY_LS_KEY)
|
||||||
|
if (legacy) {
|
||||||
|
await setStoredToken(legacy)
|
||||||
|
localStorage.removeItem(LEGACY_LS_KEY)
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
} catch { /* localStorage nicht verfügbar – ignorieren */ }
|
||||||
|
return null
|
||||||
|
}
|
||||||
56
src/utils/sound.js
Normal file
56
src/utils/sound.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Dezente Belohnungs-Sounds via WebAudio (kein Asset-Download nötig).
|
||||||
|
// Mute-Pref in localStorage; standardmäßig an.
|
||||||
|
const KEY = 'snakkimo_sound'
|
||||||
|
let ctx = null
|
||||||
|
|
||||||
|
function audioCtx() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
if (!ctx) {
|
||||||
|
const C = window.AudioContext || window.webkitAudioContext
|
||||||
|
ctx = C ? new C() : null
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMuted() {
|
||||||
|
try { return localStorage.getItem(KEY) === 'off' } catch { return false }
|
||||||
|
}
|
||||||
|
export function setMuted(muted) {
|
||||||
|
try { localStorage.setItem(KEY, muted ? 'off' : 'on') } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tone(freq, startOffset, dur, gain = 0.05) {
|
||||||
|
const ac = audioCtx()
|
||||||
|
if (!ac) return
|
||||||
|
const osc = ac.createOscillator()
|
||||||
|
const g = ac.createGain()
|
||||||
|
osc.type = 'sine'
|
||||||
|
osc.frequency.value = freq
|
||||||
|
osc.connect(g); g.connect(ac.destination)
|
||||||
|
const t = ac.currentTime + startOffset
|
||||||
|
g.gain.setValueAtTime(0.0001, t)
|
||||||
|
g.gain.linearRampToValueAtTime(gain, t + 0.02)
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur)
|
||||||
|
osc.start(t); osc.stop(t + dur + 0.02)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurzer, freundlicher Zwei-Ton bei richtiger Antwort.
|
||||||
|
export function playCorrect() {
|
||||||
|
if (isMuted()) return
|
||||||
|
try {
|
||||||
|
const ac = audioCtx()
|
||||||
|
if (ac?.state === 'suspended') ac.resume()
|
||||||
|
tone(660, 0, 0.12)
|
||||||
|
tone(880, 0.07, 0.16)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aufsteigende Fanfare bei Milestones (Level-Up, Streak, Tagesziel).
|
||||||
|
export function playMilestone() {
|
||||||
|
if (isMuted()) return
|
||||||
|
try {
|
||||||
|
const ac = audioCtx()
|
||||||
|
if (ac?.state === 'suspended') ac.resume()
|
||||||
|
;[523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.09, 0.24, 0.06))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
23
src/utils/speak.js
Normal file
23
src/utils/speak.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Browser-TTS-Fallback fürs Vorlesen, wenn kein (ladbares) Audio-File vorhanden ist.
|
||||||
|
|
||||||
|
const LANG_TTS = { sv: 'sv-SE', en: 'en-US', de: 'de-DE' }
|
||||||
|
|
||||||
|
// {{label.w/o:uuid}} und ⟦PHn:wort⟧-Tokens auf reinen Text reduzieren.
|
||||||
|
export function toPlainText(sentence) {
|
||||||
|
if (!sentence) return ''
|
||||||
|
return sentence
|
||||||
|
.replace(/\{\{([^.]+)\.[wo]:[0-9a-f-]{36}\}\}/g, '$1')
|
||||||
|
.replace(/[⟦〚]PH\d+:([^⟧〛]*)[⟧〛]/g, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liest den Satz per SpeechSynthesis vor. Gibt true zurück, wenn tatsächlich
|
||||||
|
// vorgelesen wurde (für Karten, die daran ein Unlock koppeln).
|
||||||
|
export function speak(sentence, lang) {
|
||||||
|
if (!window.speechSynthesis || !sentence) return false
|
||||||
|
window.speechSynthesis.cancel()
|
||||||
|
const utt = new SpeechSynthesisUtterance(toPlainText(sentence))
|
||||||
|
utt.lang = LANG_TTS[lang] || 'de-DE'
|
||||||
|
utt.rate = 0.7
|
||||||
|
window.speechSynthesis.speak(utt)
|
||||||
|
return true
|
||||||
|
}
|
||||||
20
src/utils/streak.js
Normal file
20
src/utils/streak.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Streak-Zustand aus last_practice_at ableiten — für den Loss-Aversion-Nudge.
|
||||||
|
// safe = heute schon geübt → Serie sicher
|
||||||
|
// at_risk = gestern zuletzt, heute noch nicht → läuft heute ab
|
||||||
|
// broken = Lücke ≥ 2 Tage → Serie effektiv verloren (Backend setzt beim nächsten Üben zurück)
|
||||||
|
// none = noch nie geübt
|
||||||
|
export function streakState(lastPracticeAt, streakDays = 0, now = new Date()) {
|
||||||
|
if (!lastPracticeAt) return { state: 'none', streakDays: 0, hoursLeft: 0 }
|
||||||
|
|
||||||
|
const last = new Date(lastPracticeAt)
|
||||||
|
const startToday = new Date(now); startToday.setHours(0, 0, 0, 0)
|
||||||
|
const startLast = new Date(last); startLast.setHours(0, 0, 0, 0)
|
||||||
|
const dayDiff = Math.round((startToday - startLast) / 86400000)
|
||||||
|
|
||||||
|
const endOfDay = new Date(now); endOfDay.setHours(24, 0, 0, 0)
|
||||||
|
const hoursLeft = Math.max(1, Math.ceil((endOfDay - now) / 3600000))
|
||||||
|
|
||||||
|
if (dayDiff <= 0) return { state: 'safe', streakDays, hoursLeft }
|
||||||
|
if (dayDiff === 1) return { state: 'at_risk', streakDays, hoursLeft }
|
||||||
|
return { state: 'broken', streakDays, hoursLeft }
|
||||||
|
}
|
||||||
49
src/utils/streakReminder.js
Normal file
49
src/utils/streakReminder.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
|
// Lokale Tages-Erinnerung – plant auf dem Gerät selbst, KEIN APNs/Push-Server nötig.
|
||||||
|
// Auf Web/Server ein No-op (Plugin nur nativ verfügbar).
|
||||||
|
const REMINDER_ID = 4711
|
||||||
|
const REMIND_HOUR = 19
|
||||||
|
|
||||||
|
async function getPlugin() {
|
||||||
|
if (!Capacitor?.isNativePlatform?.()) return null
|
||||||
|
try {
|
||||||
|
const mod = await import('@capacitor/local-notifications')
|
||||||
|
return mod.LocalNotifications
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erinnerung für heute (oder, wenn 19 Uhr vorbei, morgen) planen.
|
||||||
|
// Beim nächsten Login wird neu geplant → faktisch täglich, aber nie nervig wiederholend.
|
||||||
|
export async function scheduleStreakReminder(streakDays = 0) {
|
||||||
|
const LN = await getPlugin()
|
||||||
|
if (!LN) return
|
||||||
|
try {
|
||||||
|
const perm = await LN.requestPermissions()
|
||||||
|
if (perm.display !== 'granted') return
|
||||||
|
await LN.cancel({ notifications: [{ id: REMINDER_ID }] })
|
||||||
|
|
||||||
|
const at = new Date(); at.setHours(REMIND_HOUR, 0, 0, 0)
|
||||||
|
if (at <= new Date()) at.setDate(at.getDate() + 1)
|
||||||
|
|
||||||
|
await LN.schedule({
|
||||||
|
notifications: [{
|
||||||
|
id: REMINDER_ID,
|
||||||
|
title: 'Deine Serie wartet 🔥',
|
||||||
|
body: streakDays > 0
|
||||||
|
? `Halte deine ${streakDays}-Tage-Serie am Leben – kurz üben reicht!`
|
||||||
|
: 'Zeit für deine kurze Lern-Session!',
|
||||||
|
schedule: { at },
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
} catch { /* Erinnerung ist optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abbrechen, sobald heute geübt wurde (Serie ist gesichert).
|
||||||
|
export async function cancelStreakReminder() {
|
||||||
|
const LN = await getPlugin()
|
||||||
|
if (!LN) return
|
||||||
|
try { await LN.cancel({ notifications: [{ id: REMINDER_ID }] }) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user