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>
174 lines
7.4 KiB
TypeScript
174 lines
7.4 KiB
TypeScript
/**
|
|
* FlashcardSheet — génère et sauvegarde des flashcards depuis une note
|
|
*/
|
|
import { useState } from 'react'
|
|
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView } from 'react-native'
|
|
import { GraduationCap, Check, RefreshCw } from 'lucide-react-native'
|
|
import { BottomSheet } from './BottomSheet'
|
|
import { C } from '@/lib/theme'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { ENDPOINTS } from '@/lib/config'
|
|
import { useRouter } from 'expo-router'
|
|
|
|
const STYLES = [
|
|
{ key: 'qa', label: 'Q&A', desc: 'Questions / Réponses' },
|
|
{ key: 'concept', label: 'Concept', desc: 'Terme / Définition' },
|
|
{ key: 'cloze', label: 'Cloze', desc: 'Texte à trous' },
|
|
] as const
|
|
|
|
const COUNTS = [5, 10, 15, 20]
|
|
|
|
interface Props {
|
|
visible: boolean
|
|
onClose: () => void
|
|
noteId: string
|
|
noteTitle: string
|
|
}
|
|
|
|
export function FlashcardSheet({ visible, onClose, noteId, noteTitle }: Props) {
|
|
const router = useRouter()
|
|
const [style, setStyle] = useState<'qa' | 'concept' | 'cloze'>('qa')
|
|
const [count, setCount] = useState(10)
|
|
const [loading, setLoading] = useState(false)
|
|
const [result, setResult] = useState<{ deckId: string; count: number } | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const generate = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
setResult(null)
|
|
try {
|
|
const res = await apiFetch(ENDPOINTS.flashcardGenerate, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ noteId, style, count }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`)
|
|
setResult({ deckId: data.deckId, count: data.count })
|
|
} catch (e: any) {
|
|
setError(e.message ?? 'Erreur de génération')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const goRevise = () => {
|
|
onClose()
|
|
router.push({ pathname: '/(tabs)/revision' })
|
|
}
|
|
|
|
const reset = () => { setResult(null); setError(null) }
|
|
|
|
return (
|
|
<BottomSheet visible={visible} onClose={onClose}>
|
|
<View style={s.header}>
|
|
<GraduationCap size={20} color={C.brand} />
|
|
<Text style={s.title}>Générer des flashcards</Text>
|
|
</View>
|
|
<Text style={s.noteTitle} numberOfLines={1}>📝 {noteTitle}</Text>
|
|
|
|
{!result && !loading && (
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
{/* Style */}
|
|
<Text style={s.sectionLabel}>Type de cartes</Text>
|
|
<View style={s.pills}>
|
|
{STYLES.map((st) => (
|
|
<TouchableOpacity
|
|
key={st.key}
|
|
style={[s.pill, style === st.key && s.pillActive]}
|
|
onPress={() => setStyle(st.key)}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[s.pillLabel, style === st.key && s.pillLabelActive]}>{st.label}</Text>
|
|
<Text style={[s.pillDesc, style === st.key && s.pillDescActive]}>{st.desc}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
{/* Nombre */}
|
|
<Text style={s.sectionLabel}>Nombre de cartes</Text>
|
|
<View style={s.countRow}>
|
|
{COUNTS.map((n) => (
|
|
<TouchableOpacity
|
|
key={n}
|
|
style={[s.countBtn, count === n && s.countBtnActive]}
|
|
onPress={() => setCount(n)}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[s.countTxt, count === n && s.countTxtActive]}>{n}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
{error && <Text style={s.error}>{error}</Text>}
|
|
|
|
<TouchableOpacity style={s.generateBtn} onPress={generate} activeOpacity={0.85}>
|
|
<GraduationCap size={16} color="#fff" />
|
|
<Text style={s.generateTxt}>Générer {count} cartes</Text>
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
)}
|
|
|
|
{loading && (
|
|
<View style={s.center}>
|
|
<ActivityIndicator color={C.brand} size="large" />
|
|
<Text style={s.loadingTxt}>Génération en cours…</Text>
|
|
</View>
|
|
)}
|
|
|
|
{result && (
|
|
<View style={s.resultWrap}>
|
|
<View style={s.checkCircle}>
|
|
<Check size={28} color="#16a34a" />
|
|
</View>
|
|
<Text style={s.resultTitle}>{result.count} cartes créées !</Text>
|
|
<Text style={s.resultSub}>Votre paquet est prêt pour la révision.</Text>
|
|
<View style={s.resultActions}>
|
|
<TouchableOpacity style={s.reviseBtn} onPress={goRevise} activeOpacity={0.85}>
|
|
<GraduationCap size={16} color="#fff" />
|
|
<Text style={s.reviseTxt}>Réviser maintenant</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={s.retryBtn} onPress={reset} activeOpacity={0.8}>
|
|
<RefreshCw size={14} color={C.concrete} />
|
|
<Text style={s.retryTxt}>Regénérer</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</BottomSheet>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
|
|
title: { fontSize: 17, fontWeight: '700', color: C.ink },
|
|
noteTitle: { fontSize: 13, color: C.concrete, marginBottom: 20 },
|
|
sectionLabel: { fontSize: 11, fontWeight: '700', color: C.concrete, letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 10 },
|
|
pills: { flexDirection: 'row', gap: 8, marginBottom: 20 },
|
|
pill: { flex: 1, padding: 12, borderRadius: 12, borderWidth: 1.5, borderColor: C.border, backgroundColor: C.paper },
|
|
pillActive: { borderColor: C.brand, backgroundColor: 'rgba(164,113,72,0.08)' },
|
|
pillLabel: { fontSize: 13, fontWeight: '700', color: C.ink, marginBottom: 2 },
|
|
pillLabelActive: { color: C.brand },
|
|
pillDesc: { fontSize: 10, color: C.concrete },
|
|
pillDescActive: { color: C.brand },
|
|
countRow: { flexDirection: 'row', gap: 8, marginBottom: 24 },
|
|
countBtn: { flex: 1, paddingVertical: 12, borderRadius: 12, borderWidth: 1.5, borderColor: C.border, alignItems: 'center', backgroundColor: C.paper },
|
|
countBtnActive: { borderColor: C.brand, backgroundColor: 'rgba(164,113,72,0.08)' },
|
|
countTxt: { fontSize: 15, fontWeight: '700', color: C.ink },
|
|
countTxtActive: { color: C.brand },
|
|
error: { color: '#e11d48', fontSize: 13, marginBottom: 12, textAlign: 'center' },
|
|
generateBtn: { backgroundColor: C.brand, borderRadius: 14, paddingVertical: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8 },
|
|
generateTxt: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
|
center: { alignItems: 'center', justifyContent: 'center', paddingVertical: 40 },
|
|
loadingTxt: { marginTop: 16, color: C.concrete, fontSize: 14 },
|
|
resultWrap: { alignItems: 'center', paddingVertical: 20 },
|
|
checkCircle: { width: 64, height: 64, borderRadius: 32, backgroundColor: '#dcfce7', alignItems: 'center', justifyContent: 'center', marginBottom: 16 },
|
|
resultTitle: { fontSize: 20, fontWeight: '800', color: C.ink, marginBottom: 8 },
|
|
resultSub: { fontSize: 14, color: C.concrete, marginBottom: 28 },
|
|
resultActions: { width: '100%', gap: 10 },
|
|
reviseBtn: { backgroundColor: C.brand, borderRadius: 14, paddingVertical: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8 },
|
|
reviseTxt: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
|
retryBtn: { borderRadius: 14, paddingVertical: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, borderWidth: 1, borderColor: C.border },
|
|
retryTxt: { color: C.concrete, fontWeight: '600', fontSize: 13 },
|
|
})
|