diff --git a/memento-mobile/.expo/types/router.d.ts b/memento-mobile/.expo/types/router.d.ts index 882e0ef..688dd76 100644 --- a/memento-mobile/.expo/types/router.d.ts +++ b/memento-mobile/.expo/types/router.d.ts @@ -6,9 +6,9 @@ export * from 'expo-router'; declare module 'expo-router' { export namespace ExpoRouter { export interface __routes { - hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownInputParams; } | { pathname: `/note/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `/notebook/[id]`, params: Router.UnknownInputParams & { id: string | number; } }; - hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownOutputParams; } | { pathname: `/note/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `/notebook/[id]`, params: Router.UnknownOutputParams & { id: string; } }; - href: Router.RelativePathString | Router.ExternalPathString | `/_sitemap${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/login${`?${string}` | `#${string}` | ''}` | `/login${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/home${`?${string}` | `#${string}` | ''}` | `/home${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/notebooks${`?${string}` | `#${string}` | ''}` | `/notebooks${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/profile${`?${string}` | `#${string}` | ''}` | `/profile${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/search${`?${string}` | `#${string}` | ''}` | `/search${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownInputParams; } | `/note/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/notebook/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | { pathname: `/note/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `/notebook/[id]`, params: Router.UnknownInputParams & { id: string | number; } }; + hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/revision` | `/revision`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownInputParams; } | { pathname: `/note/create`; params?: Router.UnknownInputParams; } | { pathname: `/revision/session`; params?: Router.UnknownInputParams; } | { pathname: `/note/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `/notebook/[id]`, params: Router.UnknownInputParams & { id: string | number; } }; + hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/revision` | `/revision`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownOutputParams; } | { pathname: `/note/create`; params?: Router.UnknownOutputParams; } | { pathname: `/revision/session`; params?: Router.UnknownOutputParams; } | { pathname: `/note/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `/notebook/[id]`, params: Router.UnknownOutputParams & { id: string; } }; + href: Router.RelativePathString | Router.ExternalPathString | `/_sitemap${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/login${`?${string}` | `#${string}` | ''}` | `/login${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/home${`?${string}` | `#${string}` | ''}` | `/home${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/notebooks${`?${string}` | `#${string}` | ''}` | `/notebooks${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/profile${`?${string}` | `#${string}` | ''}` | `/profile${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/revision${`?${string}` | `#${string}` | ''}` | `/revision${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/search${`?${string}` | `#${string}` | ''}` | `/search${`?${string}` | `#${string}` | ''}` | `/note/create${`?${string}` | `#${string}` | ''}` | `/revision/session${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/revision` | `/revision`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownInputParams; } | { pathname: `/note/create`; params?: Router.UnknownInputParams; } | { pathname: `/revision/session`; params?: Router.UnknownInputParams; } | `/note/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/notebook/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | { pathname: `/note/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `/notebook/[id]`, params: Router.UnknownInputParams & { id: string | number; } }; } } } diff --git a/memento-mobile/app.json b/memento-mobile/app.json index 68f3910..ec9d42c 100644 --- a/memento-mobile/app.json +++ b/memento-mobile/app.json @@ -21,11 +21,18 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#FAFAF8" }, - "package": "com.momentonote.app" + "package": "com.momentonote.app", + "permissions": ["android.permission.RECORD_AUDIO"] }, "plugins": [ "expo-router", - "expo-secure-store" + "expo-secure-store", + [ + "expo-av", + { + "microphonePermission": "Momento a besoin du microphone pour la saisie vocale." + } + ] ], "experiments": { "typedRoutes": true diff --git a/memento-mobile/app/(auth)/login.tsx b/memento-mobile/app/(auth)/login.tsx index 502020c..a2e4770 100644 --- a/memento-mobile/app/(auth)/login.tsx +++ b/memento-mobile/app/(auth)/login.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { View, Text, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, @@ -20,34 +20,35 @@ export default function LoginScreen() { const [googleLoading, setGoogleLoading] = useState(false) const { login, loginWithToken } = useAuthStore() - // Écoute le deep link memento://auth?token=... - useEffect(() => { - const handleUrl = async (event: { url: string }) => { - const parsed = Linking.parse(event.url) - if (parsed.scheme === 'memento' && parsed.path === 'auth') { - const p = parsed.queryParams as Record | undefined - if (p?.error) { - Alert.alert('Connexion Google échouée', p.error === 'unauthorized' ? 'Non autorisé' : 'Erreur serveur') - setGoogleLoading(false) - return - } - if (p?.token && p?.id) { - await loginWithToken(p.token, { - id: p.id, - name: p.name || null, - email: p.email || '', - tier: p.tier || 'FREE', - }) - } - setGoogleLoading(false) - } - } + // Traitement du deep link OAuth — mémorisé pour éviter les re-créations + const handleOAuthCallback = useCallback(async (url: string) => { + const parsed = Linking.parse(url) + // memento://auth?token=... → scheme='memento', hostname='auth', path='' + if (parsed.scheme !== 'memento' || parsed.hostname !== 'auth') return - const sub = Linking.addEventListener('url', handleUrl) - // Vérifier si l'app a été lancée via un deep link - Linking.getInitialURL().then((url) => { if (url) handleUrl({ url }) }) + const p = parsed.queryParams as Record | undefined + if (p?.error) { + Alert.alert('Connexion Google échouée', p.error === 'unauthorized' ? 'Non autorisé' : 'Erreur serveur') + setGoogleLoading(false) + return + } + if (p?.token && p?.id) { + await loginWithToken(p.token, { + id: p.id, + name: p.name || null, + email: p.email || '', + tier: p.tier || 'FREE', + }) + } + setGoogleLoading(false) + }, [loginWithToken]) + + // Écoute le deep link (Android + app lancée via deep link) + useEffect(() => { + const sub = Linking.addEventListener('url', (event) => handleOAuthCallback(event.url)) + Linking.getInitialURL().then((url) => { if (url) handleOAuthCallback(url) }) return () => sub.remove() - }, []) + }, [handleOAuthCallback]) const handleLogin = async () => { if (!email.trim() || !password.trim()) return @@ -71,11 +72,13 @@ export default function LoginScreen() { try { const url = `${API_URL}/api/mobile/auth/google-start` const result = await WebBrowser.openAuthSessionAsync(url, 'memento://auth') - // Si l'utilisateur ferme sans terminer - if (result.type !== 'success') { + if (result.type === 'success') { + // iOS : le deep link est capturé dans result.url, pas via Linking.addEventListener + await handleOAuthCallback(result.url) + } else { + // Annulé par l'utilisateur setGoogleLoading(false) } - // Si succès, le deep link listener prend le relais } catch (e: any) { Alert.alert('Erreur', e.message) setGoogleLoading(false) diff --git a/memento-mobile/app/(tabs)/_layout.tsx b/memento-mobile/app/(tabs)/_layout.tsx index f36d1ff..ba2d61f 100644 --- a/memento-mobile/app/(tabs)/_layout.tsx +++ b/memento-mobile/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import { Tabs } from 'expo-router' -import { BookOpen, Search, Home, User } from 'lucide-react-native' +import { BookOpen, Search, Home, User, GraduationCap } from 'lucide-react-native' import { C } from '@/lib/theme' export default function TabsLayout() { @@ -15,6 +15,7 @@ export default function TabsLayout() { > }} /> }} /> + }} /> }} /> }} /> diff --git a/memento-mobile/app/(tabs)/home.tsx b/memento-mobile/app/(tabs)/home.tsx index cdf7ece..10cb766 100644 --- a/memento-mobile/app/(tabs)/home.tsx +++ b/memento-mobile/app/(tabs)/home.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { - View, Text, ScrollView, TouchableOpacity, + View, Text, ScrollView, TouchableOpacity, Alert, ActivityIndicator, RefreshControl, StyleSheet, } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' @@ -42,10 +42,18 @@ export default function HomeScreen() { useEffect(() => { load() }, []) const handleDailyNote = async () => { - const res = await apiFetch(ENDPOINTS.dailyNote) - if (res.ok) { + try { + const res = await apiFetch(ENDPOINTS.dailyNote) + if (!res.ok) { + Alert.alert('Erreur', 'Impossible de charger la note du jour.') + return + } const data = await res.json() - router.push({ pathname: '/note/[id]', params: { id: data.id } }) + const id = data.id ?? data.note?.id + if (!id) { Alert.alert('Erreur', 'Note introuvable.'); return } + router.push({ pathname: '/note/[id]', params: { id } }) + } catch { + Alert.alert('Erreur réseau', 'Vérifiez votre connexion.') } } @@ -79,13 +87,13 @@ export default function HomeScreen() { Note du jour - router.push({ pathname: '/(tabs)/search' })} style={s.quickCard} activeOpacity={0.7}> + router.push({ pathname: '/note/create' })} style={s.quickCard} activeOpacity={0.7}> Nouvelle note - router.push({ pathname: '/(tabs)/search' })} style={s.quickCard} activeOpacity={0.7}> + router.push({ pathname: '/(tabs)/revision' })} style={s.quickCard} activeOpacity={0.7}> diff --git a/memento-mobile/app/(tabs)/notebooks.tsx b/memento-mobile/app/(tabs)/notebooks.tsx index d5014fa..78e81d9 100644 --- a/memento-mobile/app/(tabs)/notebooks.tsx +++ b/memento-mobile/app/(tabs)/notebooks.tsx @@ -5,7 +5,7 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' -import { ChevronRight, BookOpen, Folder } from 'lucide-react-native' +import { ChevronRight, BookOpen, Folder, Plus } from 'lucide-react-native' import { apiFetch } from '@/lib/api' import { ENDPOINTS } from '@/lib/config' import { C } from '@/lib/theme' @@ -65,6 +65,12 @@ export default function NotebooksScreen() { Carnets {notebooks.length} + router.push({ pathname: '/note/create' })} + style={s.newNoteBtn} + > + + ([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(null) + + const load = useCallback(async (silent = false) => { + if (!silent) setLoading(true) + setError(null) + try { + const res = await apiFetch(ENDPOINTS.flashcardDecks) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`) + setDecks(data.decks ?? []) + } catch (e: any) { + setError(e.message ?? 'Erreur de chargement') + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + const totalDue = decks.reduce((s, d) => s + d.dueCount, 0) + + if (loading) return ( + + + + ) + + return ( + + {/* Header */} + + + + Révision + + {totalDue > 0 && ( + + {totalDue} à revoir + + )} + + + {error ? ( + + {error} + load()} style={s.retryBtn}> + Réessayer + + + ) : decks.length === 0 ? ( + + + Aucun paquet + + Générez des flashcards depuis une note (bouton ✦ dans l'éditeur) pour commencer à réviser. + + + ) : ( + d.id} + refreshControl={ { setRefreshing(true); load(true) }} tintColor={C.brand} />} + contentContainerStyle={s.list} + renderItem={({ item }) => router.push({ pathname: '/revision/session', params: { deckId: item.id, deckName: item.name } })} />} + /> + )} + + ) +} + +function DeckCard({ deck, onPress }: { deck: Deck; onPress: () => void }) { + const progress = deck.totalCards > 0 ? deck.masteredCount / deck.totalCards : 0 + + return ( + + + + + + + {deck.name} + {deck.notebookName && {deck.notebookName}} + + + + + {deck.masteredCount}/{deck.totalCards} maîtrisées + + + + + {deck.dueCount > 0 ? ( + + + {deck.dueCount} + + ) : ( + + )} + + + + ) +} + +const s = StyleSheet.create({ + safe: { flex: 1, backgroundColor: C.paper }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + header: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingHorizontal: 20, paddingTop: 12, paddingBottom: 16, + borderBottomWidth: 1, borderBottomColor: C.border, + }, + headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + title: { fontSize: 20, fontWeight: '700', color: C.ink }, + badge: { backgroundColor: '#fee2e2', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 20 }, + badgeTxt: { fontSize: 12, fontWeight: '600', color: '#e11d48' }, + list: { padding: 16, gap: 10 }, + card: { + backgroundColor: C.surface, borderRadius: 14, padding: 14, + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + borderWidth: 1, borderColor: C.border, + }, + cardLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, marginRight: 8 }, + deckIcon: { + width: 40, height: 40, borderRadius: 12, + backgroundColor: '#f0f7ff', alignItems: 'center', justifyContent: 'center', + }, + cardInfo: { flex: 1 }, + deckName: { fontSize: 15, fontWeight: '600', color: C.ink, marginBottom: 2 }, + deckSub: { fontSize: 12, color: C.concrete, marginBottom: 6 }, + progressBar: { height: 4, backgroundColor: C.border, borderRadius: 2, marginBottom: 4 }, + progressFill: { height: 4, backgroundColor: C.brand, borderRadius: 2 }, + progressTxt: { fontSize: 11, color: C.concrete }, + cardRight: { alignItems: 'flex-end', gap: 2 }, + duePill: { + flexDirection: 'row', alignItems: 'center', gap: 3, + backgroundColor: '#fee2e2', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10, + }, + dueTxt: { fontSize: 12, fontWeight: '700', color: '#e11d48' }, + upToDate: { fontSize: 16, color: C.brand, fontWeight: '700' }, + emptyTitle: { fontSize: 18, fontWeight: '700', color: C.ink, marginTop: 16, marginBottom: 8 }, + emptyHint: { fontSize: 14, color: C.concrete, textAlign: 'center', lineHeight: 20 }, + errorTxt: { fontSize: 15, color: '#e11d48', textAlign: 'center', marginBottom: 12 }, + retryBtn: { backgroundColor: C.brand, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 }, + retryTxt: { color: '#fff', fontWeight: '600' }, +}) diff --git a/memento-mobile/app/_layout.tsx b/memento-mobile/app/_layout.tsx index c0752ee..45899d6 100644 --- a/memento-mobile/app/_layout.tsx +++ b/memento-mobile/app/_layout.tsx @@ -25,9 +25,11 @@ export default function RootLayout() { if (loading) { return ( - - - + + + + + ) } diff --git a/memento-mobile/app/note/[id].tsx b/memento-mobile/app/note/[id].tsx index ffb53b8..8141bd9 100644 --- a/memento-mobile/app/note/[id].tsx +++ b/memento-mobile/app/note/[id].tsx @@ -1,15 +1,20 @@ import { useEffect, useState } from 'react' import { View, Text, ActivityIndicator, - TouchableOpacity, Share, StyleSheet, + 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 } from 'lucide-react-native' +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 @@ -19,37 +24,71 @@ interface Note { notebookName?: string } -// Le contenu TipTap est stocké en HTML — on l'enveloppe dans un style CSS propre +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(//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, '>') + if (node.marks?.some((m: any) => m.type === 'bold')) t = `${t}` + if (node.marks?.some((m: any) => m.type === 'italic')) t = `${t}` + if (node.marks?.some((m: any) => m.type === 'code')) t = `${t}` + return t + } + const inner = (node.content ?? []).map(tipTapToHtml).join('') + switch (node.type) { + case 'paragraph': return `

${inner || '​'}

` + case 'heading': return `${inner}` + case 'bulletList': return `
    ${inner}
` + case 'orderedList': return `
    ${inner}
` + case 'listItem': return `
  • ${inner}
  • ` + case 'blockquote': return `
    ${inner}
    ` + case 'codeBlock': return `
    ${inner}
    ` + case 'hardBreak': return '
    ' + default: return inner + } +} + function buildHtml(content: string, title: string) { const safeTitle = title.replace(//g, '>') - // Détecter si le contenu est déjà du HTML ou du texte brut - const isHtml = content.trimStart().startsWith('<') - const body = isHtml ? content : `

    ${content.replace(/\n/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 = `

    ${content.replace(/\n/g, '
    ')}

    ` } + } else { + body = `

    ${content.replace(/\n/g, '
    ')}

    ` + } return ` @@ -99,50 +133,155 @@ export default function NoteScreen() { const [note, setNote] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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((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 ( - router.back()} style={s.backBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + { if (editMode) setEditMode(false); else router.back() }} style={s.backBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> - {note?.title ?? '…'} - {note && ( - - + {editMode ? (editTitle || '…') : (note?.title ?? '…')} + {note && !editMode && ( + <> + + + + setFlashcardSheetOpen(true)} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + + + + + + + + + + )} + {editMode && ( + + {saving ? : } )} {loading && } - {error && {error}} + {error && {error}} {!loading && !error && !note && Note introuvable.} - {note && ( + + {/* Mode lecture */} + {note && !editMode && ( + )} + + {/* Mode édition */} + {note && editMode && ( + + + + + {/* Barre outils */} + + + {audioState === 'recording' + ? ● Enregistrement… Appuyez pour arrêter + : setAiSheetOpen(true)} disabled={!editContent.trim()} style={[s.aiBtn, !editContent.trim() && { opacity: 0.35 }]} activeOpacity={0.8}> + ✨ Améliorer avec l'IA + } + + + )} + + setAiSheetOpen(false)} text={editContent} onApply={(t) => setEditContent(t)} /> + {note && ( + setFlashcardSheetOpen(false)} + noteId={note.id} + noteTitle={note.title} /> )} @@ -151,9 +290,17 @@ export default function NoteScreen() { const s = StyleSheet.create({ safe: { flex: 1, backgroundColor: C.paper }, - header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border, 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 }, - shareBtn: { padding: 4 }, + 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 }, }) diff --git a/memento-mobile/app/note/create.tsx b/memento-mobile/app/note/create.tsx new file mode 100644 index 0000000..ac4424c --- /dev/null +++ b/memento-mobile/app/note/create.tsx @@ -0,0 +1,177 @@ +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(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 ( + + + + {/* Header */} + + router.back()} style={s.cancelBtn}> + + + Nouvelle note + + {saving + ? + : Enregistrer} + + + + + {/* Titre */} + + contentRef.current?.focus()} + autoFocus + /> + {content.trim().length >= 10 && ( + setTitleSheetOpen(true)} style={s.sparkleBtn} activeOpacity={0.8}> + + + )} + + + + + {/* Contenu */} + + + + {/* Barre outils bas */} + + + {audioState === 'recording' && ( + ● Enregistrement… Appuyez pour arrêter + )} + {audioState !== 'recording' && ( + setAiSheetOpen(true)} + disabled={!content.trim()} + style={[s.aiBtn, !content.trim() && s.aiBtnDisabled]} + activeOpacity={0.8} + > + + Améliorer avec l'IA + + )} + + + + + {/* Modaux propres */} + setAiSheetOpen(false)} + text={content} + onApply={(improved) => setContent(improved)} + /> + setTitleSheetOpen(false)} + content={content} + onSelect={(t) => setTitle(t)} + /> + + ) +} + +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 }, +}) diff --git a/memento-mobile/app/notebook/[id].tsx b/memento-mobile/app/notebook/[id].tsx index 1001a71..b62a006 100644 --- a/memento-mobile/app/notebook/[id].tsx +++ b/memento-mobile/app/notebook/[id].tsx @@ -4,7 +4,7 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' -import { ArrowLeft } from 'lucide-react-native' +import { ArrowLeft, Plus } from 'lucide-react-native' import { apiFetch } from '@/lib/api' import { ENDPOINTS } from '@/lib/config' import { C } from '@/lib/theme' @@ -46,6 +46,12 @@ export default function NotebookScreen() {
    {notebookName || 'Carnet'} + router.push({ pathname: '/note/create', params: { notebookId: id } })} + style={s.addBtn} + > + + {loading @@ -73,6 +79,7 @@ const s = StyleSheet.create({ safe: { flex: 1, backgroundColor: C.paper }, header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border }, headerTitle: { fontSize: 17, fontWeight: '600', color: C.ink, flex: 1 }, + addBtn: { width: 34, height: 34, borderRadius: 17, backgroundColor: '#f3ece4', alignItems: 'center', justifyContent: 'center' }, center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, list: { paddingHorizontal: 20, paddingTop: 16, paddingBottom: 32 }, card: { backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, padding: 16, marginBottom: 10 }, diff --git a/memento-mobile/app/revision/session.tsx b/memento-mobile/app/revision/session.tsx new file mode 100644 index 0000000..108f19a --- /dev/null +++ b/memento-mobile/app/revision/session.tsx @@ -0,0 +1,237 @@ +import { useState, useCallback, useRef } from 'react' +import { + View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, + Animated, ScrollView, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ArrowLeft, GraduationCap } from 'lucide-react-native' +import { C } from '@/lib/theme' +import { apiFetch } from '@/lib/api' +import { ENDPOINTS } from '@/lib/config' +import { useFocusEffect } from 'expo-router' + +interface Card { + id: string + front: string + back: string + interval: number + type?: string +} + +type SessionState = 'loading' | 'reviewing' | 'done' | 'error' + +const GRADE_LABELS: { grade: 1 | 2 | 3 | 4; label: string; color: string; bg: string; border: string }[] = [ + { grade: 1, label: 'Oublié', color: '#dc2626', bg: 'rgba(239,68,68,0.10)', border: 'rgba(239,68,68,0.30)' }, + { grade: 2, label: 'Difficile',color: '#b45309', bg: 'rgba(245,158,11,0.10)', border: 'rgba(245,158,11,0.30)' }, + { grade: 3, label: 'Bien', color: '#047857', bg: 'rgba(16,185,129,0.10)', border: 'rgba(16,185,129,0.30)' }, + { grade: 4, label: 'Parfait', color: '#A47148', bg: 'rgba(164,113,72,0.10)', border: 'rgba(164,113,72,0.30)' }, +] + +export default function SessionScreen() { + const router = useRouter() + const params = useLocalSearchParams<{ deckId: string; deckName: string }>() + const { deckId, deckName } = params + + const [state, setState] = useState('loading') + const [cards, setCards] = useState([]) + const [index, setIndex] = useState(0) + const [flipped, setFlipped] = useState(false) + const [reviewed, setReviewed] = useState(0) + const [errorMsg, setErrorMsg] = useState(null) + + // Animation flip + const flipAnim = useRef(new Animated.Value(0)).current + const frontInterp = flipAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] }) + const backInterp = flipAnim.interpolate({ inputRange: [0, 1], outputRange: ['180deg', '360deg'] }) + + const loadSession = useCallback(async () => { + if (!deckId) return + setState('loading') + setIndex(0) + setFlipped(false) + setReviewed(0) + flipAnim.setValue(0) + try { + const res = await apiFetch(ENDPOINTS.flashcardSession(deckId)) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`) + setCards(data.cards ?? []) + setState(data.cards?.length === 0 ? 'done' : 'reviewing') + } catch (e: any) { + setErrorMsg(e.message ?? 'Erreur') + setState('error') + } + }, [deckId]) + + useFocusEffect(useCallback(() => { loadSession() }, [loadSession])) + + const flip = () => { + if (flipped) return + setFlipped(true) + Animated.spring(flipAnim, { toValue: 1, useNativeDriver: true, friction: 8 }).start() + } + + const grade = async (g: 1 | 2 | 3 | 4) => { + const card = cards[index] + if (!card) return + try { + await apiFetch(ENDPOINTS.flashcardReview, { + method: 'POST', + body: JSON.stringify({ cardId: card.id, grade: g }), + }) + } catch { /* silencieux — on avance quand même */ } + + const next = index + 1 + setReviewed((r) => r + 1) + if (next >= cards.length) { + setState('done') + } else { + setIndex(next) + setFlipped(false) + Animated.timing(flipAnim, { toValue: 0, duration: 0, useNativeDriver: true }).start() + } + } + + const current = cards[index] + const progress = cards.length > 0 ? index / cards.length : 0 + + return ( + + {/* Header */} + + router.back()} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + + {deckName ?? 'Révision'} + {state === 'reviewing' && ( + {index + 1}/{cards.length} + )} + + + {/* Barre de progression */} + {state === 'reviewing' && ( + + + + )} + + {/* Contenu */} + {state === 'loading' && ( + + + + )} + + {state === 'error' && ( + + {errorMsg} + + Réessayer + + + )} + + {state === 'done' && ( + + + Session terminée ! + + {reviewed > 0 ? `${reviewed} carte${reviewed > 1 ? 's' : ''} révisée${reviewed > 1 ? 's' : ''}` : 'Tout est à jour 🎉'} + + router.back()} style={s.doneBtn}> + Retour aux paquets + + + )} + + {state === 'reviewing' && current && ( + + {/* Carte flip */} + + {/* Face avant */} + + + Question + {current.front} + + {!flipped && ( + + Appuyez pour révéler la réponse + + )} + + + {/* Face arrière */} + + + Réponse + {current.back} + + + + + {/* Boutons de note (visibles seulement après flip) */} + {flipped && ( + + {GRADE_LABELS.map(({ grade: g, label, color, bg, border }) => ( + grade(g)} + activeOpacity={0.8} + > + {g} + {label} + + ))} + + )} + + )} + + ) +} + +const s = StyleSheet.create({ + safe: { flex: 1, backgroundColor: C.paper }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 12, + paddingHorizontal: 20, paddingVertical: 14, + borderBottomWidth: 1, borderBottomColor: C.border, + }, + headerTitle: { flex: 1, fontSize: 17, fontWeight: '700', color: C.ink }, + counter: { fontSize: 13, color: C.concrete, fontWeight: '600' }, + progressBarWrap: { height: 3, backgroundColor: C.border }, + progressBarFill: { height: 3, backgroundColor: C.brand }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + sessionWrap: { flex: 1, padding: 20, justifyContent: 'space-between' }, + cardWrap: { flex: 1, marginBottom: 16 }, + card: { + position: 'absolute', inset: 0, + borderRadius: 18, borderWidth: 1, borderColor: C.border, + backgroundColor: C.surface, + backfaceVisibility: 'hidden', + padding: 24, + }, + cardFront: { zIndex: 1 }, + cardBack: { backgroundColor: '#f0f7ff' }, + cardContent: { flexGrow: 1, justifyContent: 'center', alignItems: 'center', paddingBottom: 48 }, + cardLabel: { fontSize: 11, fontWeight: '700', color: C.brand, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 16 }, + cardText: { fontSize: 20, color: C.ink, textAlign: 'center', lineHeight: 30, fontWeight: '500' }, + tapHint: { position: 'absolute', bottom: 20, left: 0, right: 0, alignItems: 'center' }, + tapTxt: { fontSize: 12, color: C.concrete, fontStyle: 'italic' }, + gradeRow: { flexDirection: 'row', gap: 8 }, + gradeBtn: { + flex: 1, alignItems: 'center', paddingVertical: 12, borderRadius: 14, borderWidth: 1.5, + }, + gradeNum: { fontSize: 18, fontWeight: '800' }, + gradeLbl: { fontSize: 10, fontWeight: '600', marginTop: 2 }, + doneTitle: { fontSize: 24, fontWeight: '800', color: C.ink, marginTop: 20, marginBottom: 8 }, + doneSub: { fontSize: 15, color: C.concrete, marginBottom: 32 }, + doneBtn: { backgroundColor: C.brand, paddingHorizontal: 28, paddingVertical: 14, borderRadius: 14 }, + doneBtnTxt: { color: '#fff', fontWeight: '700', fontSize: 16 }, + errorTxt: { fontSize: 15, color: '#e11d48', textAlign: 'center', marginBottom: 12 }, + retryBtn: { backgroundColor: C.brand, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 }, + retryTxt: { color: '#fff', fontWeight: '600' }, +}) diff --git a/memento-mobile/components/AISheet.tsx b/memento-mobile/components/AISheet.tsx new file mode 100644 index 0000000..b681cc8 --- /dev/null +++ b/memento-mobile/components/AISheet.tsx @@ -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(null) + const [selectedMode, setSelectedMode] = useState(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 ( + + {!result && !loading && ( + + {MODES.map((m) => { + const Icon = m.icon + return ( + handleMode(m.key)} style={s.modeRow} activeOpacity={0.7}> + + + + {m.label} + + ) + })} + + )} + + {loading && ( + + + Génération en cours… + + )} + + {result && !loading && ( + + + + {result} + + + {!result.startsWith('⚠️') && ( + + + Remplacer le texte + + )} + + + Réessayer + + + )} + + ) +} + +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 }, +}) diff --git a/memento-mobile/components/BottomSheet.tsx b/memento-mobile/components/BottomSheet.tsx new file mode 100644 index 0000000..2f9f63e --- /dev/null +++ b/memento-mobile/components/BottomSheet.tsx @@ -0,0 +1,80 @@ +/** + * BottomSheet — modal bas d'écran respectant le design Momento + * Usage: + * setV(false)} title="Titre"> + * ...children + * + */ +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 ( + + + + + {/* Handle bar */} + + {title && ( + + {title} + + + + + )} + {children} + + + + ) +} + +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 }, +}) diff --git a/memento-mobile/components/FlashcardSheet.tsx b/memento-mobile/components/FlashcardSheet.tsx new file mode 100644 index 0000000..c5a119b --- /dev/null +++ b/memento-mobile/components/FlashcardSheet.tsx @@ -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(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 ( + + + + Générer des flashcards + + 📝 {noteTitle} + + {!result && !loading && ( + + {/* Style */} + Type de cartes + + {STYLES.map((st) => ( + setStyle(st.key)} + activeOpacity={0.8} + > + {st.label} + {st.desc} + + ))} + + + {/* Nombre */} + Nombre de cartes + + {COUNTS.map((n) => ( + setCount(n)} + activeOpacity={0.8} + > + {n} + + ))} + + + {error && {error}} + + + + Générer {count} cartes + + + )} + + {loading && ( + + + Génération en cours… + + )} + + {result && ( + + + + + {result.count} cartes créées ! + Votre paquet est prêt pour la révision. + + + + Réviser maintenant + + + + Regénérer + + + + )} + + ) +} + +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 }, +}) diff --git a/memento-mobile/components/MicButton.tsx b/memento-mobile/components/MicButton.tsx new file mode 100644 index 0000000..719aea5 --- /dev/null +++ b/memento-mobile/components/MicButton.tsx @@ -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 ( + + + {state === 'processing' + ? + : state === 'recording' + ? + : state === 'error' + ? + : } + + + ) +} + +const s = StyleSheet.create({ + wrap: { + width: 40, height: 40, borderRadius: 12, + borderWidth: 1, alignItems: 'center', justifyContent: 'center', + }, +}) diff --git a/memento-mobile/components/TitleSheet.tsx b/memento-mobile/components/TitleSheet.tsx new file mode 100644 index 0000000..b690724 --- /dev/null +++ b/memento-mobile/components/TitleSheet.tsx @@ -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([]) + const [error, setError] = useState(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 ( + + {loading && ( + + + Génération des titres… + + )} + {error && !loading && ( + + {error} + + Réessayer + + + )} + {!loading && !error && suggestions.length > 0 && ( + + {suggestions.map((t, i) => ( + handleSelect(t)} style={s.row} activeOpacity={0.7}> + + {i + 1} + + {t} + + + ))} + + )} + {!loading && !error && suggestions.length === 0 && ( + + Aucune suggestion disponible + + )} + + ) +} + +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 }, +}) diff --git a/memento-mobile/lib/api.ts b/memento-mobile/lib/api.ts index 5b6bd7e..112c798 100644 --- a/memento-mobile/lib/api.ts +++ b/memento-mobile/lib/api.ts @@ -3,11 +3,19 @@ import * as SecureStore from 'expo-secure-store' const TOKEN_KEY = 'memento_token' export async function getToken(): Promise { - return SecureStore.getItemAsync(TOKEN_KEY) + try { + return await SecureStore.getItemAsync(TOKEN_KEY) + } catch { + return null + } } export async function setToken(token: string): Promise { - await SecureStore.setItemAsync(TOKEN_KEY, token) + try { + await SecureStore.setItemAsync(TOKEN_KEY, token) + } catch (e) { + console.warn('[SecureStore] setToken failed:', e) + } } export async function clearToken(): Promise { diff --git a/memento-mobile/lib/config.ts b/memento-mobile/lib/config.ts index 13b77ad..451cd1b 100644 --- a/memento-mobile/lib/config.ts +++ b/memento-mobile/lib/config.ts @@ -13,6 +13,14 @@ export const ENDPOINTS = { ? `${API_URL}/api/mobile/notes?notebookId=${notebookId}` : `${API_URL}/api/mobile/notes`, note: (id: string) => `${API_URL}/api/mobile/notes/${id}`, + createNote: `${API_URL}/api/mobile/notes`, search: `${API_URL}/api/mobile/search`, - dailyNote: `${API_URL}/api/notes/daily`, + dailyNote: `${API_URL}/api/mobile/notes/daily`, + aiImprove: `${API_URL}/api/mobile/ai/improve`, + aiTitle: `${API_URL}/api/mobile/ai/title`, + aiTranscribe: `${API_URL}/api/mobile/ai/transcribe`, + flashcardDecks: `${API_URL}/api/mobile/flashcards/decks`, + flashcardSession: (deckId: string) => `${API_URL}/api/mobile/flashcards/session?deckId=${deckId}`, + flashcardReview: `${API_URL}/api/mobile/flashcards/review`, + flashcardGenerate: `${API_URL}/api/mobile/flashcards/generate`, } diff --git a/memento-mobile/lib/useAudioRecorder.ts b/memento-mobile/lib/useAudioRecorder.ts new file mode 100644 index 0000000..cf0252e --- /dev/null +++ b/memento-mobile/lib/useAudioRecorder.ts @@ -0,0 +1,100 @@ +import { useState, useRef } from 'react' +import { Audio } from 'expo-av' +import { getToken } from '@/lib/api' +import { ENDPOINTS } from '@/lib/config' + +export type AudioState = 'idle' | 'recording' | 'processing' | 'error' + +export function useAudioRecorder(onTranscript: (text: string) => void) { + const [state, setState] = useState('idle') + const [errorMsg, setErrorMsg] = useState(null) + const recordingRef = useRef(null) + + const startRecording = async () => { + setErrorMsg(null) + try { + // Demande de permission + const { granted } = await Audio.requestPermissionsAsync() + if (!granted) { + setErrorMsg('Permission micro refusée') + setState('error') + setTimeout(() => setState('idle'), 3000) + return + } + + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + playsInSilentModeIOS: true, + }) + + const { recording } = await Audio.Recording.createAsync( + Audio.RecordingOptionsPresets.HIGH_QUALITY + ) + recordingRef.current = recording + setState('recording') + } catch (e: any) { + setErrorMsg(e.message ?? 'Impossible de démarrer le micro') + setState('error') + setTimeout(() => setState('idle'), 3000) + } + } + + const stopAndTranscribe = async () => { + const recording = recordingRef.current + if (!recording) { setState('idle'); return } + + setState('processing') + recordingRef.current = null + + try { + // Sauvegarder l'URI AVANT d'unload + const uri = recording.getURI() + await recording.stopAndUnloadAsync() + + // Rétablir le mode audio normal + await Audio.setAudioModeAsync({ allowsRecordingIOS: false }) + + if (!uri) throw new Error('Fichier audio vide') + + const token = await getToken() + + // FormData RN : objet {uri, name, type} + const form = new FormData() + form.append('audio', { uri, name: 'audio.m4a', type: 'audio/m4a' } as any) + + const res = await fetch(ENDPOINTS.aiTranscribe, { + method: 'POST', + headers: { Authorization: `Bearer ${token ?? ''}` }, + body: form, + }) + + if (!res.ok) { + const d = await res.json().catch(() => ({})) + throw new Error(d.error ?? `Erreur ${res.status}`) + } + + const { text } = await res.json() + if (text?.trim()) onTranscript(text.trim()) + setState('idle') + } catch (e: any) { + setErrorMsg(e.message ?? 'Erreur transcription') + setState('error') + setTimeout(() => { setState('idle'); setErrorMsg(null) }, 4000) + } + } + + const cancelRecording = async () => { + const recording = recordingRef.current + recordingRef.current = null + if (recording) { + try { + await recording.stopAndUnloadAsync() + await Audio.setAudioModeAsync({ allowsRecordingIOS: false }) + } catch {} + } + setState('idle') + setErrorMsg(null) + } + + return { state, errorMsg, startRecording, stopAndTranscribe, cancelRecording } +} diff --git a/memento-mobile/package-lock.json b/memento-mobile/package-lock.json index 2079bb7..d5ef830 100644 --- a/memento-mobile/package-lock.json +++ b/memento-mobile/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", "expo": "~54.0.35", + "expo-av": "^16.0.8", "expo-constants": "~18.0.13", "expo-font": "~14.0.12", "expo-linking": "~8.0.12", @@ -17,8 +18,8 @@ "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~14.1.6", "lucide-react-native": "^0.477.0", - "marked": "^18.0.4", "react": "19.1.0", "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", @@ -4769,6 +4770,23 @@ "react-native": "*" } }, + "node_modules/expo-av": { + "version": "16.0.8", + "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz", + "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -4986,6 +5004,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "14.1.6", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.1.6.tgz", + "integrity": "sha512-/4P8eWqRyfXIMZna3acg320LXNA+P2cwyEVbjDX8vHnWU+UnOtyRKWy3XaAIyMPQ9hVjBNUQTh4MPvtnPRzakw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -6226,18 +6254,6 @@ "tmpl": "1.0.5" } }, - "node_modules/marked": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", - "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", diff --git a/memento-mobile/package.json b/memento-mobile/package.json index f3f6a69..260cd18 100644 --- a/memento-mobile/package.json +++ b/memento-mobile/package.json @@ -12,6 +12,7 @@ "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", "expo": "~54.0.35", + "expo-av": "^16.0.8", "expo-constants": "~18.0.13", "expo-font": "~14.0.12", "expo-linking": "~8.0.12", @@ -19,6 +20,7 @@ "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~14.1.6", "lucide-react-native": "^0.477.0", "react": "19.1.0", "react-native": "0.81.5", @@ -26,8 +28,7 @@ "react-native-screens": "~4.16.0", "react-native-svg": "15.12.0", "react-native-webview": "13.15.0", - "zustand": "^5.0.2", - "expo-web-browser": "~14.1.6" + "zustand": "^5.0.2" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/memento-note/app/api/flashcards/save/route.ts b/memento-note/app/api/flashcards/save/route.ts index b9ad0af..2bfebe1 100644 --- a/memento-note/app/api/flashcards/save/route.ts +++ b/memento-note/app/api/flashcards/save/route.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server' import { Prisma } from '@prisma/client' import { auth } from '@/auth' import { prisma } from '@/lib/prisma' -import { getOrCreateDeckForNotebook } from '@/lib/flashcards/deck-utils' import type { FlashcardStyle } from '@/lib/flashcards/generate-flashcards' interface CardInput { @@ -35,7 +34,7 @@ export async function POST(request: NextRequest) { } let notebookId: string | null = null - let fallbackDeckName: string | undefined + let noteName = 'Sans titre' if (noteId) { const note = await prisma.note.findFirst({ where: { id: noteId, userId: session.user.id }, @@ -45,14 +44,13 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Note not found' }, { status: 404 }) } notebookId = note.notebookId - if (!notebookId) { - fallbackDeckName = note.title?.trim() || 'General' - } + noteName = note.title?.trim() || 'Sans titre' } let deckId = deckIdInput if (!deckId) { if (noteId) { + // Chercher un deck déjà créé pour CETTE note spécifique const existingFromNote = await prisma.flashcard.findFirst({ where: { noteId, deck: { userId: session.user.id } }, select: { deckId: true }, @@ -62,10 +60,9 @@ export async function POST(request: NextRequest) { } } if (!deckId) { - const deck = await getOrCreateDeckForNotebook({ - userId: session.user.id, - notebookId, - manualName: fallbackDeckName, + // Créer un nouveau deck nommé d'après la note (pas le carnet) + const deck = await prisma.flashcardDeck.create({ + data: { userId: session.user.id, notebookId, name: noteName }, }) deckId = deck.id } diff --git a/memento-note/app/api/mobile/ai/improve/route.ts b/memento-note/app/api/mobile/ai/improve/route.ts new file mode 100644 index 0000000..5f4c5bb --- /dev/null +++ b/memento-note/app/api/mobile/ai/improve/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getMobileUserId } from '@/lib/mobile-auth' +import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service' +import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' + +const MODE_MAP: Record = { + improve: 'improveStyle', + shorten: 'shorten', + clarify: 'clarify', + fix_grammar: 'fix_grammar', +} + +export async function POST(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { text, mode = 'improve' } = await req.json().catch(() => ({})) + if (!text?.trim()) return NextResponse.json({ error: 'Texte requis' }, { status: 400 }) + + const refactorMode = MODE_MAP[mode] + if (!refactorMode) { + return NextResponse.json({ error: 'Mode invalide. Valeurs: improve, shorten, clarify, fix_grammar' }, { status: 400 }) + } + + const validation = paragraphRefactorService.validateWordCount(text) + if (!validation.valid) return NextResponse.json({ error: validation.error }, { status: 400 }) + + try { + await checkEntitlementOrThrow(userId, 'reformulate') + } catch (err) { + if (err instanceof QuotaExceededError) { + return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 }) + } + throw err + } + + const result = await paragraphRefactorService.refactor(text, refactorMode, 'markdown', undefined) + incrementUsageAsync(userId, 'reformulate') + + return NextResponse.json({ improved: result.refactored, original: result.original }) +} diff --git a/memento-note/app/api/mobile/ai/title/route.ts b/memento-note/app/api/mobile/ai/title/route.ts new file mode 100644 index 0000000..933c7f3 --- /dev/null +++ b/memento-note/app/api/mobile/ai/title/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getMobileUserId } from '@/lib/mobile-auth' +import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user' +import { getSystemConfig } from '@/lib/config' +import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' + +export async function POST(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { content } = await req.json().catch(() => ({})) + if (!content?.trim()) return NextResponse.json({ error: 'Contenu requis' }, { status: 400 }) + + const wordCount = content.split(/\s+/).length + if (wordCount < 5) return NextResponse.json({ error: 'Contenu trop court (min 5 mots)' }, { status: 400 }) + + try { + await checkEntitlementOrThrow(userId, 'auto_title') + } catch (err) { + if (err instanceof QuotaExceededError) { + return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 }) + } + throw err + } + + const config = await getSystemConfig() + const prompt = `Génère 3 titres concis pour ce texte. Réponds UNIQUEMENT avec un tableau JSON: [{"title":"titre1"},{"title":"titre2"},{"title":"titre3"}]\n\nTexte: ${content.slice(0, 400)}` + + const { result: titles, usedByok } = await runLaneWithBillingUser( + 'tags', + config, + userId, + (provider) => provider.generateTitles(prompt), + ) + if (!usedByok) incrementUsageAsync(userId, 'auto_title') + + return NextResponse.json({ suggestions: (titles ?? []).map((t: any) => t.title ?? t) }) +} diff --git a/memento-note/app/api/mobile/ai/transcribe/route.ts b/memento-note/app/api/mobile/ai/transcribe/route.ts new file mode 100644 index 0000000..9fca542 --- /dev/null +++ b/memento-note/app/api/mobile/ai/transcribe/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getMobileUserId } from '@/lib/mobile-auth' +import { getSystemConfig } from '@/lib/config' + +export async function POST(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const formData = await req.formData().catch(() => null) + if (!formData) return NextResponse.json({ error: 'Fichier audio requis' }, { status: 400 }) + + const file = formData.get('audio') + if (!file || !(file instanceof Blob)) { + return NextResponse.json({ error: 'Fichier audio manquant' }, { status: 400 }) + } + + const config = await getSystemConfig() + const apiKey = config.OPENAI_API_KEY + if (!apiKey) return NextResponse.json({ error: 'Service non disponible' }, { status: 503 }) + + const whisperForm = new FormData() + whisperForm.append('file', file, 'audio.m4a') + whisperForm.append('model', 'whisper-1') + whisperForm.append('response_format', 'json') + + const whisperRes = await fetch('https://api.openai.com/v1/audio/transcriptions', { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + body: whisperForm, + }) + + if (!whisperRes.ok) { + const err = await whisperRes.text() + console.error('[mobile/ai/transcribe] Whisper error:', err) + return NextResponse.json({ error: 'Erreur transcription' }, { status: 500 }) + } + + const { text } = await whisperRes.json() + return NextResponse.json({ text: text ?? '' }) +} diff --git a/memento-note/app/api/mobile/flashcards/decks/route.ts b/memento-note/app/api/mobile/flashcards/decks/route.ts new file mode 100644 index 0000000..e65b932 --- /dev/null +++ b/memento-note/app/api/mobile/flashcards/decks/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import { getMobileUserId } from '@/lib/mobile-auth' + +export async function GET(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const now = new Date() + const decks = await prisma.flashcardDeck.findMany({ + where: { userId }, + include: { + notebook: { select: { name: true } }, + flashcards: { + select: { + id: true, + interval: true, + nextReviewAt: true, + front: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }) + + const result = decks.map((deck) => ({ + id: deck.id, + name: deck.name, + notebookId: deck.notebookId, + notebookName: deck.notebook?.name ?? null, + totalCards: deck.flashcards.length, + dueCount: deck.flashcards.filter((c) => c.nextReviewAt <= now).length, + masteredCount: deck.flashcards.filter((c) => c.interval >= 7).length, + })) + + return NextResponse.json({ decks: result }) +} diff --git a/memento-note/app/api/mobile/flashcards/generate/route.ts b/memento-note/app/api/mobile/flashcards/generate/route.ts new file mode 100644 index 0000000..374b567 --- /dev/null +++ b/memento-note/app/api/mobile/flashcards/generate/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import { getMobileUserId } from '@/lib/mobile-auth' +import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards' +import { stripHtmlToText } from '@/lib/flashcards/deck-utils' +import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' + +export async function POST(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const body = await req.json().catch(() => ({})) + const noteId = typeof body.noteId === 'string' ? body.noteId : null + const count = typeof body.count === 'number' ? Math.min(body.count, 20) : 10 + const styleRaw = typeof body.style === 'string' ? body.style : 'qa' + const style: FlashcardStyle = ['qa', 'cloze', 'concept'].includes(styleRaw) ? (styleRaw as FlashcardStyle) : 'qa' + + if (!noteId) return NextResponse.json({ error: 'noteId requis' }, { status: 400 }) + + const note = await prisma.note.findFirst({ + where: { id: noteId, userId, trashedAt: null }, + select: { id: true, title: true, content: true, notebookId: true, language: true }, + }) + if (!note) return NextResponse.json({ error: 'Note introuvable' }, { status: 404 }) + + const textContent = stripHtmlToText(note.content) + if (textContent.length < 80) { + return NextResponse.json({ error: 'Contenu insuffisant pour générer des flashcards (minimum 80 caractères)' }, { status: 400 }) + } + + try { + await checkEntitlementOrThrow(userId, 'ai_flashcard') + } catch (err) { + if (err instanceof QuotaExceededError) { + return NextResponse.json({ error: err.currentQuota === 0 ? 'Fonctionnalité non disponible sur votre abonnement' : 'Quota IA atteint' }, { status: 402 }) + } + throw err + } + + const cards = await generateFlashcardsFromNote({ + title: note.title || 'Sans titre', + textContent, + count, + style, + language: note.language || undefined, + }) + + if (cards.length === 0) { + return NextResponse.json({ error: 'Génération échouée — aucune carte produite' }, { status: 500 }) + } + + // Chercher un deck existant pour cette note, ou en créer un + const existing = await prisma.flashcard.findFirst({ + where: { noteId: note.id, deck: { userId } }, + select: { deckId: true }, + }) + + let deckId: string + if (existing) { + deckId = existing.deckId + // Supprimer les anciennes cartes pour les remplacer + await prisma.flashcard.deleteMany({ where: { noteId: note.id, deckId } }) + } else { + const deck = await prisma.flashcardDeck.create({ + data: { userId, notebookId: note.notebookId, name: note.title || 'Sans titre' }, + }) + deckId = deck.id + } + + await prisma.flashcard.createMany({ + data: cards.map((c) => ({ + deckId, + noteId: note.id, + front: c.front, + back: c.back, + type: c.type, + })), + }) + + await prisma.flashcardDeck.update({ where: { id: deckId }, data: { updatedAt: new Date() } }) + incrementUsageAsync(userId, 'ai_flashcard') + + return NextResponse.json({ deckId, count: cards.length, cards }) +} diff --git a/memento-note/app/api/mobile/flashcards/review/route.ts b/memento-note/app/api/mobile/flashcards/review/route.ts new file mode 100644 index 0000000..09395df --- /dev/null +++ b/memento-note/app/api/mobile/flashcards/review/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import { getMobileUserId } from '@/lib/mobile-auth' +import { computeSm2Update } from '@/lib/flashcards/sm2' + +export async function POST(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await req.json() + const cardId = typeof body.cardId === 'string' ? body.cardId : null + const grade = typeof body.grade === 'number' ? body.grade : null + + if (!cardId || grade === null || grade < 1 || grade > 4) { + return NextResponse.json({ error: 'cardId and grade (1–4) required' }, { status: 400 }) + } + + const card = await prisma.flashcard.findFirst({ + where: { id: cardId, deck: { userId } }, + select: { id: true, interval: true, easinessFactor: true }, + }) + if (!card) return NextResponse.json({ error: 'Card not found' }, { status: 404 }) + + const { easinessFactor, interval, nextReviewAt } = computeSm2Update(grade, { + easinessFactor: card.easinessFactor, + interval: card.interval, + }) + + await prisma.$transaction([ + prisma.flashcard.update({ + where: { id: cardId }, + data: { easinessFactor, interval, nextReviewAt }, + }), + prisma.flashcardReview.create({ + data: { cardId, grade, reviewedAt: new Date() }, + }), + ]) + + return NextResponse.json({ ok: true, nextReviewAt, interval }) +} diff --git a/memento-note/app/api/mobile/flashcards/session/route.ts b/memento-note/app/api/mobile/flashcards/session/route.ts new file mode 100644 index 0000000..41958f8 --- /dev/null +++ b/memento-note/app/api/mobile/flashcards/session/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import { getMobileUserId } from '@/lib/mobile-auth' + +export async function GET(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const deckId = searchParams.get('deckId') + const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 50) + + if (!deckId) return NextResponse.json({ error: 'deckId required' }, { status: 400 }) + + // Vérifier ownership + const deck = await prisma.flashcardDeck.findFirst({ + where: { id: deckId, userId }, + select: { id: true, name: true }, + }) + if (!deck) return NextResponse.json({ error: 'Deck not found' }, { status: 404 }) + + const now = new Date() + const cards = await prisma.flashcard.findMany({ + where: { deckId, nextReviewAt: { lte: now } }, + select: { + id: true, + front: true, + back: true, + interval: true, + easinessFactor: true, + type: true, + }, + orderBy: { nextReviewAt: 'asc' }, + take: limit, + }) + + return NextResponse.json({ deck: { id: deck.id, name: deck.name }, cards }) +} diff --git a/memento-note/app/api/mobile/notes/[id]/route.ts b/memento-note/app/api/mobile/notes/[id]/route.ts index a0febe2..c301170 100644 --- a/memento-note/app/api/mobile/notes/[id]/route.ts +++ b/memento-note/app/api/mobile/notes/[id]/route.ts @@ -28,3 +28,60 @@ export async function GET( return NextResponse.json({ note }) } + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { id } = await params + const { title, content } = await req.json().catch(() => ({})) + + const existing = await prisma.note.findFirst({ where: { id, userId, trashedAt: null } }) + if (!existing) return NextResponse.json({ error: 'Note introuvable' }, { status: 404 }) + + const htmlContent = content !== undefined ? buildHtmlContent(content) : existing.content + + const note = await prisma.note.update({ + where: { id }, + data: { + ...(title?.trim() ? { title: title.trim() } : {}), + content: htmlContent, + updatedAt: new Date(), + }, + select: { id: true, title: true, updatedAt: true }, + }) + + return NextResponse.json({ note }) +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { id } = await params + const note = await prisma.note.findFirst({ where: { id, userId, trashedAt: null } }) + if (!note) return NextResponse.json({ error: 'Note introuvable' }, { status: 404 }) + + await prisma.note.update({ where: { id }, data: { trashedAt: new Date() } }) + return NextResponse.json({ ok: true }) +} + +function buildHtmlContent(text: string): string { + if (!text.trim()) return '

    ' + if (text.trimStart().startsWith('<')) return text + return text + .split('\n') + .map((line) => `

    ${line.trim() ? escapeHtml(line) : ''}

    `) + .join('') +} + +function escapeHtml(s: string) { + return s.replace(/&/g, '&').replace(//g, '>') +} + diff --git a/memento-note/app/api/mobile/notes/daily/route.ts b/memento-note/app/api/mobile/notes/daily/route.ts new file mode 100644 index 0000000..637fd8d --- /dev/null +++ b/memento-note/app/api/mobile/notes/daily/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import { getMobileUserId } from '@/lib/mobile-auth' + +function getTodayKey(): string { + return new Date().toISOString().slice(0, 10) // YYYY-MM-DD — clé de recherche interne +} + +function getTodayTitle(): string { + return new Date().toLocaleDateString('fr-FR', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + }) + // ex : "vendredi 29 mai 2026" +} + +function getTodayContent(title: string): string { + // HTML simple lisible par la WebView mobile + return `

    📅 ${title}

    ` +} + +export async function GET(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const todayKey = getTodayKey() + const todayTitle = getTodayTitle() + + // Chercher par clé ISO (titre interne) ou par titre lisible (migration) + let note = await prisma.note.findFirst({ + where: { + userId, + type: 'daily', + trashedAt: null, + title: { in: [todayKey, todayTitle] }, + }, + }) + + if (!note) { + note = await prisma.note.create({ + data: { + userId, + title: todayTitle, + content: getTodayContent(todayTitle), + type: 'daily', + color: '#FEF9C3', + labels: JSON.stringify(['daily']), + }, + }) + } else if (note.title === todayKey) { + // Migrer l'ancien titre ISO → titre lisible + const htmlContent = getTodayContent(todayTitle) + note = await prisma.note.update({ + where: { id: note.id }, + data: { title: todayTitle, content: htmlContent }, + }) + } + + return NextResponse.json({ id: note.id, note }) +} diff --git a/memento-note/app/api/mobile/notes/route.ts b/memento-note/app/api/mobile/notes/route.ts index ea8d739..68c8730 100644 --- a/memento-note/app/api/mobile/notes/route.ts +++ b/memento-note/app/api/mobile/notes/route.ts @@ -41,3 +41,43 @@ export async function GET(req: NextRequest) { ...(notebookName ? { notebookName } : {}), }) } + +export async function POST(req: NextRequest) { + const userId = getMobileUserId(req) + if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { title, content, notebookId } = await req.json().catch(() => ({})) + if (!title?.trim()) return NextResponse.json({ error: 'Titre requis' }, { status: 400 }) + + // Convertir le texte brut en HTML TipTap simple si nécessaire + const htmlContent = buildHtmlContent(content ?? '') + + const note = await prisma.note.create({ + data: { + userId, + title: title.trim(), + content: htmlContent, + type: 'richtext', + ...(notebookId ? { notebookId } : {}), + }, + select: { id: true, title: true, updatedAt: true }, + }) + + return NextResponse.json({ note }, { status: 201 }) +} + +/** Convertit du texte brut multiligne en paragraphes HTML TipTap */ +function buildHtmlContent(text: string): string { + if (!text.trim()) return '

    ' + // Si déjà du HTML, retourner tel quel + if (text.trimStart().startsWith('<')) return text + return text + .split('\n') + .map((line) => `

    ${line.trim() ? escapeHtml(line) : ''}

    `) + .join('') +} + +function escapeHtml(s: string) { + return s.replace(/&/g, '&').replace(//g, '>') +} + diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 6efb01b..05477da 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -1140,16 +1140,6 @@ html.font-system * { color: #60a5fa; } -.block-action-item:first-child:hover { - background: rgba(239, 68, 68, 0.1); - color: #ef4444; -} - -.dark .block-action-item:first-child:hover { - background: rgba(239, 68, 68, 0.2); - color: #f87171; -} - .smart-paste-menu { min-width: 240px; padding: 8px 4px 4px; @@ -1188,6 +1178,21 @@ html.font-system * { position: relative; } +/* Danger item (Supprimer) */ +.block-action-item--danger:hover { + background: rgba(239, 68, 68, 0.1) !important; + color: #ef4444 !important; +} +.dark .block-action-item--danger:hover { + background: rgba(239, 68, 68, 0.2) !important; + color: #f87171 !important; +} + +/* Wrap du sous-menu pour maintenir le hover */ +.block-action-submenu-wrap { + position: relative; +} + .block-action-submenu { position: absolute; left: 100%; diff --git a/memento-note/components/block-action-menu.tsx b/memento-note/components/block-action-menu.tsx index 2d96bc2..d930f4e 100644 --- a/memento-note/components/block-action-menu.tsx +++ b/memento-note/components/block-action-menu.tsx @@ -13,6 +13,7 @@ import { Trash2, Copy, Repeat, Link, ChevronRight, Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Quote, CodeXml, Database, + ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, } from 'lucide-react' import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension' @@ -28,6 +29,7 @@ interface BlockActionMenuProps { } type TurnIntoType = + | 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'bulletList' | 'orderedList' | 'taskList' | 'blockquote' | 'codeBlock' | 'database' @@ -39,24 +41,39 @@ interface TurnIntoOption { isDatabase?: boolean } -const TURN_INTO_OPTIONS: TurnIntoOption[] = [ - { id: 'heading1', icon: Heading1, command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() }, - { id: 'heading2', icon: Heading2, command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() }, - { id: 'heading3', icon: Heading3, command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() }, - { id: 'bulletList', icon: List, command: (e) => e.chain().focus().toggleBulletList().run() }, - { id: 'orderedList', icon: ListOrdered, command: (e) => e.chain().focus().toggleOrderedList().run() }, - { id: 'taskList', icon: CheckSquare, command: (e) => e.chain().focus().toggleTaskList().run() }, - { id: 'blockquote', icon: Quote, command: (e) => e.chain().focus().toggleBlockquote().run() }, - { id: 'codeBlock', icon: CodeXml, command: (e) => e.chain().focus().toggleCodeBlock().run() }, - { id: 'database', icon: Database, isDatabase: true }, -] - +/** Positionne le curseur dans le bloc avant d'appliquer une transformation */ function focusBlock(editor: Editor, blockPos: number) { const docSize = editor.state.doc.content.size const cursorPos = Math.min(blockPos + 1, docSize) editor.chain().focus().setTextSelection(cursorPos).run() } +/** + * Applique une transformation en passant d'abord par paragraphe. + * Ceci corrige le cas heading → liste / blockquote / codeBlock qui échoue + * avec un toggle direct. + */ +function makeTurnCommand(cmd: (e: Editor) => void): (e: Editor) => void { + return (editor: Editor) => { + // clearNodes remet en paragraphe sans perte de texte + editor.chain().focus().clearNodes().run() + cmd(editor) + } +} + +const TURN_INTO_OPTIONS: TurnIntoOption[] = [ + { id: 'paragraph', icon: AlignLeft, command: (e) => e.chain().focus().clearNodes().run() }, + { id: 'heading1', icon: Heading1, command: (e) => e.chain().focus().clearNodes().toggleHeading({ level: 1 }).run() }, + { id: 'heading2', icon: Heading2, command: (e) => e.chain().focus().clearNodes().toggleHeading({ level: 2 }).run() }, + { id: 'heading3', icon: Heading3, command: (e) => e.chain().focus().clearNodes().toggleHeading({ level: 3 }).run() }, + { id: 'bulletList', icon: List, command: makeTurnCommand((e) => e.chain().focus().toggleBulletList().run()) }, + { id: 'orderedList', icon: ListOrdered, command: makeTurnCommand((e) => e.chain().focus().toggleOrderedList().run()) }, + { id: 'taskList', icon: CheckSquare, command: makeTurnCommand((e) => e.chain().focus().toggleTaskList().run()) }, + { id: 'blockquote', icon: Quote, command: makeTurnCommand((e) => e.chain().focus().toggleBlockquote().run()) }, + { id: 'codeBlock', icon: CodeXml, command: makeTurnCommand((e) => e.chain().focus().toggleCodeBlock().run()) }, + { id: 'database', icon: Database, isDatabase: true }, +] + function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNode | null): string { const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null) if (!node || blockPos < 0) return '' @@ -68,6 +85,47 @@ function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNod return node.textContent?.trim() ?? '' } +/** Déplace un bloc vers le haut (échange avec le frère précédent) */ +function moveBlockUp(editor: Editor, blockPos: number, blockNode: PMNode): boolean { + const { doc } = editor.state + let prevPos = -1 + let prevNode: PMNode | null = null + doc.forEach((node, offset) => { + if (offset + node.nodeSize === blockPos) { + prevPos = offset + prevNode = node + } + }) + if (prevPos < 0 || !prevNode) return false + const safePrev = prevNode as PMNode + const tr = editor.state.tr + const from = prevPos + const to = blockPos + blockNode.nodeSize + tr.replaceWith(from, to, [blockNode.copy(blockNode.content), safePrev.copy(safePrev.content)]) + editor.view.dispatch(tr) + // Repositionne le curseur dans le bloc déplacé (maintenant à prevPos) + const docSize = editor.state.doc.content.size + editor.chain().focus().setTextSelection(Math.min(prevPos + 1, docSize)).run() + return true +} + +/** Déplace un bloc vers le bas (échange avec le frère suivant) */ +function moveBlockDown(editor: Editor, blockPos: number, blockNode: PMNode): boolean { + const { doc } = editor.state + const nextPos = blockPos + blockNode.nodeSize + const nextNode = doc.nodeAt(nextPos) + if (!nextNode) return false + const tr = editor.state.tr + const from = blockPos + const to = nextPos + nextNode.nodeSize + tr.replaceWith(from, to, [nextNode.copy(nextNode.content), blockNode.copy(blockNode.content)]) + editor.view.dispatch(tr) + const newPos = blockPos + nextNode.nodeSize + const docSize = editor.state.doc.content.size + editor.chain().focus().setTextSelection(Math.min(newPos + 1, docSize)).run() + return true +} + export function BlockActionMenu({ editor, onClose, @@ -81,6 +139,7 @@ export function BlockActionMenu({ const { t } = useLanguage() const menuRef = useRef(null) const [showTurnInto, setShowTurnInto] = useState(false) + const hoverTimerRef = useRef | null>(null) const handleDelete = useCallback(() => { if (blockNode && blockPos >= 0) { @@ -92,32 +151,52 @@ export function BlockActionMenu({ const handleDuplicate = useCallback(() => { if (blockNode && blockPos >= 0) { const insertPos = blockPos + blockNode.nodeSize - editor.view.dispatch( - editor.state.tr.insert(insertPos, blockNode.copy()) - ) + editor.view.dispatch(editor.state.tr.insert(insertPos, blockNode.copy())) editor.commands.focus() } onClose() }, [editor, blockNode, blockPos, onClose]) + const handleMoveUp = useCallback(() => { + if (blockNode && blockPos >= 0) { + const ok = moveBlockUp(editor, blockPos, blockNode) + if (!ok) toast(t('blockAction.moveUpFirst')) + } + onClose() + }, [editor, blockNode, blockPos, onClose, t]) + + const handleMoveDown = useCallback(() => { + if (blockNode && blockPos >= 0) { + const ok = moveBlockDown(editor, blockPos, blockNode) + if (!ok) toast(t('blockAction.moveDownLast')) + } + onClose() + }, [editor, blockNode, blockPos, onClose, t]) + + const handleCopyContent = useCallback(async () => { + const text = getBlockPlainContent(editor, blockPos, blockNode) + if (!text) { toast(t('blockAction.emptyBlock')); onClose(); return } + const ok = await copyTextToClipboard(text) + if (ok) toast.success(t('blockAction.contentCopied')) + else toast.error(t('blockAction.copyRefFailed')) + onClose() + }, [editor, blockPos, blockNode, onClose, t]) + const handleCopyRef = useCallback(async () => { if (!noteId?.trim()) { toast.error(t('blockAction.copyRefNoNote')) onClose() return } - const blockId = ensureBlockReferenceId(editor, blockPos, blockNode) if (!blockId) { toast.error(t('blockAction.copyRefUnsupported')) onClose() return } - const html = editor.getHTML() const blockContent = getBlockPlainContent(editor, blockPos, blockNode) onBlockReferenceCopied?.(html) - const ref = `${window.location.origin}/home?openNote=${encodeURIComponent(noteId)}#block-${encodeURIComponent(blockId)}` const copied = await copyTextToClipboard(ref) if (copied) { @@ -143,6 +222,19 @@ export function BlockActionMenu({ onClose() }, [editor, blockNode, blockPos, onClose]) + const handleTurnIntoEnter = useCallback(() => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) + setShowTurnInto(true) + }, []) + + const handleTurnIntoLeave = useCallback(() => { + hoverTimerRef.current = setTimeout(() => setShowTurnInto(false), 200) + }, []) + + const handleSubmenuEnter = useCallback(() => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) + }, []) + useEffect(() => { function handleClickOutside(e: MouseEvent) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { @@ -157,68 +249,89 @@ export function BlockActionMenu({ return () => { document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('keydown', handleKeyDown) + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) } }, [onClose]) + const menuLeft = anchorRect.right + 6 + const menuTop = anchorRect.top - 4 + const menuStyle: React.CSSProperties = { position: 'fixed', - left: anchorRect.right + 6, - top: anchorRect.top - 4, + left: menuLeft > window.innerWidth - 230 ? anchorRect.left - 215 : menuLeft, + top: menuTop + 320 > window.innerHeight ? window.innerHeight - 330 : menuTop, zIndex: 9999, } - if (Number(menuStyle.left) > window.innerWidth - 220) { - menuStyle.left = anchorRect.left - 210 - } - if (Number(menuStyle.top) + 300 > window.innerHeight) { - menuStyle.top = window.innerHeight - 310 - } - return createPortal(
    - + + +
    + + {/* Transformer en */} +
    + + + {showTurnInto && ( +
    + {TURN_INTO_OPTIONS.map((opt) => ( + + ))} +
    + )} +
    + +
    + + {/* Copier */} + + + +
    + + {/* Actions destructives */} - -
    - - - - {showTurnInto && ( -
    - {TURN_INTO_OPTIONS.map((opt) => ( - - ))} -
    - )} - -
    - -
    , document.body diff --git a/memento-note/components/flashcards/revision-view.tsx b/memento-note/components/flashcards/revision-view.tsx index 325c47c..58839ce 100644 --- a/memento-note/components/flashcards/revision-view.tsx +++ b/memento-note/components/flashcards/revision-view.tsx @@ -9,7 +9,6 @@ import { ChevronLeft, ChevronRight, Calendar, - Plus, BarChart3, Loader2, BookOpen, @@ -78,8 +77,7 @@ function shuffleCards(items: T[]): T[] { } function buildSessionQueue(cards: FlashcardItem[], dueOnly: boolean): FlashcardItem[] { - let toReview = dueOnly ? cards.filter((c) => c.due) : cards - if (toReview.length === 0) toReview = cards + const toReview = dueOnly ? cards.filter((c) => c.due) : cards return shuffleCards(toReview) } @@ -316,8 +314,6 @@ export function RevisionView() { const [sessionDurationSeconds, setSessionDurationSeconds] = useState(0) const sessionTimerRef = useRef | null>(null) - const [newDeckName, setNewDeckName] = useState('') - const [creatingDeck, setCreatingDeck] = useState(false) // Gap 8 — deck deletion state const [deletingDeckId, setDeletingDeckId] = useState(null) @@ -402,6 +398,17 @@ export function RevisionView() { return } const shuffled = buildSessionQueue(cardsInput, mode === 'due') + // Mode "due" mais aucune carte n'est due → afficher état "à jour" directement + if (shuffled.length === 0) { + setSessionCards([]) + setCurrentIndex(0) + setIsFlipped(false) + setSessionGrades({}) + setIsSessionActive(true) + setIsSessionFinished(true) + setActiveDeckId(deckId) + return + } setSessionCards(shuffled) setCurrentIndex(0) setIsFlipped(false) @@ -561,27 +568,6 @@ export function RevisionView() { } } - const createDeck = async () => { - const name = newDeckName.trim() - if (!name) return - setCreatingDeck(true) - try { - const res = await fetch('/api/flashcards/decks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }) - const data = await res.json() - if (res.ok) { - setNewDeckName('') - await loadDecks() - toast.success(t('flashcards.deckCreated')) - if (data.deck?.id) setActiveDeckId(data.deck.id) - } - } finally { - setCreatingDeck(false) - } - } // Gap 8 — delete a deck const handleDeleteDeck = async (deckId: string) => { @@ -910,24 +896,6 @@ export function RevisionView() {
    )} -
    - setNewDeckName(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') void createDeck() }} - placeholder={t('flashcards.newDeckPlaceholder')} - className="flex-1 min-w-[180px] px-3 py-2 rounded-lg border border-border text-sm bg-transparent" - /> - -
    {loadingDecks ? (
    diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index 129d094..e3813d4 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -173,7 +173,6 @@ export function HomeClient({ const detail = (e as CustomEvent<{ layout?: NotesLayoutMode }>).detail?.layout if (detail === 'grid' || detail === 'list' || detail === 'table' || detail === 'kanban') { setLayoutMode(detail) - setViewType('notes') } } window.addEventListener('memento-notes-layout-change', onLayoutChange) @@ -411,7 +410,6 @@ export function HomeClient({ const selectLayoutMode = useCallback((mode: NotesLayoutMode) => { if (mode === 'gallery') return setLayoutMode(mode) - setViewType('notes') }, []) const showStructuredIntro = diff --git a/memento-note/locales/ar.json b/memento-note/locales/ar.json index 9d794de..c922ff1 100644 --- a/memento-note/locales/ar.json +++ b/memento-note/locales/ar.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/de.json b/memento-note/locales/de.json index a5a7ce8..edbfd34 100644 --- a/memento-note/locales/de.json +++ b/memento-note/locales/de.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index f87ddae..657489f 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -3378,7 +3378,15 @@ "blockAction": { "delete": "Delete", "duplicate": "Duplicate", + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", "turnInto": "Turn into", + "turnInto_paragraph": "Text", "turnInto_heading1": "Heading 1", "turnInto_heading2": "Heading 2", "turnInto_heading3": "Heading 3", diff --git a/memento-note/locales/es.json b/memento-note/locales/es.json index 2d4d21c..b9a3e12 100644 --- a/memento-note/locales/es.json +++ b/memento-note/locales/es.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/fa.json b/memento-note/locales/fa.json index 2883a1a..f47857a 100644 --- a/memento-note/locales/fa.json +++ b/memento-note/locales/fa.json @@ -2906,5 +2906,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 1453b20..8c2bc1f 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -3382,7 +3382,15 @@ "blockAction": { "delete": "Supprimer", "duplicate": "Dupliquer", + "moveUp": "Monter le bloc", + "moveDown": "Descendre le bloc", + "moveUpFirst": "C'est déjà le premier bloc", + "moveDownLast": "C'est déjà le dernier bloc", + "copyContent": "Copier le contenu", + "contentCopied": "Contenu copié !", + "emptyBlock": "Ce bloc est vide", "turnInto": "Transformer en", + "turnInto_paragraph": "Texte", "turnInto_heading1": "Titre 1", "turnInto_heading2": "Titre 2", "turnInto_heading3": "Titre 3", diff --git a/memento-note/locales/hi.json b/memento-note/locales/hi.json index b2cd98e..73f857d 100644 --- a/memento-note/locales/hi.json +++ b/memento-note/locales/hi.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/it.json b/memento-note/locales/it.json index 1abe907..5109b16 100644 --- a/memento-note/locales/it.json +++ b/memento-note/locales/it.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/ja.json b/memento-note/locales/ja.json index a94143c..c2c6100 100644 --- a/memento-note/locales/ja.json +++ b/memento-note/locales/ja.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/ko.json b/memento-note/locales/ko.json index 35cdc11..12b0090 100644 --- a/memento-note/locales/ko.json +++ b/memento-note/locales/ko.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/nl.json b/memento-note/locales/nl.json index 999a132..e5d45d5 100644 --- a/memento-note/locales/nl.json +++ b/memento-note/locales/nl.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/pl.json b/memento-note/locales/pl.json index 0f0d18b..b0640c9 100644 --- a/memento-note/locales/pl.json +++ b/memento-note/locales/pl.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/pt.json b/memento-note/locales/pt.json index 7486f3f..0e8f2f9 100644 --- a/memento-note/locales/pt.json +++ b/memento-note/locales/pt.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/ru.json b/memento-note/locales/ru.json index b2524c1..5f90fb1 100644 --- a/memento-note/locales/ru.json +++ b/memento-note/locales/ru.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/locales/zh.json b/memento-note/locales/zh.json index e635af4..a2f8d8c 100644 --- a/memento-note/locales/zh.json +++ b/memento-note/locales/zh.json @@ -2867,5 +2867,32 @@ "hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.", "hint_insights_refresh_title": "Refresh clusters", "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." + }, + "blockAction": { + "moveUp": "Move block up", + "moveDown": "Move block down", + "moveUpFirst": "This is already the first block", + "moveDownLast": "This is already the last block", + "copyContent": "Copy content", + "contentCopied": "Content copied!", + "emptyBlock": "This block is empty", + "turnInto_paragraph": "Text", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "turnInto_heading1": "Heading 1", + "turnInto_heading2": "Heading 2", + "turnInto_heading3": "Heading 3", + "turnInto_bulletList": "Bullet List", + "turnInto_orderedList": "Numbered List", + "turnInto_taskList": "Task List", + "turnInto_blockquote": "Quote", + "turnInto_codeBlock": "Code Block", + "turnInto_database": "Inline database", + "copyRef": "Copy block reference", + "copied": "Reference copied!", + "copyRefFailed": "Could not copy block reference", + "copyRefNoNote": "Save the note before copying a block reference", + "copyRefUnsupported": "This block type cannot be referenced yet" } } \ No newline at end of file diff --git a/memento-note/prisma/migrations/20260529180000_flashcard_deck_per_note/migration.sql b/memento-note/prisma/migrations/20260529180000_flashcard_deck_per_note/migration.sql new file mode 100644 index 0000000..4a5f0e2 --- /dev/null +++ b/memento-note/prisma/migrations/20260529180000_flashcard_deck_per_note/migration.sql @@ -0,0 +1,2 @@ +-- Drop unique constraint on notebookId to allow multiple decks per notebook (one per note) +DROP INDEX IF EXISTS "FlashcardDeck_notebookId_key"; diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma index 31366ff..f1de3e9 100644 --- a/memento-note/prisma/schema.prisma +++ b/memento-note/prisma/schema.prisma @@ -114,7 +114,7 @@ model Notebook { children Notebook[] @relation("NotebookTree") user User @relation(fields: [userId], references: [id], onDelete: Cascade) workflows Workflow[] - flashcardDeck FlashcardDeck? + flashcardDecks FlashcardDeck[] schema NotebookSchema? @@index([userId, order]) @@ -905,7 +905,7 @@ model NoteProperty { model FlashcardDeck { id String @id @default(cuid()) userId String - notebookId String? @unique + notebookId String? name String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt