feat: mobile app complet + flashcards fixes + drag handle améliorations
Mobile app: - Révision flashcards : liste decks, session flip-card SM-2, couleurs harmonisées web - Génération flashcards depuis note (FlashcardSheet + route /api/mobile/flashcards/generate) - Audio Whisper : hook useAudioRecorder reécrit, MicButton avec erreurs - IA : AISheet (améliorer/clarifier/résumer), TitleSheet (titre automatique) - Suppression note (soft delete + confirmation Alert) - Note du jour : titre lisible + HTML (plus JSON TipTap brut) - Parser TipTap→HTML côté mobile (tipTapToHtml) - Icône 🎓 dans header note → génération flashcards - Endpoint flashcardGenerate dans config.ts Web fixes: - Bug flashcards groupées par carnet → deck par note (migration + schema) - Bug filtre 'cartes dues' ignoré (suppression fallback buildSessionQueue) - Suppression UI création deck manuelle (inutile) - Fix setViewType is not defined dans home-client.tsx Drag handle menu: - Fix : clearNodes() avant transformation (heading→liste/code/citation) - Ajout : option 'Texte' (paragraphe) dans Transformer en - Ajout : Monter / Descendre le bloc - Ajout : Copier le contenu du bloc - Fix : sous-menu hover stable (délai 200ms) - Fix : Supprimer en rouge via classe --danger (plus :first-child) - i18n : nouvelles clés dans 15 locales Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
6
memento-mobile/.expo/types/router.d.ts
vendored
6
memento-mobile/.expo/types/router.d.ts
vendored
@@ -6,9 +6,9 @@ export * from 'expo-router';
|
||||
declare module 'expo-router' {
|
||||
export namespace ExpoRouter {
|
||||
export interface __routes<T extends string | object = string> {
|
||||
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<T>}${`?${string}` | `#${string}` | ''}` | `/notebook/${Router.SingleRoutePart<T>}${`?${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<T>}${`?${string}` | `#${string}` | ''}` | `/notebook/${Router.SingleRoutePart<T>}${`?${string}` | `#${string}` | ''}` | { pathname: `/note/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `/notebook/[id]`, params: Router.UnknownInputParams & { id: string | number; } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> | 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<string, string> | 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)
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<Tabs.Screen name="home" options={{ title: 'Accueil', tabBarIcon: ({ color, size }) => <Home size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="notebooks" options={{ title: 'Carnets', tabBarIcon: ({ color, size }) => <BookOpen size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="revision" options={{ title: 'Révision', tabBarIcon: ({ color, size }) => <GraduationCap size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="search" options={{ title: 'Recherche', tabBarIcon: ({ color, size }) => <Search size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="profile" options={{ title: 'Profil', tabBarIcon: ({ color, size }) => <User size={size} color={color} /> }} />
|
||||
</Tabs>
|
||||
|
||||
@@ -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() {
|
||||
</View>
|
||||
<Text style={s.quickLabel}>Note du jour</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/(tabs)/search' })} style={s.quickCard} activeOpacity={0.7}>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/note/create' })} style={s.quickCard} activeOpacity={0.7}>
|
||||
<View style={[s.quickIcon, { backgroundColor: '#edf0f7' }]}>
|
||||
<PenLine size={20} color="#5b7ec7" />
|
||||
</View>
|
||||
<Text style={s.quickLabel}>Nouvelle note</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/(tabs)/search' })} style={s.quickCard} activeOpacity={0.7}>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/(tabs)/revision' })} style={s.quickCard} activeOpacity={0.7}>
|
||||
<View style={[s.quickIcon, { backgroundColor: '#eef7ed' }]}>
|
||||
<GraduationCap size={20} color="#4a9b61" />
|
||||
</View>
|
||||
|
||||
@@ -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() {
|
||||
<BookOpen size={18} color={C.brand} style={{ marginRight: 8 }} />
|
||||
<Text style={s.title}>Carnets</Text>
|
||||
<Text style={s.count}>{notebooks.length}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push({ pathname: '/note/create' })}
|
||||
style={s.newNoteBtn}
|
||||
>
|
||||
<Plus size={18} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
data={notebooks}
|
||||
@@ -98,7 +104,8 @@ const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
title: { fontSize: 20, fontWeight: '700', color: C.ink, flex: 1 },
|
||||
count: { fontSize: 12, color: C.concrete, backgroundColor: C.border, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, overflow: 'hidden' },
|
||||
count: { fontSize: 12, color: C.concrete, backgroundColor: C.border, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, overflow: 'hidden', marginRight: 8 },
|
||||
newNoteBtn: { width: 34, height: 34, borderRadius: 17, backgroundColor: '#f3ece4', alignItems: 'center', justifyContent: 'center' },
|
||||
list: { padding: 12 },
|
||||
card: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 14, padding: 14, marginBottom: 8 },
|
||||
iconWrap: { width: 38, height: 38, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
|
||||
|
||||
174
memento-mobile/app/(tabs)/revision.tsx
Normal file
174
memento-mobile/app/(tabs)/revision.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, TouchableOpacity,
|
||||
ActivityIndicator, RefreshControl,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { GraduationCap, BookOpen, ChevronRight, Clock } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
|
||||
interface Deck {
|
||||
id: string
|
||||
name: string
|
||||
notebookName: string | null
|
||||
totalCards: number
|
||||
dueCount: number
|
||||
masteredCount: number
|
||||
}
|
||||
|
||||
export default function RevisionScreen() {
|
||||
const router = useRouter()
|
||||
const [decks, setDecks] = useState<Deck[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<SafeAreaView style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
</SafeAreaView>
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
<View style={s.headerLeft}>
|
||||
<GraduationCap size={22} color={C.brand} />
|
||||
<Text style={s.title}>Révision</Text>
|
||||
</View>
|
||||
{totalDue > 0 && (
|
||||
<View style={s.badge}>
|
||||
<Text style={s.badgeTxt}>{totalDue} à revoir</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View style={s.center}>
|
||||
<Text style={s.errorTxt}>{error}</Text>
|
||||
<TouchableOpacity onPress={() => load()} style={s.retryBtn}>
|
||||
<Text style={s.retryTxt}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : decks.length === 0 ? (
|
||||
<View style={s.center}>
|
||||
<GraduationCap size={48} color={C.concrete} />
|
||||
<Text style={s.emptyTitle}>Aucun paquet</Text>
|
||||
<Text style={s.emptyHint}>
|
||||
Générez des flashcards depuis une note (bouton ✦ dans l'éditeur) pour commencer à réviser.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={decks}
|
||||
keyExtractor={(d) => d.id}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true) }} tintColor={C.brand} />}
|
||||
contentContainerStyle={s.list}
|
||||
renderItem={({ item }) => <DeckCard deck={item} onPress={() => router.push({ pathname: '/revision/session', params: { deckId: item.id, deckName: item.name } })} />}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
function DeckCard({ deck, onPress }: { deck: Deck; onPress: () => void }) {
|
||||
const progress = deck.totalCards > 0 ? deck.masteredCount / deck.totalCards : 0
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={s.card} onPress={onPress} activeOpacity={0.8}>
|
||||
<View style={s.cardLeft}>
|
||||
<View style={s.deckIcon}>
|
||||
<BookOpen size={18} color={C.brand} />
|
||||
</View>
|
||||
<View style={s.cardInfo}>
|
||||
<Text style={s.deckName} numberOfLines={1}>{deck.name}</Text>
|
||||
{deck.notebookName && <Text style={s.deckSub} numberOfLines={1}>{deck.notebookName}</Text>}
|
||||
<View style={s.progressBar}>
|
||||
<View style={[s.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
|
||||
</View>
|
||||
<Text style={s.progressTxt}>
|
||||
{deck.masteredCount}/{deck.totalCards} maîtrisées
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={s.cardRight}>
|
||||
{deck.dueCount > 0 ? (
|
||||
<View style={s.duePill}>
|
||||
<Clock size={11} color="#e11d48" />
|
||||
<Text style={s.dueTxt}>{deck.dueCount}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={s.upToDate}>✓</Text>
|
||||
)}
|
||||
<ChevronRight size={16} color={C.concrete} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
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' },
|
||||
})
|
||||
@@ -25,9 +25,11 @@ export default function RootLayout() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={s.loader}>
|
||||
<ActivityIndicator size="large" color={C.brand} />
|
||||
</View>
|
||||
<SafeAreaProvider>
|
||||
<View style={s.loader}>
|
||||
<ActivityIndicator size="large" color={C.brand} />
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<\/h[1-6]>/gi, '\n')
|
||||
.replace(/<\/li>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function tipTapToHtml(node: any): string {
|
||||
if (!node) return ''
|
||||
if (node.type === 'doc') return (node.content ?? []).map(tipTapToHtml).join('')
|
||||
if (node.type === 'text') {
|
||||
let t = (node.text ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
if (node.marks?.some((m: any) => m.type === 'bold')) t = `<strong>${t}</strong>`
|
||||
if (node.marks?.some((m: any) => m.type === 'italic')) t = `<em>${t}</em>`
|
||||
if (node.marks?.some((m: any) => m.type === 'code')) t = `<code>${t}</code>`
|
||||
return t
|
||||
}
|
||||
const inner = (node.content ?? []).map(tipTapToHtml).join('')
|
||||
switch (node.type) {
|
||||
case 'paragraph': return `<p>${inner || '​'}</p>`
|
||||
case 'heading': return `<h${node.attrs?.level ?? 1}>${inner}</h${node.attrs?.level ?? 1}>`
|
||||
case 'bulletList': return `<ul>${inner}</ul>`
|
||||
case 'orderedList': return `<ol>${inner}</ol>`
|
||||
case 'listItem': return `<li>${inner}</li>`
|
||||
case 'blockquote': return `<blockquote>${inner}</blockquote>`
|
||||
case 'codeBlock': return `<pre><code>${inner}</code></pre>`
|
||||
case 'hardBreak': return '<br>'
|
||||
default: return inner
|
||||
}
|
||||
}
|
||||
|
||||
function buildHtml(content: string, title: string) {
|
||||
const safeTitle = title.replace(/</g, '<').replace(/>/g, '>')
|
||||
// Détecter si le contenu est déjà du HTML ou du texte brut
|
||||
const isHtml = content.trimStart().startsWith('<')
|
||||
const body = isHtml ? content : `<p>${content.replace(/\n/g, '<br>')}</p>`
|
||||
let body: string
|
||||
const trimmed = content.trimStart()
|
||||
if (trimmed.startsWith('<')) {
|
||||
body = content
|
||||
} else if (trimmed.startsWith('{')) {
|
||||
try { body = tipTapToHtml(JSON.parse(content)) } catch { body = `<p>${content.replace(/\n/g, '<br>')}</p>` }
|
||||
} else {
|
||||
body = `<p>${content.replace(/\n/g, '<br>')}</p>`
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=3">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #A47148;
|
||||
--ink: #1A1A18;
|
||||
--paper: #FAFAF8;
|
||||
--concrete: #8A8A82;
|
||||
--border: #E8E6E0;
|
||||
--code-bg: #f0ede8;
|
||||
}
|
||||
:root { --brand: #A47148; --ink: #1A1A18; --paper: #FAFAF8; --concrete: #8A8A82; --border: #E8E6E0; --code-bg: #f0ede8; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, 'Helvetica Neue', sans-serif;
|
||||
font-size: 16px; line-height: 1.75; color: var(--ink);
|
||||
background: var(--paper); padding: 20px 20px 80px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.note-title {
|
||||
font-size: 24px; font-weight: 800; letter-spacing: -0.5px;
|
||||
color: var(--ink); margin-bottom: 4px; line-height: 1.3;
|
||||
}
|
||||
body { font-family: -apple-system, 'Helvetica Neue', sans-serif; font-size: 16px; line-height: 1.75; color: var(--ink); background: var(--paper); padding: 20px 20px 80px; word-break: break-word; }
|
||||
.note-title { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; color: var(--ink); margin-bottom: 4px; line-height: 1.3; }
|
||||
.note-sep { border: none; border-top: 1px solid var(--border); margin: 16px 0 20px; }
|
||||
h1 { font-size: 22px; font-weight: 700; margin: 24px 0 10px; }
|
||||
h2 { font-size: 19px; font-weight: 700; margin: 20px 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
||||
@@ -75,15 +114,10 @@ function buildHtml(content: string, title: string) {
|
||||
del, s { text-decoration: line-through; color: var(--concrete); }
|
||||
mark { background: #fff3cd; padding: 1px 3px; border-radius: 3px; }
|
||||
input[type=checkbox] { accent-color: var(--brand); margin-right: 6px; width: 16px; height: 16px; }
|
||||
/* TipTap task list */
|
||||
ul[data-type="taskList"] { list-style: none; padding-left: 4px; }
|
||||
ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; }
|
||||
ul[data-type="taskList"] li > label { margin-top: 2px; }
|
||||
/* TipTap nœuds spéciaux cachés sur mobile */
|
||||
[data-type="liveBlock"], [data-type="structuredViewBlock"] {
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px;
|
||||
margin: 8px 0; background: #f8f6f2; color: var(--concrete); font-size: 13px;
|
||||
}
|
||||
[data-type="liveBlock"], [data-type="structuredViewBlock"] { border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; margin: 8px 0; background: #f8f6f2; color: var(--concrete); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -99,50 +133,155 @@ export default function NoteScreen() {
|
||||
const [note, setNote] = useState<Note | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [aiSheetOpen, setAiSheetOpen] = useState(false)
|
||||
const [flashcardSheetOpen, setFlashcardSheetOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
// Audio en mode édition
|
||||
const { state: audioState, startRecording, stopAndTranscribe, cancelRecording } = useAudioRecorder(
|
||||
(text) => setEditContent((prev) => prev ? prev + ' ' + text : text)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
apiFetch(ENDPOINTS.note(id as string))
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`Erreur ${r.status}`)
|
||||
return r.json()
|
||||
})
|
||||
.then((r) => { if (!r.ok) throw new Error(`Erreur ${r.status}`); return r.json() })
|
||||
.then((data) => setNote(data.note ?? null))
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!note) return
|
||||
setEditTitle(note.title)
|
||||
setEditContent(htmlToPlainText(note.content ?? ''))
|
||||
setEditMode(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!note || !editTitle.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.note(note.id), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title: editTitle.trim(), content: editContent }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Erreur de sauvegarde')
|
||||
setNote((prev) => prev ? { ...prev, title: editTitle.trim(), content: editContent } : prev)
|
||||
setEditMode(false)
|
||||
} catch (e: any) {
|
||||
Alert.alert('Erreur', e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMic = () => {
|
||||
if (audioState === 'idle' || audioState === 'error') startRecording()
|
||||
else if (audioState === 'recording') stopAndTranscribe()
|
||||
else cancelRecording()
|
||||
}
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!note) return
|
||||
await Share.share({ title: note.title, message: `${note.title}\nhttps://memento-note.com` })
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!note) return
|
||||
Alert.alert(
|
||||
'Supprimer la note',
|
||||
`Mettre "${note.title}" à la corbeille ?`,
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer', style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.note(note.id), { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`Erreur ${res.status}`)
|
||||
router.back()
|
||||
} catch (e: any) {
|
||||
Alert.alert('Erreur', e.message ?? 'Impossible de supprimer la note')
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
<View style={s.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={s.backBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<TouchableOpacity onPress={() => { if (editMode) setEditMode(false); else router.back() }} style={s.backBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<ArrowLeft size={22} color={C.ink} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle} numberOfLines={1}>{note?.title ?? '…'}</Text>
|
||||
{note && (
|
||||
<TouchableOpacity onPress={handleShare} style={s.shareBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Share2 size={18} color={C.concrete} />
|
||||
<Text style={s.headerTitle} numberOfLines={1}>{editMode ? (editTitle || '…') : (note?.title ?? '…')}</Text>
|
||||
{note && !editMode && (
|
||||
<>
|
||||
<TouchableOpacity onPress={handleEdit} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Pencil size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setFlashcardSheetOpen(true)} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<GraduationCap size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleShare} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Share2 size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleDelete} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Trash2 size={18} color="#e11d48" />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
{editMode && (
|
||||
<TouchableOpacity onPress={handleSave} disabled={saving || !editTitle.trim()} style={[s.saveBtn, (!editTitle.trim() || saving) && { opacity: 0.35 }]}>
|
||||
{saving ? <ActivityIndicator size="small" color={C.white} /> : <Check size={18} color={C.white} />}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{loading && <View style={s.center}><ActivityIndicator color={C.brand} size="large" /></View>}
|
||||
{error && <View style={s.center}><Text style={{ color: C.rose }}>{error}</Text></View>}
|
||||
{error && <View style={s.center}><Text style={{ color: '#e11d48' }}>{error}</Text></View>}
|
||||
{!loading && !error && !note && <View style={s.center}><Text style={{ color: C.concrete }}>Note introuvable.</Text></View>}
|
||||
{note && (
|
||||
|
||||
{/* Mode lecture */}
|
||||
{note && !editMode && (
|
||||
<WebView
|
||||
source={{ html: buildHtml(note.content ?? '', note.title ?? '') }}
|
||||
style={{ flex: 1, backgroundColor: C.paper }}
|
||||
javaScriptEnabled={true}
|
||||
scrollEnabled={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
originWhitelist={['*']}
|
||||
javaScriptEnabled scrollEnabled showsVerticalScrollIndicator={false} originWhitelist={['*']}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mode édition */}
|
||||
{note && editMode && (
|
||||
<View style={{ flex: 1 }}>
|
||||
<TextInput value={editTitle} onChangeText={setEditTitle} style={s.editTitle} placeholder="Titre…" placeholderTextColor={C.border} />
|
||||
<View style={s.editDivider} />
|
||||
<TextInput value={editContent} onChangeText={setEditContent} style={s.editContent} placeholder="Contenu…" placeholderTextColor={C.concrete} multiline textAlignVertical="top" scrollEnabled />
|
||||
{/* Barre outils */}
|
||||
<View style={s.toolbar}>
|
||||
<MicButton state={audioState} onPress={handleMic} />
|
||||
{audioState === 'recording'
|
||||
? <Text style={s.recordHint}>● Enregistrement… Appuyez pour arrêter</Text>
|
||||
: <TouchableOpacity onPress={() => setAiSheetOpen(true)} disabled={!editContent.trim()} style={[s.aiBtn, !editContent.trim() && { opacity: 0.35 }]} activeOpacity={0.8}>
|
||||
<Text style={s.aiBtnText}>✨ Améliorer avec l'IA</Text>
|
||||
</TouchableOpacity>}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<AISheet visible={aiSheetOpen} onClose={() => setAiSheetOpen(false)} text={editContent} onApply={(t) => setEditContent(t)} />
|
||||
{note && (
|
||||
<FlashcardSheet
|
||||
visible={flashcardSheetOpen}
|
||||
onClose={() => setFlashcardSheetOpen(false)}
|
||||
noteId={note.id}
|
||||
noteTitle={note.title}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
177
memento-mobile/app/note/create.tsx
Normal file
177
memento-mobile/app/note/create.tsx
Normal file
@@ -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<TextInput>(null)
|
||||
|
||||
// Audio
|
||||
const { state: audioState, startRecording, stopAndTranscribe, cancelRecording } = useAudioRecorder(
|
||||
(text) => setContent((prev) => prev ? prev + ' ' + text : text)
|
||||
)
|
||||
|
||||
const handleMic = () => {
|
||||
if (audioState === 'idle' || audioState === 'error') startRecording()
|
||||
else if (audioState === 'recording') stopAndTranscribe()
|
||||
else cancelRecording()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
Alert.alert('Titre requis', 'Donnez un titre à votre note.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.createNote, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: title.trim(), content, notebookId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
throw new Error(d.error ?? 'Erreur serveur')
|
||||
}
|
||||
const { note } = await res.json()
|
||||
router.replace({ pathname: '/note/[id]', params: { id: note.id } })
|
||||
} catch (e: any) {
|
||||
Alert.alert('Erreur', e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
|
||||
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={s.cancelBtn}>
|
||||
<X size={20} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle}>Nouvelle note</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleSave}
|
||||
disabled={saving || !title.trim()}
|
||||
style={[s.saveBtn, (!title.trim() || saving) && s.saveBtnDisabled]}
|
||||
>
|
||||
{saving
|
||||
? <ActivityIndicator size="small" color={C.white} />
|
||||
: <Text style={s.saveBtnText}>Enregistrer</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
|
||||
{/* Titre */}
|
||||
<View style={s.titleRow}>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Titre de la note…"
|
||||
style={s.titleInput}
|
||||
placeholderTextColor={C.border}
|
||||
returnKeyType="next"
|
||||
onSubmitEditing={() => contentRef.current?.focus()}
|
||||
autoFocus
|
||||
/>
|
||||
{content.trim().length >= 10 && (
|
||||
<TouchableOpacity onPress={() => setTitleSheetOpen(true)} style={s.sparkleBtn} activeOpacity={0.8}>
|
||||
<Sparkles size={16} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={s.divider} />
|
||||
|
||||
{/* Contenu */}
|
||||
<TextInput
|
||||
ref={contentRef}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Commencez à écrire…"
|
||||
style={s.contentInput}
|
||||
placeholderTextColor={C.concrete}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Barre outils bas */}
|
||||
<View style={s.toolbar}>
|
||||
<MicButton state={audioState} onPress={handleMic} />
|
||||
{audioState === 'recording' && (
|
||||
<Text style={s.recordingHint}>● Enregistrement… Appuyez pour arrêter</Text>
|
||||
)}
|
||||
{audioState !== 'recording' && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setAiSheetOpen(true)}
|
||||
disabled={!content.trim()}
|
||||
style={[s.aiBtn, !content.trim() && s.aiBtnDisabled]}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Sparkles size={15} color={C.white} />
|
||||
<Text style={s.aiBtnText}>Améliorer avec l'IA</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Modaux propres */}
|
||||
<AISheet
|
||||
visible={aiSheetOpen}
|
||||
onClose={() => setAiSheetOpen(false)}
|
||||
text={content}
|
||||
onApply={(improved) => setContent(improved)}
|
||||
/>
|
||||
<TitleSheet
|
||||
visible={titleSheetOpen}
|
||||
onClose={() => setTitleSheetOpen(false)}
|
||||
content={content}
|
||||
onSelect={(t) => setTitle(t)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
cancelBtn: { padding: 4, marginRight: 4 },
|
||||
headerTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: C.ink, textAlign: 'center' },
|
||||
saveBtn: { backgroundColor: C.brand, paddingHorizontal: 14, paddingVertical: 7, borderRadius: 10 },
|
||||
saveBtnDisabled: { opacity: 0.35 },
|
||||
saveBtnText: { color: C.white, fontWeight: '700', fontSize: 13 },
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingTop: 20, paddingBottom: 8, gap: 8 },
|
||||
titleInput: { flex: 1, fontSize: 26, fontWeight: '800', color: C.ink, letterSpacing: -0.5, lineHeight: 32 },
|
||||
sparkleBtn: { padding: 8, borderRadius: 10, backgroundColor: '#f3ece4' },
|
||||
divider: { height: 1, backgroundColor: C.border, marginHorizontal: 20, marginBottom: 16 },
|
||||
contentInput: { flex: 1, fontSize: 16, color: C.ink, lineHeight: 26, paddingHorizontal: 20, paddingBottom: 120, minHeight: 300 },
|
||||
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 12, borderTopWidth: 1, borderTopColor: C.border, backgroundColor: C.paper },
|
||||
recordingHint: { flex: 1, fontSize: 13, color: '#e11d48', fontWeight: '500' },
|
||||
aiBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: C.ink, paddingVertical: 11, paddingHorizontal: 14, borderRadius: 12, justifyContent: 'center' },
|
||||
aiBtnDisabled: { opacity: 0.35 },
|
||||
aiBtnText: { color: C.white, fontWeight: '600', fontSize: 14 },
|
||||
})
|
||||
@@ -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() {
|
||||
<ArrowLeft size={22} color={C.ink} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle}>{notebookName || 'Carnet'}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push({ pathname: '/note/create', params: { notebookId: id } })}
|
||||
style={s.addBtn}
|
||||
>
|
||||
<Plus size={20} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{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 },
|
||||
|
||||
237
memento-mobile/app/revision/session.tsx
Normal file
237
memento-mobile/app/revision/session.tsx
Normal file
@@ -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<SessionState>('loading')
|
||||
const [cards, setCards] = useState<Card[]>([])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [flipped, setFlipped] = useState(false)
|
||||
const [reviewed, setReviewed] = useState(0)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
|
||||
<SafeAreaView style={s.safe}>
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<ArrowLeft size={22} color={C.ink} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle} numberOfLines={1}>{deckName ?? 'Révision'}</Text>
|
||||
{state === 'reviewing' && (
|
||||
<Text style={s.counter}>{index + 1}/{cards.length}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Barre de progression */}
|
||||
{state === 'reviewing' && (
|
||||
<View style={s.progressBarWrap}>
|
||||
<View style={[s.progressBarFill, { width: `${Math.round(progress * 100)}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Contenu */}
|
||||
{state === 'loading' && (
|
||||
<View style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<View style={s.center}>
|
||||
<Text style={s.errorTxt}>{errorMsg}</Text>
|
||||
<TouchableOpacity onPress={loadSession} style={s.retryBtn}>
|
||||
<Text style={s.retryTxt}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state === 'done' && (
|
||||
<View style={s.center}>
|
||||
<GraduationCap size={56} color={C.brand} />
|
||||
<Text style={s.doneTitle}>Session terminée !</Text>
|
||||
<Text style={s.doneSub}>
|
||||
{reviewed > 0 ? `${reviewed} carte${reviewed > 1 ? 's' : ''} révisée${reviewed > 1 ? 's' : ''}` : 'Tout est à jour 🎉'}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => router.back()} style={s.doneBtn}>
|
||||
<Text style={s.doneBtnTxt}>Retour aux paquets</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state === 'reviewing' && current && (
|
||||
<View style={s.sessionWrap}>
|
||||
{/* Carte flip */}
|
||||
<TouchableOpacity activeOpacity={0.95} onPress={flip} style={s.cardWrap}>
|
||||
{/* Face avant */}
|
||||
<Animated.View style={[s.card, s.cardFront, { transform: [{ rotateY: frontInterp }] }]}>
|
||||
<ScrollView contentContainerStyle={s.cardContent} showsVerticalScrollIndicator={false}>
|
||||
<Text style={s.cardLabel}>Question</Text>
|
||||
<Text style={s.cardText}>{current.front}</Text>
|
||||
</ScrollView>
|
||||
{!flipped && (
|
||||
<View style={s.tapHint}>
|
||||
<Text style={s.tapTxt}>Appuyez pour révéler la réponse</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Face arrière */}
|
||||
<Animated.View style={[s.card, s.cardBack, { transform: [{ rotateY: backInterp }] }]}>
|
||||
<ScrollView contentContainerStyle={s.cardContent} showsVerticalScrollIndicator={false}>
|
||||
<Text style={s.cardLabel}>Réponse</Text>
|
||||
<Text style={s.cardText}>{current.back}</Text>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Boutons de note (visibles seulement après flip) */}
|
||||
{flipped && (
|
||||
<View style={s.gradeRow}>
|
||||
{GRADE_LABELS.map(({ grade: g, label, color, bg, border }) => (
|
||||
<TouchableOpacity
|
||||
key={g}
|
||||
style={[s.gradeBtn, { backgroundColor: bg, borderColor: border }]}
|
||||
onPress={() => grade(g)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[s.gradeNum, { color }]}>{g}</Text>
|
||||
<Text style={[s.gradeLbl, { color }]}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
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' },
|
||||
})
|
||||
142
memento-mobile/components/AISheet.tsx
Normal file
142
memento-mobile/components/AISheet.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* AISheet — bottom sheet IA avec modes d'amélioration + résultat
|
||||
* Remplace les Alert.alert natifs par une UI propre au design Momento
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View, Text, TouchableOpacity, ActivityIndicator,
|
||||
ScrollView, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { Sparkles, Scissors, MessageSquare, Pencil, CheckCircle2, RefreshCw } from 'lucide-react-native'
|
||||
import { BottomSheet } from '@/components/BottomSheet'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
const MODES = [
|
||||
{ key: 'improve', label: 'Améliorer le style', icon: Sparkles, color: C.brand },
|
||||
{ key: 'fix_grammar', label: 'Corriger la grammaire', icon: CheckCircle2, color: '#5b7ec7' },
|
||||
{ key: 'shorten', label: 'Raccourcir', icon: Scissors, color: '#4a9b61' },
|
||||
{ key: 'clarify', label: 'Clarifier les idées', icon: MessageSquare,color: '#c77a3a' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
text: string
|
||||
onApply: (improved: string) => void
|
||||
}
|
||||
|
||||
export function AISheet({ visible, onClose, text, onApply }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<string | null>(null)
|
||||
const [selectedMode, setSelectedMode] = useState<string | null>(null)
|
||||
|
||||
const handleClose = () => {
|
||||
setResult(null)
|
||||
setSelectedMode(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleMode = async (mode: string) => {
|
||||
setSelectedMode(mode)
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.aiImprove, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, mode }),
|
||||
})
|
||||
const d = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
setResult(`⚠️ ${d.error === 'quota_exceeded' ? 'Quota IA dépassé' : (d.error ?? 'Erreur serveur')}`)
|
||||
return
|
||||
}
|
||||
setResult(d.improved ?? '')
|
||||
} catch {
|
||||
setResult('⚠️ Erreur réseau — vérifiez votre connexion.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
if (result && !result.startsWith('⚠️')) {
|
||||
onApply(result)
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
if (selectedMode) handleMode(selectedMode)
|
||||
}
|
||||
|
||||
const title = result ? 'Résultat IA' : 'Améliorer avec l\'IA'
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={handleClose} title={title}>
|
||||
{!result && !loading && (
|
||||
<View style={s.modeList}>
|
||||
{MODES.map((m) => {
|
||||
const Icon = m.icon
|
||||
return (
|
||||
<TouchableOpacity key={m.key} onPress={() => handleMode(m.key)} style={s.modeRow} activeOpacity={0.7}>
|
||||
<View style={[s.modeIcon, { backgroundColor: m.color + '18' }]}>
|
||||
<Icon size={18} color={m.color} />
|
||||
</View>
|
||||
<Text style={s.modeLabel}>{m.label}</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<View style={s.loadingBox}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
<Text style={s.loadingText}>Génération en cours…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{result && !loading && (
|
||||
<View style={s.resultBox}>
|
||||
<ScrollView style={s.resultScroll} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[s.resultText, result.startsWith('⚠️') && { color: '#e11d48' }]}>
|
||||
{result}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
{!result.startsWith('⚠️') && (
|
||||
<TouchableOpacity onPress={handleApply} style={s.applyBtn} activeOpacity={0.8}>
|
||||
<Pencil size={15} color={C.white} />
|
||||
<Text style={s.applyBtnText}>Remplacer le texte</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity onPress={handleRetry} style={s.retryBtn} activeOpacity={0.8}>
|
||||
<RefreshCw size={14} color={C.concrete} />
|
||||
<Text style={s.retryText}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
modeList: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8 },
|
||||
modeRow: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 14,
|
||||
paddingHorizontal: 12, paddingVertical: 14,
|
||||
borderRadius: 14, marginBottom: 4,
|
||||
},
|
||||
modeIcon: { width: 40, height: 40, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
|
||||
modeLabel: { fontSize: 15, fontWeight: '500', color: C.ink },
|
||||
loadingBox: { alignItems: 'center', paddingVertical: 36, gap: 12 },
|
||||
loadingText: { fontSize: 14, color: C.concrete },
|
||||
resultBox: { paddingHorizontal: 20, paddingTop: 8 },
|
||||
resultScroll: { maxHeight: 200, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 14, padding: 14, marginBottom: 14 },
|
||||
resultText: { fontSize: 15, color: C.ink, lineHeight: 23 },
|
||||
applyBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: C.ink, paddingVertical: 13, borderRadius: 14, justifyContent: 'center', marginBottom: 8 },
|
||||
applyBtnText: { color: C.white, fontWeight: '700', fontSize: 14 },
|
||||
retryBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, justifyContent: 'center', paddingVertical: 10 },
|
||||
retryText: { fontSize: 13, color: C.concrete },
|
||||
})
|
||||
80
memento-mobile/components/BottomSheet.tsx
Normal file
80
memento-mobile/components/BottomSheet.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* BottomSheet — modal bas d'écran respectant le design Momento
|
||||
* Usage:
|
||||
* <BottomSheet visible={v} onClose={() => setV(false)} title="Titre">
|
||||
* ...children
|
||||
* </BottomSheet>
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
View, Text, Modal, TouchableOpacity, Animated,
|
||||
Pressable, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { X } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function BottomSheet({ visible, onClose, title, children }: Props) {
|
||||
const translateY = useRef(new Animated.Value(400)).current
|
||||
const opacity = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.spring(translateY, { toValue: 0, useNativeDriver: true, damping: 20, stiffness: 200 }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
|
||||
]).start()
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(translateY, { toValue: 400, duration: 220, useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
||||
<Animated.View style={[s.overlay, { opacity }]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
|
||||
<Animated.View style={[s.sheet, { transform: [{ translateY }] }]}>
|
||||
{/* Handle bar */}
|
||||
<View style={s.handle} />
|
||||
{title && (
|
||||
<View style={s.titleRow}>
|
||||
<Text style={s.title}>{title}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={s.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<X size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(26,26,24,0.5)', justifyContent: 'flex-end' },
|
||||
sheet: {
|
||||
backgroundColor: C.paper,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingBottom: 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 16,
|
||||
elevation: 16,
|
||||
},
|
||||
handle: { width: 36, height: 4, backgroundColor: C.border, borderRadius: 2, alignSelf: 'center', marginTop: 12, marginBottom: 4 },
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
title: { flex: 1, fontSize: 15, fontWeight: '700', color: C.ink },
|
||||
closeBtn: { padding: 2 },
|
||||
})
|
||||
173
memento-mobile/components/FlashcardSheet.tsx
Normal file
173
memento-mobile/components/FlashcardSheet.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* FlashcardSheet — génère et sauvegarde des flashcards depuis une note
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView } from 'react-native'
|
||||
import { GraduationCap, Check, RefreshCw } from 'lucide-react-native'
|
||||
import { BottomSheet } from './BottomSheet'
|
||||
import { C } from '@/lib/theme'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { useRouter } from 'expo-router'
|
||||
|
||||
const STYLES = [
|
||||
{ key: 'qa', label: 'Q&A', desc: 'Questions / Réponses' },
|
||||
{ key: 'concept', label: 'Concept', desc: 'Terme / Définition' },
|
||||
{ key: 'cloze', label: 'Cloze', desc: 'Texte à trous' },
|
||||
] as const
|
||||
|
||||
const COUNTS = [5, 10, 15, 20]
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
}
|
||||
|
||||
export function FlashcardSheet({ visible, onClose, noteId, noteTitle }: Props) {
|
||||
const router = useRouter()
|
||||
const [style, setStyle] = useState<'qa' | 'concept' | 'cloze'>('qa')
|
||||
const [count, setCount] = useState(10)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ deckId: string; count: number } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const generate = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.flashcardGenerate, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ noteId, style, count }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`)
|
||||
setResult({ deckId: data.deckId, count: data.count })
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Erreur de génération')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goRevise = () => {
|
||||
onClose()
|
||||
router.push({ pathname: '/(tabs)/revision' })
|
||||
}
|
||||
|
||||
const reset = () => { setResult(null); setError(null) }
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={onClose}>
|
||||
<View style={s.header}>
|
||||
<GraduationCap size={20} color={C.brand} />
|
||||
<Text style={s.title}>Générer des flashcards</Text>
|
||||
</View>
|
||||
<Text style={s.noteTitle} numberOfLines={1}>📝 {noteTitle}</Text>
|
||||
|
||||
{!result && !loading && (
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Style */}
|
||||
<Text style={s.sectionLabel}>Type de cartes</Text>
|
||||
<View style={s.pills}>
|
||||
{STYLES.map((st) => (
|
||||
<TouchableOpacity
|
||||
key={st.key}
|
||||
style={[s.pill, style === st.key && s.pillActive]}
|
||||
onPress={() => setStyle(st.key)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[s.pillLabel, style === st.key && s.pillLabelActive]}>{st.label}</Text>
|
||||
<Text style={[s.pillDesc, style === st.key && s.pillDescActive]}>{st.desc}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Nombre */}
|
||||
<Text style={s.sectionLabel}>Nombre de cartes</Text>
|
||||
<View style={s.countRow}>
|
||||
{COUNTS.map((n) => (
|
||||
<TouchableOpacity
|
||||
key={n}
|
||||
style={[s.countBtn, count === n && s.countBtnActive]}
|
||||
onPress={() => setCount(n)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[s.countTxt, count === n && s.countTxtActive]}>{n}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{error && <Text style={s.error}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity style={s.generateBtn} onPress={generate} activeOpacity={0.85}>
|
||||
<GraduationCap size={16} color="#fff" />
|
||||
<Text style={s.generateTxt}>Générer {count} cartes</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<View style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
<Text style={s.loadingTxt}>Génération en cours…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<View style={s.resultWrap}>
|
||||
<View style={s.checkCircle}>
|
||||
<Check size={28} color="#16a34a" />
|
||||
</View>
|
||||
<Text style={s.resultTitle}>{result.count} cartes créées !</Text>
|
||||
<Text style={s.resultSub}>Votre paquet est prêt pour la révision.</Text>
|
||||
<View style={s.resultActions}>
|
||||
<TouchableOpacity style={s.reviseBtn} onPress={goRevise} activeOpacity={0.85}>
|
||||
<GraduationCap size={16} color="#fff" />
|
||||
<Text style={s.reviseTxt}>Réviser maintenant</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.retryBtn} onPress={reset} activeOpacity={0.8}>
|
||||
<RefreshCw size={14} color={C.concrete} />
|
||||
<Text style={s.retryTxt}>Regénérer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
|
||||
title: { fontSize: 17, fontWeight: '700', color: C.ink },
|
||||
noteTitle: { fontSize: 13, color: C.concrete, marginBottom: 20 },
|
||||
sectionLabel: { fontSize: 11, fontWeight: '700', color: C.concrete, letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 10 },
|
||||
pills: { flexDirection: 'row', gap: 8, marginBottom: 20 },
|
||||
pill: { flex: 1, padding: 12, borderRadius: 12, borderWidth: 1.5, borderColor: C.border, backgroundColor: C.paper },
|
||||
pillActive: { borderColor: C.brand, backgroundColor: 'rgba(164,113,72,0.08)' },
|
||||
pillLabel: { fontSize: 13, fontWeight: '700', color: C.ink, marginBottom: 2 },
|
||||
pillLabelActive: { color: C.brand },
|
||||
pillDesc: { fontSize: 10, color: C.concrete },
|
||||
pillDescActive: { color: C.brand },
|
||||
countRow: { flexDirection: 'row', gap: 8, marginBottom: 24 },
|
||||
countBtn: { flex: 1, paddingVertical: 12, borderRadius: 12, borderWidth: 1.5, borderColor: C.border, alignItems: 'center', backgroundColor: C.paper },
|
||||
countBtnActive: { borderColor: C.brand, backgroundColor: 'rgba(164,113,72,0.08)' },
|
||||
countTxt: { fontSize: 15, fontWeight: '700', color: C.ink },
|
||||
countTxtActive: { color: C.brand },
|
||||
error: { color: '#e11d48', fontSize: 13, marginBottom: 12, textAlign: 'center' },
|
||||
generateBtn: { backgroundColor: C.brand, borderRadius: 14, paddingVertical: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8 },
|
||||
generateTxt: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
center: { alignItems: 'center', justifyContent: 'center', paddingVertical: 40 },
|
||||
loadingTxt: { marginTop: 16, color: C.concrete, fontSize: 14 },
|
||||
resultWrap: { alignItems: 'center', paddingVertical: 20 },
|
||||
checkCircle: { width: 64, height: 64, borderRadius: 32, backgroundColor: '#dcfce7', alignItems: 'center', justifyContent: 'center', marginBottom: 16 },
|
||||
resultTitle: { fontSize: 20, fontWeight: '800', color: C.ink, marginBottom: 8 },
|
||||
resultSub: { fontSize: 14, color: C.concrete, marginBottom: 28 },
|
||||
resultActions: { width: '100%', gap: 10 },
|
||||
reviseBtn: { backgroundColor: C.brand, borderRadius: 14, paddingVertical: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8 },
|
||||
reviseTxt: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
retryBtn: { borderRadius: 14, paddingVertical: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, borderWidth: 1, borderColor: C.border },
|
||||
retryTxt: { color: C.concrete, fontWeight: '600', fontSize: 13 },
|
||||
})
|
||||
66
memento-mobile/components/MicButton.tsx
Normal file
66
memento-mobile/components/MicButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* MicButton — bouton enregistrement vocal avec feedback visuel
|
||||
* États : idle → recording (pulsé rouge) → processing (spinner)
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { TouchableOpacity, ActivityIndicator, Animated, StyleSheet, View, Text } from 'react-native'
|
||||
import { Mic, MicOff, Square } from 'lucide-react-native'
|
||||
import { AudioState } from '@/lib/useAudioRecorder'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
state: AudioState
|
||||
onPress: () => void
|
||||
errorMsg?: string | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function MicButton({ state, onPress, errorMsg, size = 20 }: Props) {
|
||||
const pulse = useRef(new Animated.Value(1)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'recording') {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1.25, duration: 600, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
])
|
||||
).start()
|
||||
} else {
|
||||
pulse.stopAnimation()
|
||||
pulse.setValue(1)
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const bgColor =
|
||||
state === 'recording' ? '#fee2e2' :
|
||||
state === 'processing' ? '#f3ece4' :
|
||||
state === 'error' ? '#fee2e2' :
|
||||
'#f3ece4'
|
||||
|
||||
const borderColor =
|
||||
state === 'recording' ? '#fca5a5' :
|
||||
state === 'error' ? '#fca5a5' :
|
||||
C.border
|
||||
|
||||
return (
|
||||
<Animated.View style={[s.wrap, { backgroundColor: bgColor, borderColor }, { transform: [{ scale: state === 'recording' ? pulse : 1 }] }]}>
|
||||
<TouchableOpacity onPress={onPress} disabled={state === 'processing'} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
{state === 'processing'
|
||||
? <ActivityIndicator size="small" color={C.brand} />
|
||||
: state === 'recording'
|
||||
? <Square size={size - 2} color="#e11d48" />
|
||||
: state === 'error'
|
||||
? <MicOff size={size} color="#e11d48" />
|
||||
: <Mic size={size} color={C.brand} />}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: {
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
borderWidth: 1, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
115
memento-mobile/components/TitleSheet.tsx
Normal file
115
memento-mobile/components/TitleSheet.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* TitleSheet — suggère 3 titres IA dans un bottom sheet propre
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
View, Text, TouchableOpacity, ActivityIndicator, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { Sparkles } from 'lucide-react-native'
|
||||
import { BottomSheet } from '@/components/BottomSheet'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
content: string
|
||||
onSelect: (title: string) => void
|
||||
}
|
||||
|
||||
export function TitleSheet({ visible, onClose, content, onSelect }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && content.trim()) {
|
||||
fetchTitles()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const fetchTitles = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuggestions([])
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.aiTitle, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
const d = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
setError(d.error === 'quota_exceeded' ? 'Quota dépassé' : 'Erreur serveur')
|
||||
return
|
||||
}
|
||||
const raw: unknown[] = d.suggestions ?? []
|
||||
setSuggestions(raw.map((s) => (typeof s === 'string' ? s : (s as any).title ?? '')).filter(Boolean))
|
||||
} catch {
|
||||
setError('Erreur réseau')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (t: string) => {
|
||||
onSelect(t)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={onClose} title="Titres suggérés">
|
||||
{loading && (
|
||||
<View style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
<Text style={s.hint}>Génération des titres…</Text>
|
||||
</View>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<View style={s.center}>
|
||||
<Text style={s.errorText}>{error}</Text>
|
||||
<TouchableOpacity onPress={fetchTitles} style={s.retryBtn}>
|
||||
<Text style={s.retryText}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{!loading && !error && suggestions.length > 0 && (
|
||||
<View style={s.list}>
|
||||
{suggestions.map((t, i) => (
|
||||
<TouchableOpacity key={i} onPress={() => handleSelect(t)} style={s.row} activeOpacity={0.7}>
|
||||
<View style={s.numBadge}>
|
||||
<Text style={s.numText}>{i + 1}</Text>
|
||||
</View>
|
||||
<Text style={s.titleText}>{t}</Text>
|
||||
<Sparkles size={14} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{!loading && !error && suggestions.length === 0 && (
|
||||
<View style={s.center}>
|
||||
<Text style={s.hint}>Aucune suggestion disponible</Text>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
center: { alignItems: 'center', paddingVertical: 32, gap: 12 },
|
||||
hint: { fontSize: 14, color: C.concrete },
|
||||
errorText: { fontSize: 14, color: '#e11d48' },
|
||||
retryBtn: { paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10, backgroundColor: C.border },
|
||||
retryText: { fontSize: 13, color: C.ink, fontWeight: '600' },
|
||||
list: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8 },
|
||||
row: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
paddingHorizontal: 12, paddingVertical: 14,
|
||||
borderRadius: 14, marginBottom: 8,
|
||||
backgroundColor: C.white, borderWidth: 1, borderColor: C.border,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
numBadge: { width: 26, height: 26, borderRadius: 13, backgroundColor: '#f3ece4', alignItems: 'center', justifyContent: 'center' },
|
||||
numText: { fontSize: 12, fontWeight: '700', color: C.brand },
|
||||
titleText: { flex: 1, fontSize: 14, fontWeight: '500', color: C.ink },
|
||||
})
|
||||
@@ -3,11 +3,19 @@ import * as SecureStore from 'expo-secure-store'
|
||||
const TOKEN_KEY = 'memento_token'
|
||||
|
||||
export async function getToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(TOKEN_KEY)
|
||||
try {
|
||||
return await SecureStore.getItemAsync(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setToken(token: string): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
|
||||
100
memento-mobile/lib/useAudioRecorder.ts
Normal file
100
memento-mobile/lib/useAudioRecorder.ts
Normal file
@@ -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<AudioState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const recordingRef = useRef<Audio.Recording | null>(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 }
|
||||
}
|
||||
42
memento-mobile/package-lock.json
generated
42
memento-mobile/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
41
memento-note/app/api/mobile/ai/improve/route.ts
Normal file
41
memento-note/app/api/mobile/ai/improve/route.ts
Normal file
@@ -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<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar'> = {
|
||||
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 })
|
||||
}
|
||||
38
memento-note/app/api/mobile/ai/title/route.ts
Normal file
38
memento-note/app/api/mobile/ai/title/route.ts
Normal file
@@ -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) })
|
||||
}
|
||||
40
memento-note/app/api/mobile/ai/transcribe/route.ts
Normal file
40
memento-note/app/api/mobile/ai/transcribe/route.ts
Normal file
@@ -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 ?? '' })
|
||||
}
|
||||
37
memento-note/app/api/mobile/flashcards/decks/route.ts
Normal file
37
memento-note/app/api/mobile/flashcards/decks/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
84
memento-note/app/api/mobile/flashcards/generate/route.ts
Normal file
84
memento-note/app/api/mobile/flashcards/generate/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
40
memento-note/app/api/mobile/flashcards/review/route.ts
Normal file
40
memento-note/app/api/mobile/flashcards/review/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
38
memento-note/app/api/mobile/flashcards/session/route.ts
Normal file
38
memento-note/app/api/mobile/flashcards/session/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
@@ -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 '<p></p>'
|
||||
if (text.trimStart().startsWith('<')) return text
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => `<p>${line.trim() ? escapeHtml(line) : ''}</p>`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
|
||||
59
memento-note/app/api/mobile/notes/daily/route.ts
Normal file
59
memento-note/app/api/mobile/notes/daily/route.ts
Normal file
@@ -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 `<h1>📅 ${title}</h1><p></p>`
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -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 '<p></p>'
|
||||
// Si déjà du HTML, retourner tel quel
|
||||
if (text.trimStart().startsWith('<')) return text
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => `<p>${line.trim() ? escapeHtml(line) : ''}</p>`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const [showTurnInto, setShowTurnInto] = useState(false)
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | 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(
|
||||
<div ref={menuRef} style={menuStyle} className="block-action-menu">
|
||||
<button type="button" className="block-action-item" onClick={handleDelete}>
|
||||
<Trash2 size={16} />
|
||||
<span>{t('blockAction.delete')}</span>
|
||||
{/* Actions de déplacement */}
|
||||
<button type="button" className="block-action-item" onClick={handleMoveUp}>
|
||||
<ArrowUp size={16} />
|
||||
<span>{t('blockAction.moveUp')}</span>
|
||||
</button>
|
||||
<button type="button" className="block-action-item" onClick={handleMoveDown}>
|
||||
<ArrowDown size={16} />
|
||||
<span>{t('blockAction.moveDown')}</span>
|
||||
</button>
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
{/* Transformer en */}
|
||||
<div
|
||||
className="block-action-submenu-wrap"
|
||||
onMouseLeave={handleTurnIntoLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="block-action-item block-action-submenu-trigger"
|
||||
onClick={() => setShowTurnInto((v) => !v)}
|
||||
onMouseEnter={handleTurnIntoEnter}
|
||||
>
|
||||
<Repeat size={16} />
|
||||
<span>{t('blockAction.turnInto')}</span>
|
||||
<ChevronRight size={14} className="ml-auto" />
|
||||
</button>
|
||||
|
||||
{showTurnInto && (
|
||||
<div className="block-action-submenu" onMouseEnter={handleSubmenuEnter}>
|
||||
{TURN_INTO_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
className="block-action-item"
|
||||
onClick={() => handleTurnInto(opt)}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
<span>{t(`blockAction.turnInto_${opt.id}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
{/* Copier */}
|
||||
<button type="button" className="block-action-item" onClick={() => { void handleCopyContent() }}>
|
||||
<ClipboardCopy size={16} />
|
||||
<span>{t('blockAction.copyContent')}</span>
|
||||
</button>
|
||||
<button type="button" className="block-action-item" onClick={() => { void handleCopyRef() }}>
|
||||
<Link size={16} />
|
||||
<span>{t('blockAction.copyRef')}</span>
|
||||
</button>
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
{/* Actions destructives */}
|
||||
<button type="button" className="block-action-item" onClick={handleDuplicate}>
|
||||
<Copy size={16} />
|
||||
<span>{t('blockAction.duplicate')}</span>
|
||||
</button>
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="block-action-item block-action-submenu-trigger"
|
||||
onClick={() => setShowTurnInto(!showTurnInto)}
|
||||
onMouseEnter={() => setShowTurnInto(true)}
|
||||
>
|
||||
<Repeat size={16} />
|
||||
<span>{t('blockAction.turnInto')}</span>
|
||||
<ChevronRight size={14} className="ml-auto" />
|
||||
</button>
|
||||
|
||||
{showTurnInto && (
|
||||
<div className="block-action-submenu">
|
||||
{TURN_INTO_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
className="block-action-item"
|
||||
onClick={() => handleTurnInto(opt)}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
<span>{t(`blockAction.turnInto_${opt.id}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
<button type="button" className="block-action-item" onClick={() => { void handleCopyRef() }}>
|
||||
<Link size={16} />
|
||||
<span>{t('blockAction.copyRef')}</span>
|
||||
<button type="button" className="block-action-item block-action-item--danger" onClick={handleDelete}>
|
||||
<Trash2 size={16} />
|
||||
<span>{t('blockAction.delete')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
Plus,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
@@ -78,8 +77,7 @@ function shuffleCards<T>(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<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const [newDeckName, setNewDeckName] = useState('')
|
||||
const [creatingDeck, setCreatingDeck] = useState(false)
|
||||
|
||||
// Gap 8 — deck deletion state
|
||||
const [deletingDeckId, setDeletingDeckId] = useState<string | null>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input
|
||||
value={newDeckName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createDeck}
|
||||
disabled={creatingDeck || !newDeckName.trim()}
|
||||
className="px-3 py-2 rounded-lg border border-border text-xs font-bold flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
{creatingDeck ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
{t('flashcards.createDeck')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingDecks ? (
|
||||
<div className="flex justify-center py-16"><Loader2 className="animate-spin text-concrete" /></div>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user