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:
142
memento-mobile/components/AISheet.tsx
Normal file
142
memento-mobile/components/AISheet.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* AISheet — bottom sheet IA avec modes d'amélioration + résultat
|
||||
* Remplace les Alert.alert natifs par une UI propre au design Momento
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View, Text, TouchableOpacity, ActivityIndicator,
|
||||
ScrollView, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { Sparkles, Scissors, MessageSquare, Pencil, CheckCircle2, RefreshCw } from 'lucide-react-native'
|
||||
import { BottomSheet } from '@/components/BottomSheet'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
const MODES = [
|
||||
{ key: 'improve', label: 'Améliorer le style', icon: Sparkles, color: C.brand },
|
||||
{ key: 'fix_grammar', label: 'Corriger la grammaire', icon: CheckCircle2, color: '#5b7ec7' },
|
||||
{ key: 'shorten', label: 'Raccourcir', icon: Scissors, color: '#4a9b61' },
|
||||
{ key: 'clarify', label: 'Clarifier les idées', icon: MessageSquare,color: '#c77a3a' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
text: string
|
||||
onApply: (improved: string) => void
|
||||
}
|
||||
|
||||
export function AISheet({ visible, onClose, text, onApply }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<string | null>(null)
|
||||
const [selectedMode, setSelectedMode] = useState<string | null>(null)
|
||||
|
||||
const handleClose = () => {
|
||||
setResult(null)
|
||||
setSelectedMode(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleMode = async (mode: string) => {
|
||||
setSelectedMode(mode)
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.aiImprove, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, mode }),
|
||||
})
|
||||
const d = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
setResult(`⚠️ ${d.error === 'quota_exceeded' ? 'Quota IA dépassé' : (d.error ?? 'Erreur serveur')}`)
|
||||
return
|
||||
}
|
||||
setResult(d.improved ?? '')
|
||||
} catch {
|
||||
setResult('⚠️ Erreur réseau — vérifiez votre connexion.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
if (result && !result.startsWith('⚠️')) {
|
||||
onApply(result)
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
if (selectedMode) handleMode(selectedMode)
|
||||
}
|
||||
|
||||
const title = result ? 'Résultat IA' : 'Améliorer avec l\'IA'
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={handleClose} title={title}>
|
||||
{!result && !loading && (
|
||||
<View style={s.modeList}>
|
||||
{MODES.map((m) => {
|
||||
const Icon = m.icon
|
||||
return (
|
||||
<TouchableOpacity key={m.key} onPress={() => handleMode(m.key)} style={s.modeRow} activeOpacity={0.7}>
|
||||
<View style={[s.modeIcon, { backgroundColor: m.color + '18' }]}>
|
||||
<Icon size={18} color={m.color} />
|
||||
</View>
|
||||
<Text style={s.modeLabel}>{m.label}</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<View style={s.loadingBox}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
<Text style={s.loadingText}>Génération en cours…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{result && !loading && (
|
||||
<View style={s.resultBox}>
|
||||
<ScrollView style={s.resultScroll} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[s.resultText, result.startsWith('⚠️') && { color: '#e11d48' }]}>
|
||||
{result}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
{!result.startsWith('⚠️') && (
|
||||
<TouchableOpacity onPress={handleApply} style={s.applyBtn} activeOpacity={0.8}>
|
||||
<Pencil size={15} color={C.white} />
|
||||
<Text style={s.applyBtnText}>Remplacer le texte</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity onPress={handleRetry} style={s.retryBtn} activeOpacity={0.8}>
|
||||
<RefreshCw size={14} color={C.concrete} />
|
||||
<Text style={s.retryText}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
modeList: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8 },
|
||||
modeRow: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 14,
|
||||
paddingHorizontal: 12, paddingVertical: 14,
|
||||
borderRadius: 14, marginBottom: 4,
|
||||
},
|
||||
modeIcon: { width: 40, height: 40, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
|
||||
modeLabel: { fontSize: 15, fontWeight: '500', color: C.ink },
|
||||
loadingBox: { alignItems: 'center', paddingVertical: 36, gap: 12 },
|
||||
loadingText: { fontSize: 14, color: C.concrete },
|
||||
resultBox: { paddingHorizontal: 20, paddingTop: 8 },
|
||||
resultScroll: { maxHeight: 200, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 14, padding: 14, marginBottom: 14 },
|
||||
resultText: { fontSize: 15, color: C.ink, lineHeight: 23 },
|
||||
applyBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: C.ink, paddingVertical: 13, borderRadius: 14, justifyContent: 'center', marginBottom: 8 },
|
||||
applyBtnText: { color: C.white, fontWeight: '700', fontSize: 14 },
|
||||
retryBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, justifyContent: 'center', paddingVertical: 10 },
|
||||
retryText: { fontSize: 13, color: C.concrete },
|
||||
})
|
||||
80
memento-mobile/components/BottomSheet.tsx
Normal file
80
memento-mobile/components/BottomSheet.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* BottomSheet — modal bas d'écran respectant le design Momento
|
||||
* Usage:
|
||||
* <BottomSheet visible={v} onClose={() => setV(false)} title="Titre">
|
||||
* ...children
|
||||
* </BottomSheet>
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
View, Text, Modal, TouchableOpacity, Animated,
|
||||
Pressable, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { X } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function BottomSheet({ visible, onClose, title, children }: Props) {
|
||||
const translateY = useRef(new Animated.Value(400)).current
|
||||
const opacity = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.spring(translateY, { toValue: 0, useNativeDriver: true, damping: 20, stiffness: 200 }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
|
||||
]).start()
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(translateY, { toValue: 400, duration: 220, useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
||||
<Animated.View style={[s.overlay, { opacity }]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
|
||||
<Animated.View style={[s.sheet, { transform: [{ translateY }] }]}>
|
||||
{/* Handle bar */}
|
||||
<View style={s.handle} />
|
||||
{title && (
|
||||
<View style={s.titleRow}>
|
||||
<Text style={s.title}>{title}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={s.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<X size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(26,26,24,0.5)', justifyContent: 'flex-end' },
|
||||
sheet: {
|
||||
backgroundColor: C.paper,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingBottom: 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 16,
|
||||
elevation: 16,
|
||||
},
|
||||
handle: { width: 36, height: 4, backgroundColor: C.border, borderRadius: 2, alignSelf: 'center', marginTop: 12, marginBottom: 4 },
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
title: { flex: 1, fontSize: 15, fontWeight: '700', color: C.ink },
|
||||
closeBtn: { padding: 2 },
|
||||
})
|
||||
173
memento-mobile/components/FlashcardSheet.tsx
Normal file
173
memento-mobile/components/FlashcardSheet.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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 },
|
||||
})
|
||||
66
memento-mobile/components/MicButton.tsx
Normal file
66
memento-mobile/components/MicButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* MicButton — bouton enregistrement vocal avec feedback visuel
|
||||
* États : idle → recording (pulsé rouge) → processing (spinner)
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { TouchableOpacity, ActivityIndicator, Animated, StyleSheet, View, Text } from 'react-native'
|
||||
import { Mic, MicOff, Square } from 'lucide-react-native'
|
||||
import { AudioState } from '@/lib/useAudioRecorder'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
state: AudioState
|
||||
onPress: () => void
|
||||
errorMsg?: string | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function MicButton({ state, onPress, errorMsg, size = 20 }: Props) {
|
||||
const pulse = useRef(new Animated.Value(1)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'recording') {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1.25, duration: 600, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
])
|
||||
).start()
|
||||
} else {
|
||||
pulse.stopAnimation()
|
||||
pulse.setValue(1)
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const bgColor =
|
||||
state === 'recording' ? '#fee2e2' :
|
||||
state === 'processing' ? '#f3ece4' :
|
||||
state === 'error' ? '#fee2e2' :
|
||||
'#f3ece4'
|
||||
|
||||
const borderColor =
|
||||
state === 'recording' ? '#fca5a5' :
|
||||
state === 'error' ? '#fca5a5' :
|
||||
C.border
|
||||
|
||||
return (
|
||||
<Animated.View style={[s.wrap, { backgroundColor: bgColor, borderColor }, { transform: [{ scale: state === 'recording' ? pulse : 1 }] }]}>
|
||||
<TouchableOpacity onPress={onPress} disabled={state === 'processing'} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
{state === 'processing'
|
||||
? <ActivityIndicator size="small" color={C.brand} />
|
||||
: state === 'recording'
|
||||
? <Square size={size - 2} color="#e11d48" />
|
||||
: state === 'error'
|
||||
? <MicOff size={size} color="#e11d48" />
|
||||
: <Mic size={size} color={C.brand} />}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: {
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
borderWidth: 1, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
115
memento-mobile/components/TitleSheet.tsx
Normal file
115
memento-mobile/components/TitleSheet.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* TitleSheet — suggère 3 titres IA dans un bottom sheet propre
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
View, Text, TouchableOpacity, ActivityIndicator, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { Sparkles } from 'lucide-react-native'
|
||||
import { BottomSheet } from '@/components/BottomSheet'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
content: string
|
||||
onSelect: (title: string) => void
|
||||
}
|
||||
|
||||
export function TitleSheet({ visible, onClose, content, onSelect }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && content.trim()) {
|
||||
fetchTitles()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const fetchTitles = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuggestions([])
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.aiTitle, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
const d = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
setError(d.error === 'quota_exceeded' ? 'Quota dépassé' : 'Erreur serveur')
|
||||
return
|
||||
}
|
||||
const raw: unknown[] = d.suggestions ?? []
|
||||
setSuggestions(raw.map((s) => (typeof s === 'string' ? s : (s as any).title ?? '')).filter(Boolean))
|
||||
} catch {
|
||||
setError('Erreur réseau')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (t: string) => {
|
||||
onSelect(t)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={onClose} title="Titres suggérés">
|
||||
{loading && (
|
||||
<View style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
<Text style={s.hint}>Génération des titres…</Text>
|
||||
</View>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<View style={s.center}>
|
||||
<Text style={s.errorText}>{error}</Text>
|
||||
<TouchableOpacity onPress={fetchTitles} style={s.retryBtn}>
|
||||
<Text style={s.retryText}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{!loading && !error && suggestions.length > 0 && (
|
||||
<View style={s.list}>
|
||||
{suggestions.map((t, i) => (
|
||||
<TouchableOpacity key={i} onPress={() => handleSelect(t)} style={s.row} activeOpacity={0.7}>
|
||||
<View style={s.numBadge}>
|
||||
<Text style={s.numText}>{i + 1}</Text>
|
||||
</View>
|
||||
<Text style={s.titleText}>{t}</Text>
|
||||
<Sparkles size={14} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{!loading && !error && suggestions.length === 0 && (
|
||||
<View style={s.center}>
|
||||
<Text style={s.hint}>Aucune suggestion disponible</Text>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
center: { alignItems: 'center', paddingVertical: 32, gap: 12 },
|
||||
hint: { fontSize: 14, color: C.concrete },
|
||||
errorText: { fontSize: 14, color: '#e11d48' },
|
||||
retryBtn: { paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10, backgroundColor: C.border },
|
||||
retryText: { fontSize: 13, color: C.ink, fontWeight: '600' },
|
||||
list: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8 },
|
||||
row: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
paddingHorizontal: 12, paddingVertical: 14,
|
||||
borderRadius: 14, marginBottom: 8,
|
||||
backgroundColor: C.white, borderWidth: 1, borderColor: C.border,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
numBadge: { width: 26, height: 26, borderRadius: 13, backgroundColor: '#f3ece4', alignItems: 'center', justifyContent: 'center' },
|
||||
numText: { fontSize: 12, fontWeight: '700', color: C.brand },
|
||||
titleText: { flex: 1, fontSize: 14, fontWeight: '500', color: C.ink },
|
||||
})
|
||||
Reference in New Issue
Block a user