Files
Momento/memento-mobile/app/note/[id].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

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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 || '&#8203;'}</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, '&lt;').replace(/>/g, '&gt;')
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 },
})