feat: mobile app complet + flashcards fixes + drag handle améliorations
Mobile app: - Révision flashcards : liste decks, session flip-card SM-2, couleurs harmonisées web - Génération flashcards depuis note (FlashcardSheet + route /api/mobile/flashcards/generate) - Audio Whisper : hook useAudioRecorder reécrit, MicButton avec erreurs - IA : AISheet (améliorer/clarifier/résumer), TitleSheet (titre automatique) - Suppression note (soft delete + confirmation Alert) - Note du jour : titre lisible + HTML (plus JSON TipTap brut) - Parser TipTap→HTML côté mobile (tipTapToHtml) - Icône 🎓 dans header note → génération flashcards - Endpoint flashcardGenerate dans config.ts Web fixes: - Bug flashcards groupées par carnet → deck par note (migration + schema) - Bug filtre 'cartes dues' ignoré (suppression fallback buildSessionQueue) - Suppression UI création deck manuelle (inutile) - Fix setViewType is not defined dans home-client.tsx Drag handle menu: - Fix : clearNodes() avant transformation (heading→liste/code/citation) - Ajout : option 'Texte' (paragraphe) dans Transformer en - Ajout : Monter / Descendre le bloc - Ajout : Copier le contenu du bloc - Fix : sous-menu hover stable (délai 200ms) - Fix : Supprimer en rouge via classe --danger (plus :first-child) - i18n : nouvelles clés dans 15 locales Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
174
memento-mobile/app/(tabs)/revision.tsx
Normal file
174
memento-mobile/app/(tabs)/revision.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, TouchableOpacity,
|
||||
ActivityIndicator, RefreshControl,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { GraduationCap, BookOpen, ChevronRight, Clock } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
|
||||
interface Deck {
|
||||
id: string
|
||||
name: string
|
||||
notebookName: string | null
|
||||
totalCards: number
|
||||
dueCount: number
|
||||
masteredCount: number
|
||||
}
|
||||
|
||||
export default function RevisionScreen() {
|
||||
const router = useRouter()
|
||||
const [decks, setDecks] = useState<Deck[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.flashcardDecks)
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`)
|
||||
setDecks(data.decks ?? [])
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Erreur de chargement')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const totalDue = decks.reduce((s, d) => s + d.dueCount, 0)
|
||||
|
||||
if (loading) return (
|
||||
<SafeAreaView style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
</SafeAreaView>
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
<View style={s.headerLeft}>
|
||||
<GraduationCap size={22} color={C.brand} />
|
||||
<Text style={s.title}>Révision</Text>
|
||||
</View>
|
||||
{totalDue > 0 && (
|
||||
<View style={s.badge}>
|
||||
<Text style={s.badgeTxt}>{totalDue} à revoir</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View style={s.center}>
|
||||
<Text style={s.errorTxt}>{error}</Text>
|
||||
<TouchableOpacity onPress={() => load()} style={s.retryBtn}>
|
||||
<Text style={s.retryTxt}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : decks.length === 0 ? (
|
||||
<View style={s.center}>
|
||||
<GraduationCap size={48} color={C.concrete} />
|
||||
<Text style={s.emptyTitle}>Aucun paquet</Text>
|
||||
<Text style={s.emptyHint}>
|
||||
Générez des flashcards depuis une note (bouton ✦ dans l'éditeur) pour commencer à réviser.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={decks}
|
||||
keyExtractor={(d) => d.id}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true) }} tintColor={C.brand} />}
|
||||
contentContainerStyle={s.list}
|
||||
renderItem={({ item }) => <DeckCard deck={item} onPress={() => router.push({ pathname: '/revision/session', params: { deckId: item.id, deckName: item.name } })} />}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
function DeckCard({ deck, onPress }: { deck: Deck; onPress: () => void }) {
|
||||
const progress = deck.totalCards > 0 ? deck.masteredCount / deck.totalCards : 0
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={s.card} onPress={onPress} activeOpacity={0.8}>
|
||||
<View style={s.cardLeft}>
|
||||
<View style={s.deckIcon}>
|
||||
<BookOpen size={18} color={C.brand} />
|
||||
</View>
|
||||
<View style={s.cardInfo}>
|
||||
<Text style={s.deckName} numberOfLines={1}>{deck.name}</Text>
|
||||
{deck.notebookName && <Text style={s.deckSub} numberOfLines={1}>{deck.notebookName}</Text>}
|
||||
<View style={s.progressBar}>
|
||||
<View style={[s.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
|
||||
</View>
|
||||
<Text style={s.progressTxt}>
|
||||
{deck.masteredCount}/{deck.totalCards} maîtrisées
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={s.cardRight}>
|
||||
{deck.dueCount > 0 ? (
|
||||
<View style={s.duePill}>
|
||||
<Clock size={11} color="#e11d48" />
|
||||
<Text style={s.dueTxt}>{deck.dueCount}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={s.upToDate}>✓</Text>
|
||||
)}
|
||||
<ChevronRight size={16} color={C.concrete} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
|
||||
header: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 20, paddingTop: 12, paddingBottom: 16,
|
||||
borderBottomWidth: 1, borderBottomColor: C.border,
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
title: { fontSize: 20, fontWeight: '700', color: C.ink },
|
||||
badge: { backgroundColor: '#fee2e2', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 20 },
|
||||
badgeTxt: { fontSize: 12, fontWeight: '600', color: '#e11d48' },
|
||||
list: { padding: 16, gap: 10 },
|
||||
card: {
|
||||
backgroundColor: C.surface, borderRadius: 14, padding: 14,
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderWidth: 1, borderColor: C.border,
|
||||
},
|
||||
cardLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, marginRight: 8 },
|
||||
deckIcon: {
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
backgroundColor: '#f0f7ff', alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
cardInfo: { flex: 1 },
|
||||
deckName: { fontSize: 15, fontWeight: '600', color: C.ink, marginBottom: 2 },
|
||||
deckSub: { fontSize: 12, color: C.concrete, marginBottom: 6 },
|
||||
progressBar: { height: 4, backgroundColor: C.border, borderRadius: 2, marginBottom: 4 },
|
||||
progressFill: { height: 4, backgroundColor: C.brand, borderRadius: 2 },
|
||||
progressTxt: { fontSize: 11, color: C.concrete },
|
||||
cardRight: { alignItems: 'flex-end', gap: 2 },
|
||||
duePill: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 3,
|
||||
backgroundColor: '#fee2e2', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,
|
||||
},
|
||||
dueTxt: { fontSize: 12, fontWeight: '700', color: '#e11d48' },
|
||||
upToDate: { fontSize: 16, color: C.brand, fontWeight: '700' },
|
||||
emptyTitle: { fontSize: 18, fontWeight: '700', color: C.ink, marginTop: 16, marginBottom: 8 },
|
||||
emptyHint: { fontSize: 14, color: C.concrete, textAlign: 'center', lineHeight: 20 },
|
||||
errorTxt: { fontSize: 15, color: '#e11d48', textAlign: 'center', marginBottom: 12 },
|
||||
retryBtn: { backgroundColor: C.brand, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
|
||||
retryTxt: { color: '#fff', fontWeight: '600' },
|
||||
})
|
||||
Reference in New Issue
Block a user