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>
178 lines
6.9 KiB
TypeScript
178 lines
6.9 KiB
TypeScript
import { useState, useRef } from 'react'
|
|
import {
|
|
View, Text, TextInput, TouchableOpacity, ScrollView,
|
|
KeyboardAvoidingView, Platform, ActivityIndicator,
|
|
Alert, StyleSheet,
|
|
} from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { useRouter, useLocalSearchParams } from 'expo-router'
|
|
import { Sparkles, X } from 'lucide-react-native'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { ENDPOINTS } from '@/lib/config'
|
|
import { C } from '@/lib/theme'
|
|
import { AISheet } from '@/components/AISheet'
|
|
import { TitleSheet } from '@/components/TitleSheet'
|
|
import { MicButton } from '@/components/MicButton'
|
|
import { useAudioRecorder } from '@/lib/useAudioRecorder'
|
|
|
|
export default function CreateNoteScreen() {
|
|
const router = useRouter()
|
|
const { notebookId } = useLocalSearchParams<{ notebookId?: string }>()
|
|
|
|
const [title, setTitle] = useState('')
|
|
const [content, setContent] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const [aiSheetOpen, setAiSheetOpen] = useState(false)
|
|
const [titleSheetOpen, setTitleSheetOpen] = useState(false)
|
|
const contentRef = useRef<TextInput>(null)
|
|
|
|
// Audio
|
|
const { state: audioState, startRecording, stopAndTranscribe, cancelRecording } = useAudioRecorder(
|
|
(text) => setContent((prev) => prev ? prev + ' ' + text : text)
|
|
)
|
|
|
|
const handleMic = () => {
|
|
if (audioState === 'idle' || audioState === 'error') startRecording()
|
|
else if (audioState === 'recording') stopAndTranscribe()
|
|
else cancelRecording()
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!title.trim()) {
|
|
Alert.alert('Titre requis', 'Donnez un titre à votre note.')
|
|
return
|
|
}
|
|
setSaving(true)
|
|
try {
|
|
const res = await apiFetch(ENDPOINTS.createNote, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ title: title.trim(), content, notebookId }),
|
|
})
|
|
if (!res.ok) {
|
|
const d = await res.json().catch(() => ({}))
|
|
throw new Error(d.error ?? 'Erreur serveur')
|
|
}
|
|
const { note } = await res.json()
|
|
router.replace({ pathname: '/note/[id]', params: { id: note.id } })
|
|
} catch (e: any) {
|
|
Alert.alert('Erreur', e.message)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={s.safe}>
|
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
|
|
|
|
{/* Header */}
|
|
<View style={s.header}>
|
|
<TouchableOpacity onPress={() => router.back()} style={s.cancelBtn}>
|
|
<X size={20} color={C.concrete} />
|
|
</TouchableOpacity>
|
|
<Text style={s.headerTitle}>Nouvelle note</Text>
|
|
<TouchableOpacity
|
|
onPress={handleSave}
|
|
disabled={saving || !title.trim()}
|
|
style={[s.saveBtn, (!title.trim() || saving) && s.saveBtnDisabled]}
|
|
>
|
|
{saving
|
|
? <ActivityIndicator size="small" color={C.white} />
|
|
: <Text style={s.saveBtnText}>Enregistrer</Text>}
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
|
|
{/* Titre */}
|
|
<View style={s.titleRow}>
|
|
<TextInput
|
|
value={title}
|
|
onChangeText={setTitle}
|
|
placeholder="Titre de la note…"
|
|
style={s.titleInput}
|
|
placeholderTextColor={C.border}
|
|
returnKeyType="next"
|
|
onSubmitEditing={() => contentRef.current?.focus()}
|
|
autoFocus
|
|
/>
|
|
{content.trim().length >= 10 && (
|
|
<TouchableOpacity onPress={() => setTitleSheetOpen(true)} style={s.sparkleBtn} activeOpacity={0.8}>
|
|
<Sparkles size={16} color={C.brand} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
<View style={s.divider} />
|
|
|
|
{/* Contenu */}
|
|
<TextInput
|
|
ref={contentRef}
|
|
value={content}
|
|
onChangeText={setContent}
|
|
placeholder="Commencez à écrire…"
|
|
style={s.contentInput}
|
|
placeholderTextColor={C.concrete}
|
|
multiline
|
|
textAlignVertical="top"
|
|
scrollEnabled={false}
|
|
/>
|
|
</ScrollView>
|
|
|
|
{/* Barre outils bas */}
|
|
<View style={s.toolbar}>
|
|
<MicButton state={audioState} onPress={handleMic} />
|
|
{audioState === 'recording' && (
|
|
<Text style={s.recordingHint}>● Enregistrement… Appuyez pour arrêter</Text>
|
|
)}
|
|
{audioState !== 'recording' && (
|
|
<TouchableOpacity
|
|
onPress={() => setAiSheetOpen(true)}
|
|
disabled={!content.trim()}
|
|
style={[s.aiBtn, !content.trim() && s.aiBtnDisabled]}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Sparkles size={15} color={C.white} />
|
|
<Text style={s.aiBtnText}>Améliorer avec l'IA</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
</KeyboardAvoidingView>
|
|
|
|
{/* Modaux propres */}
|
|
<AISheet
|
|
visible={aiSheetOpen}
|
|
onClose={() => setAiSheetOpen(false)}
|
|
text={content}
|
|
onApply={(improved) => setContent(improved)}
|
|
/>
|
|
<TitleSheet
|
|
visible={titleSheetOpen}
|
|
onClose={() => setTitleSheetOpen(false)}
|
|
content={content}
|
|
onSelect={(t) => setTitle(t)}
|
|
/>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
safe: { flex: 1, backgroundColor: C.paper },
|
|
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border },
|
|
cancelBtn: { padding: 4, marginRight: 4 },
|
|
headerTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: C.ink, textAlign: 'center' },
|
|
saveBtn: { backgroundColor: C.brand, paddingHorizontal: 14, paddingVertical: 7, borderRadius: 10 },
|
|
saveBtnDisabled: { opacity: 0.35 },
|
|
saveBtnText: { color: C.white, fontWeight: '700', fontSize: 13 },
|
|
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingTop: 20, paddingBottom: 8, gap: 8 },
|
|
titleInput: { flex: 1, fontSize: 26, fontWeight: '800', color: C.ink, letterSpacing: -0.5, lineHeight: 32 },
|
|
sparkleBtn: { padding: 8, borderRadius: 10, backgroundColor: '#f3ece4' },
|
|
divider: { height: 1, backgroundColor: C.border, marginHorizontal: 20, marginBottom: 16 },
|
|
contentInput: { flex: 1, fontSize: 16, color: C.ink, lineHeight: 26, paddingHorizontal: 20, paddingBottom: 120, minHeight: 300 },
|
|
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 12, borderTopWidth: 1, borderTopColor: C.border, backgroundColor: C.paper },
|
|
recordingHint: { flex: 1, fontSize: 13, color: '#e11d48', fontWeight: '500' },
|
|
aiBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: C.ink, paddingVertical: 11, paddingHorizontal: 14, borderRadius: 12, justifyContent: 'center' },
|
|
aiBtnDisabled: { opacity: 0.35 },
|
|
aiBtnText: { color: C.white, fontWeight: '600', fontSize: 14 },
|
|
})
|