feat: persönlichere Profilseite + iOS-App-Setup

Profil: Begrüßung in Zielsprache, Kategorie-Punkte-Übersicht,
ruhigerer Header (kein rotierender Avatar/Online-Dot), Notch-Fix
und kompaktere Aktivitäts-Heatmap. Außerdem Capacitor-iOS-Projekt
und diverse Auth/Feed/Audio-Verbesserungen aus dem Premium-Redesign.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:55:13 +02:00
parent 712f9a243c
commit e7b4ec571e
49 changed files with 3221 additions and 210 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef, useCallback } from 'react'
import './Feed.css'
import { useAuth } from '../context/AuthContext'
import { getFeedPairs, saveProgress, getUserProgress, getStats } from '../api/directus'
@@ -9,6 +9,7 @@ import PairWordCard from '../components/PairWordCard'
// Points per answer_type
const POINTS = { text: 2, yes_no: 2, word: 3, question: 3 }
const PAGE_SIZE = 20
function buildCard(pair) {
return {
@@ -24,18 +25,29 @@ export default function Feed() {
const [done, setDone] = useState(new Set())
const [loading, setLoading] = useState(true)
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
// 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(() => {
getFeedPairs(token, lang, 20)
getFeedPairs(token, lang, PAGE_SIZE)
.then(pairs => {
const built = pairs.map(buildCard)
setCards(built)
setEmpty(built.length === 0)
if (built.length < PAGE_SIZE) { exhaustedRef.current = true; setExhausted(true) }
})
.catch(err => { console.error('Feed load error', err); setEmpty(true) })
.finally(() => setLoading(false))
@@ -51,6 +63,41 @@ export default function Feed() {
.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'
@@ -77,14 +124,10 @@ export default function Feed() {
)
}
if (empty || visible.length === 0) {
if (empty) {
return (
<div className="feed page-enter">
<div className="feed-empty">
{cards.length === 0
? 'Noch keine Inhalte verfügbar.'
: 'Super! Alle Karten abgeschlossen. 🎉'}
</div>
<div className="feed-empty">Noch keine Inhalte verfügbar.</div>
</div>
)
}
@@ -119,6 +162,11 @@ export default function Feed() {
</div>
)
})}
{/* Nachlade-Bereich */}
{!exhausted && <div ref={sentinelRef} className="feed-sentinel" aria-hidden="true" />}
{loadingMore && <div className="feed-empty">Lade weitere Karten</div>}
{exhausted && <div className="feed-empty">Super! Alle Karten abgeschlossen. 🎉</div>}
</div>
)
}