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>
307 lines
14 KiB
TypeScript
307 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import {
|
|
View, Text, ActivityIndicator,
|
|
TouchableOpacity, Share, Alert,
|
|
TextInput, StyleSheet,
|
|
} from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { useLocalSearchParams, useRouter } from 'expo-router'
|
|
import { ArrowLeft, Share2, Pencil, Check, Trash2, GraduationCap } from 'lucide-react-native'
|
|
import { WebView } from 'react-native-webview'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { ENDPOINTS } from '@/lib/config'
|
|
import { C } from '@/lib/theme'
|
|
import { AISheet } from '@/components/AISheet'
|
|
import { MicButton } from '@/components/MicButton'
|
|
import { FlashcardSheet } from '@/components/FlashcardSheet'
|
|
import { useAudioRecorder } from '@/lib/useAudioRecorder'
|
|
|
|
interface Note {
|
|
id: string
|
|
title: string
|
|
content: string
|
|
updatedAt: string
|
|
notebookName?: string
|
|
}
|
|
|
|
const AI_MODES = [
|
|
{ key: 'improve', label: '✨ Améliorer le style' },
|
|
{ key: 'fix_grammar', label: '🔤 Corriger la grammaire' },
|
|
{ key: 'shorten', label: '✂️ Raccourcir' },
|
|
{ key: 'clarify', label: '💡 Clarifier' },
|
|
]
|
|
|
|
/** Extrait le texte brut depuis le HTML TipTap */
|
|
function htmlToPlainText(html: string): string {
|
|
return html
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<\/p>/gi, '\n')
|
|
.replace(/<\/h[1-6]>/gi, '\n')
|
|
.replace(/<\/li>/gi, '\n')
|
|
.replace(/<[^>]+>/g, '')
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim()
|
|
}
|
|
|
|
function tipTapToHtml(node: any): string {
|
|
if (!node) return ''
|
|
if (node.type === 'doc') return (node.content ?? []).map(tipTapToHtml).join('')
|
|
if (node.type === 'text') {
|
|
let t = (node.text ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
if (node.marks?.some((m: any) => m.type === 'bold')) t = `<strong>${t}</strong>`
|
|
if (node.marks?.some((m: any) => m.type === 'italic')) t = `<em>${t}</em>`
|
|
if (node.marks?.some((m: any) => m.type === 'code')) t = `<code>${t}</code>`
|
|
return t
|
|
}
|
|
const inner = (node.content ?? []).map(tipTapToHtml).join('')
|
|
switch (node.type) {
|
|
case 'paragraph': return `<p>${inner || '​'}</p>`
|
|
case 'heading': return `<h${node.attrs?.level ?? 1}>${inner}</h${node.attrs?.level ?? 1}>`
|
|
case 'bulletList': return `<ul>${inner}</ul>`
|
|
case 'orderedList': return `<ol>${inner}</ol>`
|
|
case 'listItem': return `<li>${inner}</li>`
|
|
case 'blockquote': return `<blockquote>${inner}</blockquote>`
|
|
case 'codeBlock': return `<pre><code>${inner}</code></pre>`
|
|
case 'hardBreak': return '<br>'
|
|
default: return inner
|
|
}
|
|
}
|
|
|
|
function buildHtml(content: string, title: string) {
|
|
const safeTitle = title.replace(/</g, '<').replace(/>/g, '>')
|
|
let body: string
|
|
const trimmed = content.trimStart()
|
|
if (trimmed.startsWith('<')) {
|
|
body = content
|
|
} else if (trimmed.startsWith('{')) {
|
|
try { body = tipTapToHtml(JSON.parse(content)) } catch { body = `<p>${content.replace(/\n/g, '<br>')}</p>` }
|
|
} else {
|
|
body = `<p>${content.replace(/\n/g, '<br>')}</p>`
|
|
}
|
|
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=3">
|
|
<style>
|
|
:root { --brand: #A47148; --ink: #1A1A18; --paper: #FAFAF8; --concrete: #8A8A82; --border: #E8E6E0; --code-bg: #f0ede8; }
|
|
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-text-size-adjust: 100%; }
|
|
body { font-family: -apple-system, 'Helvetica Neue', sans-serif; font-size: 16px; line-height: 1.75; color: var(--ink); background: var(--paper); padding: 20px 20px 80px; word-break: break-word; }
|
|
.note-title { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; color: var(--ink); margin-bottom: 4px; line-height: 1.3; }
|
|
.note-sep { border: none; border-top: 1px solid var(--border); margin: 16px 0 20px; }
|
|
h1 { font-size: 22px; font-weight: 700; margin: 24px 0 10px; }
|
|
h2 { font-size: 19px; font-weight: 700; margin: 20px 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
|
h3 { font-size: 16px; font-weight: 600; margin: 16px 0 6px; }
|
|
p { margin: 0 0 12px; }
|
|
ul, ol { padding-left: 24px; margin: 0 0 12px; }
|
|
li { margin-bottom: 4px; }
|
|
li::marker { color: var(--brand); }
|
|
blockquote { border-left: 3px solid var(--brand); margin: 16px 0; padding: 10px 14px; background: #f7f2ec; border-radius: 0 10px 10px 0; color: #5a5a52; font-style: italic; }
|
|
blockquote p { margin: 0; }
|
|
code { background: var(--code-bg); padding: 2px 7px; border-radius: 6px; font-size: 13px; font-family: 'Menlo', 'Courier New', monospace; color: var(--brand); }
|
|
pre { background: #1e1e1c; padding: 16px; border-radius: 12px; overflow-x: auto; margin: 16px 0; }
|
|
pre code { background: none; padding: 0; color: #e8e6e0; font-size: 13px; line-height: 1.6; }
|
|
a { color: var(--brand); text-decoration: none; border-bottom: 1px solid #d4b896; }
|
|
img { max-width: 100%; border-radius: 12px; margin: 12px 0; display: block; }
|
|
hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
|
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
|
|
th { background: var(--code-bg); font-weight: 700; padding: 10px 12px; text-align: left; border-bottom: 2px solid var(--border); }
|
|
td { padding: 9px 12px; border-bottom: 1px solid var(--border); }
|
|
tr:last-child td { border-bottom: none; }
|
|
strong, b { font-weight: 700; }
|
|
em, i { font-style: italic; }
|
|
del, s { text-decoration: line-through; color: var(--concrete); }
|
|
mark { background: #fff3cd; padding: 1px 3px; border-radius: 3px; }
|
|
input[type=checkbox] { accent-color: var(--brand); margin-right: 6px; width: 16px; height: 16px; }
|
|
ul[data-type="taskList"] { list-style: none; padding-left: 4px; }
|
|
ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; }
|
|
ul[data-type="taskList"] li > label { margin-top: 2px; }
|
|
[data-type="liveBlock"], [data-type="structuredViewBlock"] { border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; margin: 8px 0; background: #f8f6f2; color: var(--concrete); font-size: 13px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="note-title">${safeTitle}</div>
|
|
<hr class="note-sep">
|
|
<div id="content">${body}</div>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
export default function NoteScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>()
|
|
const [note, setNote] = useState<Note | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [editMode, setEditMode] = useState(false)
|
|
const [editTitle, setEditTitle] = useState('')
|
|
const [editContent, setEditContent] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const [aiSheetOpen, setAiSheetOpen] = useState(false)
|
|
const [flashcardSheetOpen, setFlashcardSheetOpen] = useState(false)
|
|
const router = useRouter()
|
|
|
|
// Audio en mode édition
|
|
const { state: audioState, startRecording, stopAndTranscribe, cancelRecording } = useAudioRecorder(
|
|
(text) => setEditContent((prev) => prev ? prev + ' ' + text : text)
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!id) return
|
|
apiFetch(ENDPOINTS.note(id as string))
|
|
.then((r) => { if (!r.ok) throw new Error(`Erreur ${r.status}`); return r.json() })
|
|
.then((data) => setNote(data.note ?? null))
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false))
|
|
}, [id])
|
|
|
|
const handleEdit = () => {
|
|
if (!note) return
|
|
setEditTitle(note.title)
|
|
setEditContent(htmlToPlainText(note.content ?? ''))
|
|
setEditMode(true)
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!note || !editTitle.trim()) return
|
|
setSaving(true)
|
|
try {
|
|
const res = await apiFetch(ENDPOINTS.note(note.id), {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ title: editTitle.trim(), content: editContent }),
|
|
})
|
|
if (!res.ok) throw new Error('Erreur de sauvegarde')
|
|
setNote((prev) => prev ? { ...prev, title: editTitle.trim(), content: editContent } : prev)
|
|
setEditMode(false)
|
|
} catch (e: any) {
|
|
Alert.alert('Erreur', e.message)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleMic = () => {
|
|
if (audioState === 'idle' || audioState === 'error') startRecording()
|
|
else if (audioState === 'recording') stopAndTranscribe()
|
|
else cancelRecording()
|
|
}
|
|
|
|
const handleShare = async () => {
|
|
if (!note) return
|
|
await Share.share({ title: note.title, message: `${note.title}\nhttps://memento-note.com` })
|
|
}
|
|
|
|
const handleDelete = () => {
|
|
if (!note) return
|
|
Alert.alert(
|
|
'Supprimer la note',
|
|
`Mettre "${note.title}" à la corbeille ?`,
|
|
[
|
|
{ text: 'Annuler', style: 'cancel' },
|
|
{
|
|
text: 'Supprimer', style: 'destructive',
|
|
onPress: async () => {
|
|
try {
|
|
const res = await apiFetch(ENDPOINTS.note(note.id), { method: 'DELETE' })
|
|
if (!res.ok) throw new Error(`Erreur ${res.status}`)
|
|
router.back()
|
|
} catch (e: any) {
|
|
Alert.alert('Erreur', e.message ?? 'Impossible de supprimer la note')
|
|
}
|
|
},
|
|
},
|
|
]
|
|
)
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={s.safe}>
|
|
<View style={s.header}>
|
|
<TouchableOpacity onPress={() => { if (editMode) setEditMode(false); else router.back() }} style={s.backBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
<ArrowLeft size={22} color={C.ink} />
|
|
</TouchableOpacity>
|
|
<Text style={s.headerTitle} numberOfLines={1}>{editMode ? (editTitle || '…') : (note?.title ?? '…')}</Text>
|
|
{note && !editMode && (
|
|
<>
|
|
<TouchableOpacity onPress={handleEdit} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
<Pencil size={18} color={C.concrete} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={() => setFlashcardSheetOpen(true)} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
<GraduationCap size={18} color={C.concrete} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={handleShare} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
<Share2 size={18} color={C.concrete} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={handleDelete} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
<Trash2 size={18} color="#e11d48" />
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
{editMode && (
|
|
<TouchableOpacity onPress={handleSave} disabled={saving || !editTitle.trim()} style={[s.saveBtn, (!editTitle.trim() || saving) && { opacity: 0.35 }]}>
|
|
{saving ? <ActivityIndicator size="small" color={C.white} /> : <Check size={18} color={C.white} />}
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{loading && <View style={s.center}><ActivityIndicator color={C.brand} size="large" /></View>}
|
|
{error && <View style={s.center}><Text style={{ color: '#e11d48' }}>{error}</Text></View>}
|
|
{!loading && !error && !note && <View style={s.center}><Text style={{ color: C.concrete }}>Note introuvable.</Text></View>}
|
|
|
|
{/* Mode lecture */}
|
|
{note && !editMode && (
|
|
<WebView
|
|
source={{ html: buildHtml(note.content ?? '', note.title ?? '') }}
|
|
style={{ flex: 1, backgroundColor: C.paper }}
|
|
javaScriptEnabled scrollEnabled showsVerticalScrollIndicator={false} originWhitelist={['*']}
|
|
/>
|
|
)}
|
|
|
|
{/* Mode édition */}
|
|
{note && editMode && (
|
|
<View style={{ flex: 1 }}>
|
|
<TextInput value={editTitle} onChangeText={setEditTitle} style={s.editTitle} placeholder="Titre…" placeholderTextColor={C.border} />
|
|
<View style={s.editDivider} />
|
|
<TextInput value={editContent} onChangeText={setEditContent} style={s.editContent} placeholder="Contenu…" placeholderTextColor={C.concrete} multiline textAlignVertical="top" scrollEnabled />
|
|
{/* Barre outils */}
|
|
<View style={s.toolbar}>
|
|
<MicButton state={audioState} onPress={handleMic} />
|
|
{audioState === 'recording'
|
|
? <Text style={s.recordHint}>● Enregistrement… Appuyez pour arrêter</Text>
|
|
: <TouchableOpacity onPress={() => setAiSheetOpen(true)} disabled={!editContent.trim()} style={[s.aiBtn, !editContent.trim() && { opacity: 0.35 }]} activeOpacity={0.8}>
|
|
<Text style={s.aiBtnText}>✨ Améliorer avec l'IA</Text>
|
|
</TouchableOpacity>}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<AISheet visible={aiSheetOpen} onClose={() => setAiSheetOpen(false)} text={editContent} onApply={(t) => setEditContent(t)} />
|
|
{note && (
|
|
<FlashcardSheet
|
|
visible={flashcardSheetOpen}
|
|
onClose={() => setFlashcardSheetOpen(false)}
|
|
noteId={note.id}
|
|
noteTitle={note.title}
|
|
/>
|
|
)}
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
safe: { flex: 1, backgroundColor: C.paper },
|
|
header: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border, backgroundColor: C.paper },
|
|
backBtn: { padding: 4 },
|
|
headerTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: C.ink },
|
|
iconBtn: { padding: 4 },
|
|
saveBtn: { backgroundColor: C.brand, padding: 7, borderRadius: 10 },
|
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
editTitle: { fontSize: 24, fontWeight: '800', color: C.ink, paddingHorizontal: 20, paddingTop: 16, paddingBottom: 8, letterSpacing: -0.5 },
|
|
editDivider: { height: 1, backgroundColor: C.border, marginHorizontal: 20, marginBottom: 12 },
|
|
editContent: { flex: 1, fontSize: 16, color: C.ink, lineHeight: 26, paddingHorizontal: 20, paddingBottom: 80 },
|
|
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 12, borderTopWidth: 1, borderTopColor: C.border, backgroundColor: C.paper },
|
|
recordHint: { 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' },
|
|
aiBtnText: { color: C.white, fontWeight: '600', fontSize: 14 },
|
|
})
|