Files
Momento/memento-mobile/components/FlashcardSheet.tsx
Antigravity 0fa8978395
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped
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>
2026-05-29 18:49:40 +00:00

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 },
})