diff --git a/memento-mobile/app.json b/memento-mobile/app.json new file mode 100644 index 0000000..68f3910 --- /dev/null +++ b/memento-mobile/app.json @@ -0,0 +1,34 @@ +{ + "expo": { + "name": "Momento", + "slug": "memento-mobile", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "scheme": "memento", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#FAFAF8" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.momentonote.app" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FAFAF8" + }, + "package": "com.momentonote.app" + }, + "plugins": [ + "expo-router", + "expo-secure-store" + ], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/memento-mobile/app/(auth)/_layout.tsx b/memento-mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..981966b --- /dev/null +++ b/memento-mobile/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router' + +export default function AuthLayout() { + return +} diff --git a/memento-mobile/app/(auth)/login.tsx b/memento-mobile/app/(auth)/login.tsx new file mode 100644 index 0000000..070719c --- /dev/null +++ b/memento-mobile/app/(auth)/login.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react' +import { + View, + Text, + TextInput, + TouchableOpacity, + KeyboardAvoidingView, + Platform, + ActivityIndicator, + Alert, +} from 'react-native' +import { useAuthStore } from '@/lib/store' + +export default function LoginScreen() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const login = useAuthStore((s) => s.login) + + const handleLogin = async () => { + if (!email.trim() || !password.trim()) return + setLoading(true) + try { + await login(email.trim().toLowerCase(), password) + } catch (e: any) { + Alert.alert('Connexion échouée', e.message) + } finally { + setLoading(false) + } + } + + return ( + + + {/* Logo */} + + Momento + Votre espace de connaissance + + + {/* Form */} + + + + Email + + + + + + + Mot de passe + + + + + + {loading ? ( + + ) : ( + Se connecter + )} + + + + + memento-note.com + + + + ) +} diff --git a/memento-mobile/app/(tabs)/_layout.tsx b/memento-mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..fdec56c --- /dev/null +++ b/memento-mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,55 @@ +import { Tabs } from 'expo-router' +import { BookOpen, Search, Home, User } from 'lucide-react-native' + +export default function TabsLayout() { + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + ) +} diff --git a/memento-mobile/app/(tabs)/home.tsx b/memento-mobile/app/(tabs)/home.tsx new file mode 100644 index 0000000..4240ede --- /dev/null +++ b/memento-mobile/app/(tabs)/home.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import { + View, Text, ScrollView, TouchableOpacity, + ActivityIndicator, RefreshControl, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { CalendarDays, Sparkles, BookOpen } from 'lucide-react-native' +import { apiFetch } from '@/lib/api' +import { ENDPOINTS } from '@/lib/config' +import { useAuthStore } from '@/lib/store' + +interface Note { + id: string + title: string + updatedAt: string + notebookName?: string + color?: string +} + +export default function HomeScreen() { + const user = useAuthStore((s) => s.user) + const [recentNotes, setRecentNotes] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const router = useRouter() + + const load = async () => { + try { + const res = await apiFetch(ENDPOINTS.notes()) + if (res.ok) { + const data = await res.json() + setRecentNotes((data.notes ?? []).slice(0, 10)) + } + } finally { + setLoading(false) + setRefreshing(false) + } + } + + useEffect(() => { load() }, []) + + const handleDailyNote = async () => { + const res = await apiFetch(ENDPOINTS.dailyNote) + if (res.ok) { + const data = await res.json() + router.push(`/note/${data.id}`) + } + } + + const formatDate = (iso: string) => { + const d = new Date(iso) + return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) + } + + return ( + + { setRefreshing(true); load() }} tintColor="#A47148" />} + > + {/* Header */} + + + Bonjour{user?.name ? `, ${user.name.split(' ')[0]}` : ''} 👋 + + + {new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })} + + + + {/* Quick actions */} + + + + Note du jour + + router.push('/(tabs)/notebooks')} + className="flex-1 bg-white border border-border rounded-2xl p-4 items-center gap-2" + > + + Carnets + + router.push('/(tabs)/search')} + className="flex-1 bg-white border border-border rounded-2xl p-4 items-center gap-2" + > + + Recherche IA + + + + {/* Recent notes */} + + + Récentes + + + {loading ? ( + + ) : recentNotes.length === 0 ? ( + Aucune note pour l'instant. + ) : ( + recentNotes.map((note) => ( + router.push(`/note/${note.id}`)} + className="bg-white border border-border rounded-2xl p-4 mb-2.5 active:opacity-70" + > + + {note.title || 'Sans titre'} + + + {note.notebookName && ( + {note.notebookName} + )} + {formatDate(note.updatedAt)} + + + )) + )} + + + + + + ) +} diff --git a/memento-mobile/app/(tabs)/notebooks.tsx b/memento-mobile/app/(tabs)/notebooks.tsx new file mode 100644 index 0000000..46a22b9 --- /dev/null +++ b/memento-mobile/app/(tabs)/notebooks.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { + View, Text, FlatList, TouchableOpacity, + ActivityIndicator, RefreshControl, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronRight } from 'lucide-react-native' +import { apiFetch } from '@/lib/api' +import { ENDPOINTS } from '@/lib/config' + +interface Notebook { + id: string + name: string + icon: string | null + _count: { notes: number } +} + +export default function NotebooksScreen() { + const [notebooks, setNotebooks] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const router = useRouter() + + const load = async () => { + try { + const res = await apiFetch(ENDPOINTS.notebooks) + if (res.ok) { + const data = await res.json() + setNotebooks(data.notebooks ?? []) + } + } finally { + setLoading(false) + setRefreshing(false) + } + } + + useEffect(() => { load() }, []) + + if (loading) { + return ( + + + + ) + } + + return ( + + + Carnets + + + item.id} + contentContainerClassName="px-5 pb-8" + refreshControl={ { setRefreshing(true); load() }} tintColor="#A47148" />} + renderItem={({ item }) => ( + router.push(`/notebook/${item.id}`)} + className="flex-row items-center bg-white border border-border rounded-2xl p-4 mb-2.5 active:opacity-70" + > + {item.icon ?? '📓'} + + {item.name} + {item._count?.notes ?? 0} notes + + + + )} + ListEmptyComponent={ + Aucun carnet. + } + /> + + ) +} diff --git a/memento-mobile/app/(tabs)/profile.tsx b/memento-mobile/app/(tabs)/profile.tsx new file mode 100644 index 0000000..b456bd5 --- /dev/null +++ b/memento-mobile/app/(tabs)/profile.tsx @@ -0,0 +1,73 @@ +import { View, Text, TouchableOpacity, ScrollView, Alert } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { LogOut, CreditCard, Globe } from 'lucide-react-native' +import { useAuthStore } from '@/lib/store' + +const TIER_LABELS: Record = { + FREE: 'Gratuit', + BASIC: 'Basic', + PRO: 'Pro', + BUSINESS: 'Business', + ENTERPRISE: 'Enterprise', +} + +export default function ProfileScreen() { + const { user, logout } = useAuthStore() + + const handleLogout = () => { + Alert.alert( + 'Se déconnecter', + 'Voulez-vous vraiment vous déconnecter ?', + [ + { text: 'Annuler', style: 'cancel' }, + { text: 'Déconnecter', style: 'destructive', onPress: logout }, + ] + ) + } + + return ( + + + Profil + + {/* User info */} + + + + {user?.name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? '?'} + + + {user?.name ?? 'Utilisateur'} + {user?.email} + {user?.tier && ( + + + {TIER_LABELS[user.tier] ?? user.tier} + + + )} + + + {/* Actions */} + + + + Abonnement + + + + Ouvrir memento-note.com + + + + + + Se déconnecter + + + + ) +} diff --git a/memento-mobile/app/(tabs)/search.tsx b/memento-mobile/app/(tabs)/search.tsx new file mode 100644 index 0000000..4473612 --- /dev/null +++ b/memento-mobile/app/(tabs)/search.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { + View, Text, TextInput, FlatList, + TouchableOpacity, ActivityIndicator, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { Search as SearchIcon, X } from 'lucide-react-native' +import { useRouter } from 'expo-router' +import { apiFetch } from '@/lib/api' +import { ENDPOINTS } from '@/lib/config' + +interface SearchResult { + id: string + title: string + snippet: string + notebookName?: string + score?: number +} + +export default function SearchScreen() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [searched, setSearched] = useState(false) + const router = useRouter() + + const handleSearch = async () => { + if (!query.trim()) return + setLoading(true) + setSearched(true) + try { + const res = await apiFetch(`${ENDPOINTS.search}?q=${encodeURIComponent(query)}`) + if (res.ok) { + const data = await res.json() + setResults(data.results ?? []) + } + } finally { + setLoading(false) + } + } + + return ( + + + Recherche + + + + + {query.length > 0 && ( + { setQuery(''); setResults([]); setSearched(false) }}> + + + )} + + + + {loading ? ( + + + + ) : ( + item.id} + contentContainerClassName="px-5 pt-3 pb-8" + renderItem={({ item }) => ( + router.push(`/note/${item.id}`)} + className="bg-white border border-border rounded-2xl p-4 mb-2.5 active:opacity-70" + > + + {item.title || 'Sans titre'} + + {item.snippet && ( + + {item.snippet} + + )} + {item.notebookName && ( + {item.notebookName} + )} + + )} + ListEmptyComponent={ + searched ? ( + Aucun résultat pour "{query}" + ) : ( + + Tapez votre recherche puis appuyez sur Entrée + + ) + } + /> + )} + + ) +} diff --git a/memento-mobile/app/_layout.tsx b/memento-mobile/app/_layout.tsx new file mode 100644 index 0000000..57c2507 --- /dev/null +++ b/memento-mobile/app/_layout.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react' +import { Slot, useRouter, useSegments } from 'expo-router' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import { StatusBar } from 'expo-status-bar' +import { View, ActivityIndicator } from 'react-native' +import { useAuthStore } from '@/lib/store' + +export default function RootLayout() { + const { user, loading, restore } = useAuthStore() + const router = useRouter() + const segments = useSegments() + + useEffect(() => { + restore() + }, []) + + useEffect(() => { + if (loading) return + const inAuth = segments[0] === '(auth)' + if (!user && !inAuth) { + router.replace('/(auth)/login') + } else if (user && inAuth) { + router.replace('/(tabs)/home') + } + }, [user, loading, segments]) + + if (loading) { + return ( + + + + ) + } + + return ( + + + + + ) +} diff --git a/memento-mobile/app/note/[id].tsx b/memento-mobile/app/note/[id].tsx new file mode 100644 index 0000000..653f6fe --- /dev/null +++ b/memento-mobile/app/note/[id].tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react' +import { + View, Text, ScrollView, ActivityIndicator, + TouchableOpacity, Share, +} 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 { WebView } from 'react-native-webview' +import { apiFetch } from '@/lib/api' +import { ENDPOINTS } from '@/lib/config' + +interface Note { + id: string + title: string + content: string + updatedAt: string + notebookName?: string +} + +// Wrap HTML content in a minimal styled page for WebView rendering +function buildHtml(content: string, title: string) { + return ` + + + + + +${content} +` +} + +export default function NoteScreen() { + const { id } = useLocalSearchParams<{ id: string }>() + const [note, setNote] = useState(null) + const [loading, setLoading] = useState(true) + const router = useRouter() + + useEffect(() => { + apiFetch(ENDPOINTS.note(id)) + .then((r) => r.json()) + .then((data) => setNote(data.note ?? null)) + .finally(() => setLoading(false)) + }, [id]) + + const handleShare = async () => { + if (!note) return + await Share.share({ + title: note.title, + message: `${note.title}\nhttps://memento-note.com`, + }) + } + + return ( + + {/* Header */} + + router.back()} className="p-1"> + + + + {note?.title ?? '…'} + + + + + + + {loading ? ( + + + + ) : note ? ( + + ) : ( + + Note introuvable. + + )} + + ) +} diff --git a/memento-mobile/app/notebook/[id].tsx b/memento-mobile/app/notebook/[id].tsx new file mode 100644 index 0000000..1576613 --- /dev/null +++ b/memento-mobile/app/notebook/[id].tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { + View, Text, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ArrowLeft } from 'lucide-react-native' +import { apiFetch } from '@/lib/api' +import { ENDPOINTS } from '@/lib/config' + +interface Note { + id: string + title: string + updatedAt: string +} + +export default function NotebookScreen() { + const { id } = useLocalSearchParams<{ id: string }>() + const [notes, setNotes] = useState([]) + const [notebookName, setNotebookName] = useState('') + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const router = useRouter() + + const load = async () => { + try { + const res = await apiFetch(ENDPOINTS.notes(id)) + if (res.ok) { + const data = await res.json() + setNotes(data.notes ?? []) + setNotebookName(data.notebookName ?? '') + } + } finally { + setLoading(false) + setRefreshing(false) + } + } + + useEffect(() => { load() }, [id]) + + return ( + + + router.back()} className="p-1"> + + + {notebookName || 'Carnet'} + + + {loading ? ( + + + + ) : ( + item.id} + contentContainerClassName="px-5 pt-4 pb-8" + refreshControl={ { setRefreshing(true); load() }} tintColor="#A47148" />} + renderItem={({ item }) => ( + router.push(`/note/${item.id}`)} + className="bg-white border border-border rounded-2xl p-4 mb-2.5 active:opacity-70" + > + + {item.title || 'Sans titre'} + + + {new Date(item.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} + + + )} + ListEmptyComponent={Carnet vide.} + /> + )} + + ) +} diff --git a/memento-mobile/babel.config.js b/memento-mobile/babel.config.js new file mode 100644 index 0000000..1d1ac9c --- /dev/null +++ b/memento-mobile/babel.config.js @@ -0,0 +1,9 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [ + ['babel-preset-expo', { jsxImportSource: 'nativewind' }], + 'nativewind/babel', + ], + }; +}; diff --git a/memento-mobile/index.ts b/memento-mobile/index.ts new file mode 100644 index 0000000..5b83418 --- /dev/null +++ b/memento-mobile/index.ts @@ -0,0 +1 @@ +import 'expo-router/entry'; diff --git a/memento-mobile/lib/api.ts b/memento-mobile/lib/api.ts new file mode 100644 index 0000000..5b6bd7e --- /dev/null +++ b/memento-mobile/lib/api.ts @@ -0,0 +1,27 @@ +import * as SecureStore from 'expo-secure-store' + +const TOKEN_KEY = 'memento_token' + +export async function getToken(): Promise { + return SecureStore.getItemAsync(TOKEN_KEY) +} + +export async function setToken(token: string): Promise { + await SecureStore.setItemAsync(TOKEN_KEY, token) +} + +export async function clearToken(): Promise { + await SecureStore.deleteItemAsync(TOKEN_KEY) +} + +export async function apiFetch(url: string, options: RequestInit = {}): Promise { + const token = await getToken() + return fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }) +} diff --git a/memento-mobile/lib/config.ts b/memento-mobile/lib/config.ts new file mode 100644 index 0000000..253dc90 --- /dev/null +++ b/memento-mobile/lib/config.ts @@ -0,0 +1,18 @@ +// API base URL — change for dev/prod +export const API_URL = __DEV__ + ? 'http://192.168.1.190:3000' // local network dev server + : 'https://memento-note.com' + +export const ENDPOINTS = { + login: `${API_URL}/api/mobile/auth/login`, + logout: `${API_URL}/api/mobile/auth/logout`, + me: `${API_URL}/api/mobile/auth/me`, + notebooks: `${API_URL}/api/mobile/notebooks`, + notes: (notebookId?: string) => + notebookId + ? `${API_URL}/api/mobile/notes?notebookId=${notebookId}` + : `${API_URL}/api/mobile/notes`, + note: (id: string) => `${API_URL}/api/mobile/notes/${id}`, + search: `${API_URL}/api/mobile/search`, + dailyNote: `${API_URL}/api/notes/daily`, +} diff --git a/memento-mobile/lib/store.ts b/memento-mobile/lib/store.ts new file mode 100644 index 0000000..9c58ea7 --- /dev/null +++ b/memento-mobile/lib/store.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand' +import { apiFetch, setToken, clearToken, getToken } from '@/lib/api' +import { ENDPOINTS } from '@/lib/config' + +interface User { + id: string + name: string | null + email: string + tier: string +} + +interface AuthState { + user: User | null + loading: boolean + login: (email: string, password: string) => Promise + logout: () => Promise + restore: () => Promise +} + +export const useAuthStore = create((set) => ({ + user: null, + loading: true, + + login: async (email, password) => { + const res = await fetch(ENDPOINTS.login, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Identifiants invalides') + } + const { token, user } = await res.json() + await setToken(token) + set({ user }) + }, + + logout: async () => { + await clearToken() + set({ user: null }) + }, + + restore: async () => { + try { + const token = await getToken() + if (!token) { set({ loading: false }); return } + const res = await apiFetch(ENDPOINTS.me) + if (!res.ok) { await clearToken(); set({ loading: false, user: null }); return } + const user = await res.json() + set({ user, loading: false }) + } catch { + set({ loading: false, user: null }) + } + }, +})) diff --git a/memento-mobile/package.json b/memento-mobile/package.json new file mode 100644 index 0000000..a6274d9 --- /dev/null +++ b/memento-mobile/package.json @@ -0,0 +1,34 @@ +{ + "name": "memento-mobile", + "version": "1.0.0", + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "build:android": "eas build --platform android", + "build:ios": "eas build --platform ios" + }, + "dependencies": { + "expo": "~52.0.0", + "expo-router": "~4.0.0", + "expo-status-bar": "~2.0.1", + "expo-secure-store": "~14.0.0", + "expo-font": "~13.0.2", + "expo-splash-screen": "~0.29.18", + "react": "18.3.1", + "react-native": "0.76.7", + "react-native-safe-area-context": "4.12.0", + "react-native-screens": "~4.4.0", + "react-native-webview": "13.12.5", + "@react-native-async-storage/async-storage": "1.23.1", + "nativewind": "^4.1.23", + "tailwindcss": "^3.4.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@types/react": "~18.3.12", + "typescript": "^5.3.3" + } +} diff --git a/memento-mobile/tailwind.config.js b/memento-mobile/tailwind.config.js new file mode 100644 index 0000000..0596dd2 --- /dev/null +++ b/memento-mobile/tailwind.config.js @@ -0,0 +1,21 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'], + presets: [require('nativewind/preset')], + theme: { + extend: { + colors: { + brand: '#A47148', + ink: '#1A1A18', + paper: '#FAFAF8', + concrete: '#8A8A82', + border: '#E8E6E0', + }, + fontFamily: { + sans: ['System'], + serif: ['Georgia'], + }, + }, + }, + plugins: [], +} diff --git a/memento-mobile/tsconfig.json b/memento-mobile/tsconfig.json new file mode 100644 index 0000000..ed3ad5c --- /dev/null +++ b/memento-mobile/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"] +} diff --git a/memento-note/app/api/mobile/auth/login/route.ts b/memento-note/app/api/mobile/auth/login/route.ts new file mode 100644 index 0000000..f87ac6e --- /dev/null +++ b/memento-note/app/api/mobile/auth/login/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import bcrypt from 'bcryptjs' +import { createMobileToken } from '@/lib/mobile-auth' + +export async function POST(req: NextRequest) { + try { + const { email, password } = await req.json() + if (!email || !password) { + return NextResponse.json({ error: 'Email et mot de passe requis' }, { status: 400 }) + } + + const user = await prisma.user.findUnique({ + where: { email: email.toLowerCase().trim() }, + select: { + id: true, name: true, email: true, password: true, + subscription: { select: { tier: true } }, + }, + }) + + if (!user?.password) { + return NextResponse.json({ error: 'Identifiants invalides' }, { status: 401 }) + } + + const valid = await bcrypt.compare(password, user.password) + if (!valid) { + return NextResponse.json({ error: 'Identifiants invalides' }, { status: 401 }) + } + + const token = createMobileToken(user.id) + return NextResponse.json({ + token, + user: { + id: user.id, + name: user.name, + email: user.email, + tier: user.subscription?.tier ?? 'FREE', + }, + }) + } catch (e) { + console.error('[mobile/auth/login]', e) + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} + diff --git a/memento-note/app/api/mobile/auth/me/route.ts b/memento-note/app/api/mobile/auth/me/route.ts new file mode 100644 index 0000000..f7df5a1 --- /dev/null +++ b/memento-note/app/api/mobile/auth/me/route.ts @@ -0,0 +1,25 @@ +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: 'Non autorisé' }, { status: 401 }) + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, name: true, email: true, + subscription: { select: { tier: true } }, + }, + }) + if (!user) return NextResponse.json({ error: 'Introuvable' }, { status: 404 }) + + return NextResponse.json({ + id: user.id, + name: user.name, + email: user.email, + tier: user.subscription?.tier ?? 'FREE', + }) +} + diff --git a/memento-note/app/api/mobile/notebooks/route.ts b/memento-note/app/api/mobile/notebooks/route.ts new file mode 100644 index 0000000..1458f86 --- /dev/null +++ b/memento-note/app/api/mobile/notebooks/route.ts @@ -0,0 +1,16 @@ +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: 'Non autorisé' }, { status: 401 }) + + const notebooks = await prisma.notebook.findMany({ + where: { userId, trashedAt: null }, + include: { _count: { select: { notes: { where: { trashedAt: null } } } } }, + orderBy: { order: 'asc' }, + }) + + return NextResponse.json({ notebooks }) +} diff --git a/memento-note/app/api/mobile/notes/[id]/route.ts b/memento-note/app/api/mobile/notes/[id]/route.ts new file mode 100644 index 0000000..a0febe2 --- /dev/null +++ b/memento-note/app/api/mobile/notes/[id]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import { getMobileUserId } from '@/lib/mobile-auth' + +export async function GET( + 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 }, + select: { + id: true, + title: true, + content: true, + updatedAt: true, + createdAt: true, + color: true, + notebook: { select: { id: true, name: true } }, + }, + }) + + if (!note) return NextResponse.json({ error: 'Note introuvable' }, { status: 404 }) + + return NextResponse.json({ note }) +} diff --git a/memento-note/app/api/mobile/notes/route.ts b/memento-note/app/api/mobile/notes/route.ts new file mode 100644 index 0000000..ea8d739 --- /dev/null +++ b/memento-note/app/api/mobile/notes/route.ts @@ -0,0 +1,43 @@ +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: 'Non autorisé' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const notebookId = searchParams.get('notebookId') + + const notes = await prisma.note.findMany({ + where: { + userId, + trashedAt: null, + ...(notebookId ? { notebookId } : {}), + }, + select: { + id: true, + title: true, + updatedAt: true, + color: true, + notebook: { select: { name: true } }, + }, + orderBy: { updatedAt: 'desc' }, + take: 50, + }) + + const notebookName = notebookId + ? (await prisma.notebook.findUnique({ where: { id: notebookId }, select: { name: true } }))?.name + : undefined + + return NextResponse.json({ + notes: notes.map((n) => ({ + id: n.id, + title: n.title, + updatedAt: n.updatedAt, + color: n.color, + notebookName: n.notebook?.name, + })), + ...(notebookName ? { notebookName } : {}), + }) +} diff --git a/memento-note/app/api/mobile/search/route.ts b/memento-note/app/api/mobile/search/route.ts new file mode 100644 index 0000000..cef3d0f --- /dev/null +++ b/memento-note/app/api/mobile/search/route.ts @@ -0,0 +1,42 @@ +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: 'Non autorisé' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const q = searchParams.get('q')?.trim() + if (!q) return NextResponse.json({ results: [] }) + + const notes = await prisma.note.findMany({ + where: { + userId, + trashedAt: null, + OR: [ + { title: { contains: q, mode: 'insensitive' } }, + { content: { contains: q, mode: 'insensitive' } }, + ], + }, + select: { + id: true, + title: true, + content: true, + notebook: { select: { name: true } }, + }, + take: 20, + orderBy: { updatedAt: 'desc' }, + }) + + const results = notes.map((n) => ({ + id: n.id, + title: n.title, + notebookName: n.notebook?.name, + snippet: n.content + ? n.content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160) + : '', + })) + + return NextResponse.json({ results }) +} diff --git a/memento-note/components/settings/settings-help-box.tsx b/memento-note/components/settings/settings-help-box.tsx index 5a0deed..7c4c04c 100644 --- a/memento-note/components/settings/settings-help-box.tsx +++ b/memento-note/components/settings/settings-help-box.tsx @@ -21,29 +21,29 @@ export function SettingsHelpBox({ title, steps, defaultOpen = false, className } const [open, setOpen] = useState(defaultOpen) return ( -
+
{open && ( -
    +
      {steps.map((step, i) => (
    1. - + {step.icon ?? i + 1} - + {step.text} {step.link && ( <> @@ -52,7 +52,7 @@ export function SettingsHelpBox({ title, steps, defaultOpen = false, className } href={step.link.href} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-0.5 underline underline-offset-2 font-medium hover:text-blue-600" + className="inline-flex items-center gap-0.5 text-brand-accent underline underline-offset-2 font-medium hover:opacity-80" > {step.link.label} diff --git a/memento-note/lib/mobile-auth.ts b/memento-note/lib/mobile-auth.ts new file mode 100644 index 0000000..6a68867 --- /dev/null +++ b/memento-note/lib/mobile-auth.ts @@ -0,0 +1,37 @@ +/** + * Mobile auth helper — validates Bearer token and returns userId + * Token format: base64(userId:timestamp:hmac) + */ +import { createHmac } from 'crypto' + +const SECRET = process.env.NEXTAUTH_SECRET || 'fallback-secret' + +export function createMobileToken(userId: string): string { + const ts = Date.now() + const payload = `${userId}:${ts}` + const sig = createHmac('sha256', SECRET).update(payload).digest('hex').slice(0, 16) + return Buffer.from(`${payload}:${sig}`).toString('base64url') +} + +export function verifyMobileToken(token: string): string | null { + try { + const decoded = Buffer.from(token, 'base64url').toString('utf-8') + const parts = decoded.split(':') + if (parts.length !== 3) return null + const [userId, ts, sig] = parts + const payload = `${userId}:${ts}` + const expected = createHmac('sha256', SECRET).update(payload).digest('hex').slice(0, 16) + if (sig !== expected) return null + // Token valid for 90 days + if (Date.now() - Number(ts) > 90 * 24 * 60 * 60 * 1000) return null + return userId + } catch { + return null + } +} + +export function getMobileUserId(request: Request): string | null { + const auth = request.headers.get('Authorization') + if (!auth?.startsWith('Bearer ')) return null + return verifyMobileToken(auth.slice(7)) +}