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>
175 lines
6.6 KiB
TypeScript
175 lines
6.6 KiB
TypeScript
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' },
|
|
})
|