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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user