Compare commits

...

5 Commits

Author SHA1 Message Date
Antigravity
d06ea93f11 mobile: login - bouton Google OAuth + show/hide password + message erreur Google
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m18s
CI / Deploy production (on server) (push) Has been skipped
- login.tsx: bouton 'Continuer avec Google' (expo-web-browser + deep link memento://auth)
- login.tsx: bouton oeil pour afficher/masquer mot de passe
- login.tsx: message d'erreur contextuel si compte Google (pas de mot de passe en DB)
- store.ts: loginWithToken() pour recevoir le token après OAuth Google
- google-start/route.ts: lance le flux NextAuth Google avec redirect callback
- google-callback/route.ts: reçoit la session, génère token mobile, redirige vers memento://auth

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 17:09:06 +00:00
Antigravity
725bf2c445 mobile: add .npmrc with legacy-peer-deps=true
Évite d'avoir à taper --legacy-peer-deps à chaque npm install

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 17:05:23 +00:00
Antigravity
d2145f761d mobile: fix navigation (typed routes), extract C tokens to lib/theme.ts
- lib/theme.ts: C design tokens dans fichier dédié (plus d'import circulaire _layout)
- app/_layout.tsx: importe C depuis @/lib/theme, ré-exporte pour compatibilité
- Tous les écrans: import C depuis '@/lib/theme' au lieu de '../_layout'
- Toutes les navigations: router.push({ pathname, params }) au lieu de template strings
  -> Fix réel du bug 'impossible d'ouvrir carnet/note' avec Expo Router v6
- package.json: expo-web-browser ajouté (pour Google OAuth étape suivante)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 17:03:14 +00:00
Antigravity
45877db706 mobile: fix notebook icons + redesign home/notebooks + inline MD parser in WebView
- notebooks.tsx: detect Lucide icon names (folder, book, etc.) vs emoji
  -> render <Folder> component instead of raw string 'folder'
  -> colored icon wrap using notebook color
- home.tsx: full redesign — header greeting + quick actions + recent list
  -> section-based layout, dense note rows with chevron
- note/[id].tsx: remove 'marked' import (Metro bundler issue)
  -> inline minimal MD→HTML parser runs inside WebView JS context
  -> handles headings, lists, blockquotes, code blocks, inline styles
  -> zero external dependency, works 100% offline
- package.json: remove 'marked' dependency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 16:49:55 +00:00
Antigravity
0ef12f7399 fix(mobile): render notes with marked (proper Markdown→HTML) + design CSS soigné
- Install marked package (UMD, hors-ligne)
- buildHtml: parse Markdown server-side avec marked, inject HTML statique
- CSS: typographie soignée, blockquotes brandés, code dark, tables propres
- Plus de CDN, fonctionne hors-ligne

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 16:43:24 +00:00
22 changed files with 494 additions and 122 deletions

View File

@@ -0,0 +1,13 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.

View File

@@ -0,0 +1,3 @@
{
"devices": []
}

14
memento-mobile/.expo/types/router.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/* eslint-disable */
import * as Router from 'expo-router';
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; } };
}
}
}

6
memento-mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

1
memento-mobile/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -1,17 +1,53 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import {
View, Text, TextInput, TouchableOpacity,
KeyboardAvoidingView, Platform, ActivityIndicator,
Alert, StyleSheet,
} from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking'
import { useAuthStore } from '@/lib/store'
import { C } from '../_layout'
import { API_URL } from '@/lib/config'
import { C } from '@/lib/theme'
WebBrowser.maybeCompleteAuthSession()
export default function LoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPwd, setShowPwd] = useState(false)
const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
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)
}
}
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 }) })
return () => sub.remove()
}, [])
const handleLogin = async () => {
if (!email.trim() || !password.trim()) return
@@ -19,20 +55,61 @@ export default function LoginScreen() {
try {
await login(email.trim().toLowerCase(), password)
} catch (e: any) {
Alert.alert('Connexion échouée', e.message)
Alert.alert(
'Connexion échouée',
e.message?.includes('Identifiants invalides')
? 'Email ou mot de passe incorrect.\n\nSi vous vous êtes inscrit avec Google, utilisez le bouton Google ci-dessous.'
: e.message
)
} finally {
setLoading(false)
}
}
const handleGoogle = async () => {
setGoogleLoading(true)
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') {
setGoogleLoading(false)
}
// Si succès, le deep link listener prend le relais
} catch (e: any) {
Alert.alert('Erreur', e.message)
setGoogleLoading(false)
}
}
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={s.container}>
<View style={s.inner}>
{/* Logo */}
<View style={s.logoBlock}>
<Text style={s.logo}>Momento</Text>
<Text style={s.tagline}>Votre espace de connaissance</Text>
</View>
{/* Bouton Google */}
<TouchableOpacity onPress={handleGoogle} disabled={googleLoading || loading} style={s.googleBtn} activeOpacity={0.8}>
{googleLoading
? <ActivityIndicator color={C.ink} size="small" />
: <>
<Text style={s.googleG}>G</Text>
<Text style={s.googleText}>Continuer avec Google</Text>
</>}
</TouchableOpacity>
{/* Séparateur */}
<View style={s.sep}>
<View style={s.sepLine} />
<Text style={s.sepText}>ou</Text>
<View style={s.sepLine} />
</View>
{/* Formulaire email/password */}
<View style={s.form}>
<Text style={s.label}>Email</Text>
<TextInput
@@ -41,13 +118,23 @@ export default function LoginScreen() {
keyboardType="email-address" autoComplete="email"
style={s.input} placeholderTextColor={C.concrete}
/>
<Text style={[s.label, { marginTop: 16 }]}>Mot de passe</Text>
<TextInput
value={password} onChangeText={setPassword}
placeholder="••••••••" secureTextEntry
autoComplete="password" style={s.input}
placeholderTextColor={C.concrete} onSubmitEditing={handleLogin}
/>
<View style={s.pwdRow}>
<TextInput
value={password} onChangeText={setPassword}
placeholder="••••••••"
secureTextEntry={!showPwd}
autoComplete="password"
style={[s.input, { flex: 1, marginRight: 8 }]}
placeholderTextColor={C.concrete}
onSubmitEditing={handleLogin}
/>
<TouchableOpacity onPress={() => setShowPwd(!showPwd)} style={s.eyeBtn}>
<Text style={s.eyeIcon}>{showPwd ? '🙈' : '👁️'}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handleLogin}
disabled={loading || !email || !password}
@@ -68,14 +155,32 @@ export default function LoginScreen() {
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: C.paper },
inner: { flex: 1, justifyContent: 'center', paddingHorizontal: 32 },
logoBlock: { marginBottom: 48, alignItems: 'center' },
logoBlock: { marginBottom: 40, alignItems: 'center' },
logo: { fontSize: 36, fontStyle: 'italic', color: C.ink, fontWeight: '600' },
tagline: { fontSize: 14, color: C.concrete, marginTop: 4 },
googleBtn: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10,
backgroundColor: C.white, borderWidth: 1.5, borderColor: C.border,
borderRadius: 12, paddingVertical: 14,
shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 3,
},
googleG: { fontSize: 18, fontWeight: '700', color: '#4285F4' },
googleText: { fontSize: 15, fontWeight: '600', color: C.ink },
sep: { flexDirection: 'row', alignItems: 'center', marginVertical: 20, gap: 10 },
sepLine: { flex: 1, height: 1, backgroundColor: C.border },
sepText: { fontSize: 12, color: C.concrete, fontWeight: '500' },
form: {},
label: { fontSize: 11, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase', color: C.concrete, marginBottom: 6 },
input: { borderWidth: 1, borderColor: C.border, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12, fontSize: 15, color: C.ink, backgroundColor: C.white },
btn: { marginTop: 24, backgroundColor: C.ink, borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
btnDisabled: { opacity: 0.5 },
input: { borderWidth: 1, borderColor: C.border, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13, fontSize: 15, color: C.ink, backgroundColor: C.white },
pwdRow: { flexDirection: 'row', alignItems: 'center' },
eyeBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 12 },
eyeIcon: { fontSize: 18 },
btn: { marginTop: 20, backgroundColor: C.ink, borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
btnDisabled: { opacity: 0.4 },
btnText: { color: C.white, fontWeight: '600', fontSize: 15 },
footer: { textAlign: 'center', fontSize: 12, color: C.concrete, marginTop: 32 },
})

View File

@@ -1,6 +1,6 @@
import { Tabs } from 'expo-router'
import { BookOpen, Search, Home, User } from 'lucide-react-native'
import { C } from '../_layout'
import { C } from '@/lib/theme'
export default function TabsLayout() {
return (

View File

@@ -5,11 +5,11 @@ import {
} 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 { CalendarDays, Search, BookOpen, Clock, ChevronRight } from 'lucide-react-native'
import { apiFetch } from '@/lib/api'
import { ENDPOINTS } from '@/lib/config'
import { useAuthStore } from '@/lib/store'
import { C } from '../_layout'
import { C } from '@/lib/theme'
interface Note {
id: string
@@ -31,7 +31,7 @@ export default function HomeScreen() {
const res = await apiFetch(ENDPOINTS.notes())
if (res.ok) {
const data = await res.json()
setRecentNotes((data.notes ?? []).slice(0, 10))
setRecentNotes((data.notes ?? []).slice(0, 12))
}
} finally {
setLoading(false)
@@ -45,60 +45,84 @@ export default function HomeScreen() {
const res = await apiFetch(ENDPOINTS.dailyNote)
if (res.ok) {
const data = await res.json()
router.push(`/note/${data.id}`)
router.push({ pathname: '/note/[id]', params: { id: data.id } })
}
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
const now = new Date()
const hour = now.getHours()
const greeting = hour < 12 ? 'Bonjour' : hour < 18 ? 'Bon après-midi' : 'Bonsoir'
const firstName = user?.name?.split(' ')[0] ?? ''
return (
<SafeAreaView style={s.safe}>
<ScrollView
style={{ flex: 1 }}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load() }} tintColor={C.brand} />}
>
<View style={s.headerBlock}>
<Text style={s.greeting}>
Bonjour{user?.name ? `, ${user.name.split(' ')[0]}` : ''} 👋
</Text>
<Text style={s.date}>
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
</Text>
{/* Header */}
<View style={s.header}>
<View>
<Text style={s.greeting}>{greeting}{firstName ? `, ${firstName}` : ''}</Text>
<Text style={s.date}>{now.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}</Text>
</View>
<TouchableOpacity onPress={() => router.push('/(tabs)/search')} style={s.searchBtn}>
<Search size={18} color={C.ink} />
</TouchableOpacity>
</View>
{/* Quick actions */}
<View style={s.quickRow}>
<TouchableOpacity onPress={handleDailyNote} style={s.quickBtn}>
<CalendarDays size={22} color={C.brand} />
<TouchableOpacity onPress={handleDailyNote} style={s.quickCard} activeOpacity={0.7}>
<View style={[s.quickIcon, { backgroundColor: '#f3ece4' }]}>
<CalendarDays size={20} color={C.brand} />
</View>
<Text style={s.quickLabel}>Note du jour</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/(tabs)/notebooks')} style={s.quickBtn}>
<BookOpen size={22} color={C.brand} />
<TouchableOpacity onPress={() => router.push('/(tabs)/notebooks')} style={s.quickCard} activeOpacity={0.7}>
<View style={[s.quickIcon, { backgroundColor: '#edf0f7' }]}>
<BookOpen size={20} color="#5b7ec7" />
</View>
<Text style={s.quickLabel}>Carnets</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/(tabs)/search')} style={s.quickBtn}>
<Sparkles size={22} color={C.brand} />
<Text style={s.quickLabel}>Recherche IA</Text>
<TouchableOpacity onPress={() => router.push('/(tabs)/search')} style={s.quickCard} activeOpacity={0.7}>
<View style={[s.quickIcon, { backgroundColor: '#eef7ed' }]}>
<Search size={20} color="#4a9b61" />
</View>
<Text style={s.quickLabel}>Recherche</Text>
</TouchableOpacity>
</View>
<View style={s.recentBlock}>
<Text style={s.sectionLabel}>Récentes</Text>
{/* Recent notes */}
<View style={s.section}>
<View style={s.sectionHeader}>
<Clock size={13} color={C.concrete} />
<Text style={s.sectionLabel}>Récentes</Text>
</View>
{loading
? <ActivityIndicator color={C.brand} />
? <ActivityIndicator color={C.brand} style={{ marginTop: 24 }} />
: recentNotes.length === 0
? <Text style={s.empty}>Aucune note pour l'instant.</Text>
: recentNotes.map((note) => (
<TouchableOpacity key={note.id} onPress={() => router.push(`/note/${note.id}`)} style={s.noteCard}>
<Text style={s.noteTitle} numberOfLines={1}>{note.title || 'Sans titre'}</Text>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
{note.notebookName && <Text style={s.noteMeta}>{note.notebookName}</Text>}
<Text style={s.noteMeta}>{formatDate(note.updatedAt)}</Text>
: recentNotes.map((note, i) => (
<TouchableOpacity
key={note.id}
onPress={() => router.push({ pathname: '/note/[id]', params: { id: note.id } })}
style={[s.noteRow, i === recentNotes.length - 1 && { borderBottomWidth: 0 }]}
activeOpacity={0.6}
>
<View style={{ flex: 1 }}>
<Text style={s.noteTitle} numberOfLines={1}>{note.title || 'Sans titre'}</Text>
{note.notebookName && <Text style={s.noteMeta} numberOfLines={1}>{note.notebookName}</Text>}
</View>
<View style={s.noteRight}>
<Text style={s.noteDate}>{new Date(note.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}</Text>
<ChevronRight size={12} color={C.border} />
</View>
</TouchableOpacity>
))}
</View>
<View style={{ height: 32 }} />
<View style={{ height: 40 }} />
</ScrollView>
</SafeAreaView>
)
@@ -106,16 +130,21 @@ export default function HomeScreen() {
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: C.paper },
headerBlock: { paddingHorizontal: 20, paddingTop: 16, paddingBottom: 8 },
greeting: { fontSize: 22, fontWeight: '700', fontStyle: 'italic', color: C.ink },
date: { fontSize: 13, color: C.concrete, marginTop: 2 },
quickRow: { flexDirection: 'row', gap: 10, paddingHorizontal: 20, marginTop: 16 },
quickBtn: { flex: 1, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, padding: 14, alignItems: 'center', gap: 8 },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16 },
greeting: { fontSize: 22, fontWeight: '700', color: C.ink, letterSpacing: -0.3 },
date: { fontSize: 13, color: C.concrete, marginTop: 3, textTransform: 'capitalize' },
searchBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, alignItems: 'center', justifyContent: 'center' },
quickRow: { flexDirection: 'row', gap: 10, paddingHorizontal: 20, marginBottom: 24 },
quickCard: { flex: 1, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 14, padding: 14, alignItems: 'center', gap: 10 },
quickIcon: { width: 40, height: 40, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
quickLabel: { fontSize: 11, fontWeight: '600', color: C.ink, textAlign: 'center' },
recentBlock: { paddingHorizontal: 20, marginTop: 24 },
sectionLabel: { fontSize: 10, fontWeight: '800', letterSpacing: 2, textTransform: 'uppercase', color: C.concrete, marginBottom: 12 },
empty: { color: C.concrete, fontSize: 14 },
noteCard: { backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, padding: 16, marginBottom: 10 },
noteTitle: { fontSize: 15, fontWeight: '600', color: C.ink },
noteMeta: { fontSize: 11, color: C.concrete },
section: { marginHorizontal: 20, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, overflow: 'hidden' },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: C.border, backgroundColor: '#f8f6f2' },
sectionLabel: { fontSize: 11, fontWeight: '700', color: C.concrete, textTransform: 'uppercase', letterSpacing: 1 },
empty: { color: C.concrete, fontSize: 14, textAlign: 'center', padding: 24 },
noteRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 13, borderBottomWidth: 1, borderBottomColor: C.border },
noteTitle: { fontSize: 14, fontWeight: '500', color: C.ink },
noteMeta: { fontSize: 11, color: C.concrete, marginTop: 2 },
noteRight: { flexDirection: 'row', alignItems: 'center', gap: 4 },
noteDate: { fontSize: 11, color: C.concrete },
})

View File

@@ -5,18 +5,31 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronRight } from 'lucide-react-native'
import { ChevronRight, BookOpen, Folder } from 'lucide-react-native'
import { apiFetch } from '@/lib/api'
import { ENDPOINTS } from '@/lib/config'
import { C } from '../_layout'
import { C } from '@/lib/theme'
interface Notebook {
id: string
name: string
icon: string | null
color: string | null
_count: { notes: number }
}
// icônes Lucide stockées en string → afficher composant, sinon emoji
const LUCIDE_ICONS = new Set(['folder','book','archive','bookmark','file','note','inbox'])
function NotebookIcon({ icon, color }: { icon: string | null, color: string | null }) {
const tint = color || C.brand
if (!icon || LUCIDE_ICONS.has(icon.toLowerCase())) {
return <Folder size={18} color={tint} />
}
// emoji ou autre caractère unicode
return <Text style={{ fontSize: 18, lineHeight: 22 }}>{icon}</Text>
}
export default function NotebooksScreen() {
const [notebooks, setNotebooks] = useState<Notebook[]>([])
const [loading, setLoading] = useState(true)
@@ -49,7 +62,9 @@ export default function NotebooksScreen() {
return (
<SafeAreaView style={s.safe}>
<View style={s.header}>
<BookOpen size={18} color={C.brand} style={{ marginRight: 8 }} />
<Text style={s.title}>Carnets</Text>
<Text style={s.count}>{notebooks.length}</Text>
</View>
<FlatList
data={notebooks}
@@ -57,16 +72,23 @@ export default function NotebooksScreen() {
contentContainerStyle={s.list}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load() }} tintColor={C.brand} />}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => router.push(`/notebook/${item.id}`)} style={s.card}>
<Text style={s.icon}>{item.icon ?? '📓'}</Text>
<View style={{ flex: 1 }}>
<Text style={s.cardTitle}>{item.name}</Text>
<Text style={s.cardMeta}>{item._count?.notes ?? 0} notes</Text>
<TouchableOpacity onPress={() => router.push({ pathname: '/notebook/[id]', params: { id: item.id } })} style={s.card} activeOpacity={0.7}>
<View style={[s.iconWrap, { backgroundColor: (item.color || C.brand) + '18' }]}>
<NotebookIcon icon={item.icon} color={item.color} />
</View>
<ChevronRight size={16} color={C.concrete} />
<View style={{ flex: 1 }}>
<Text style={s.cardTitle} numberOfLines={1}>{item.name}</Text>
<Text style={s.cardMeta}>{item._count?.notes ?? 0} note{(item._count?.notes ?? 0) !== 1 ? 's' : ''}</Text>
</View>
<ChevronRight size={14} color={C.border} />
</TouchableOpacity>
)}
ListEmptyComponent={<Text style={s.empty}>Aucun carnet.</Text>}
ListEmptyComponent={
<View style={s.emptyWrap}>
<BookOpen size={32} color={C.border} />
<Text style={s.empty}>Aucun carnet</Text>
</View>
}
/>
</SafeAreaView>
)
@@ -74,12 +96,14 @@ export default function NotebooksScreen() {
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: C.paper },
header: { paddingHorizontal: 20, paddingTop: 16, paddingBottom: 8 },
title: { fontSize: 22, fontStyle: 'italic', fontWeight: '700', color: C.ink },
list: { paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 },
card: { flexDirection: 'row', alignItems: 'center', backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, padding: 16, marginBottom: 10 },
icon: { fontSize: 24, marginRight: 12 },
cardTitle: { fontSize: 15, fontWeight: '600', color: C.ink },
cardMeta: { fontSize: 12, color: C.concrete, marginTop: 2 },
empty: { textAlign: 'center', color: C.concrete, marginTop: 48 },
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' },
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' },
cardTitle: { fontSize: 14, fontWeight: '600', color: C.ink, marginBottom: 2 },
cardMeta: { fontSize: 12, color: C.concrete },
emptyWrap: { alignItems: 'center', marginTop: 60, gap: 12 },
empty: { color: C.concrete, fontSize: 14 },
})

View File

@@ -2,7 +2,7 @@ import { View, Text, TouchableOpacity, ScrollView, Alert, StyleSheet } from 'rea
import { SafeAreaView } from 'react-native-safe-area-context'
import { LogOut, CreditCard, Globe } from 'lucide-react-native'
import { useAuthStore } from '@/lib/store'
import { C } from '../_layout'
import { C } from '@/lib/theme'
const TIER_LABELS: Record<string, string> = {
FREE: 'Gratuit',

View File

@@ -8,7 +8,7 @@ import { Search as SearchIcon, X } from 'lucide-react-native'
import { useRouter } from 'expo-router'
import { apiFetch } from '@/lib/api'
import { ENDPOINTS } from '@/lib/config'
import { C } from '../_layout'
import { C } from '@/lib/theme'
interface SearchResult {
id: string
@@ -67,7 +67,7 @@ export default function SearchScreen() {
keyExtractor={(item) => item.id}
contentContainerStyle={s.list}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => router.push(`/note/${item.id}`)} style={s.card}>
<TouchableOpacity onPress={() => router.push({ pathname: '/note/[id]', params: { id: item.id } })} style={s.card}>
<Text style={s.cardTitle} numberOfLines={1}>{item.title || 'Sans titre'}</Text>
{item.snippet && <Text style={s.snippet} numberOfLines={2}>{item.snippet}</Text>}
{item.notebookName && <Text style={s.nb}>{item.notebookName}</Text>}

View File

@@ -4,6 +4,10 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'
import { StatusBar } from 'expo-status-bar'
import { View, ActivityIndicator, StyleSheet } from 'react-native'
import { useAuthStore } from '@/lib/store'
import { C } from '@/lib/theme'
// Ré-exporter C pour la compatibilité avec les anciens imports
export { C } from '@/lib/theme'
export default function RootLayout() {
const { user, loading, restore } = useAuthStore()
@@ -35,18 +39,6 @@ export default function RootLayout() {
)
}
export const C = {
brand: '#A47148',
ink: '#1A1A18',
paper: '#FAFAF8',
concrete: '#8A8A82',
border: '#E8E6E0',
white: '#FFFFFF',
rose: '#e11d48',
roseBg: '#fff1f2',
roseBorder: '#fecdd3',
}
const s = StyleSheet.create({
loader: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: C.paper },
})

View File

@@ -9,7 +9,7 @@ import { ArrowLeft, Share2 } from 'lucide-react-native'
import { WebView } from 'react-native-webview'
import { apiFetch } from '@/lib/api'
import { ENDPOINTS } from '@/lib/config'
import { C } from '../_layout'
import { C } from '@/lib/theme'
interface Note {
id: string
@@ -19,31 +19,124 @@ interface Note {
notebookName?: string
}
// Le markdown est parsé côté WebView (JS natif) — évite tout problème Metro bundler
function buildHtml(content: string, title: string) {
// On passe le contenu comme JSON pour éviter les problèmes d'échappement
const safeContent = JSON.stringify(content)
const safeTitle = JSON.stringify(title || 'Sans titre')
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=2">
<style>
:root {
--brand: #A47148;
--ink: #1A1A18;
--paper: #FAFAF8;
--concrete: #8A8A82;
--border: #E8E6E0;
--code-bg: #f0ede8;
--dark-bg: #1e1e1c;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, sans-serif; font-size: 16px; line-height: 1.7;
color: #1A1A18; background: #FAFAF8; padding: 0 16px 32px; }
h1 { font-size: 22px; font-weight: 700; margin: 20px 0 12px; }
h2 { font-size: 18px; font-weight: 700; margin: 16px 0 10px; }
h3 { font-size: 16px; font-weight: 600; margin: 14px 0 8px; }
p { margin: 0 0 12px; }
ul, ol { padding-left: 20px; margin: 0 0 12px; }
li { margin-bottom: 4px; }
blockquote { border-left: 3px solid #A47148; padding-left: 12px; color: #666; margin: 12px 0; }
code { background: #f0ede8; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
pre { background: #f0ede8; padding: 12px; border-radius: 8px; overflow: auto; margin: 12px 0; }
a { color: #A47148; }
img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
body {
font-family: -apple-system, 'Helvetica Neue', sans-serif;
font-size: 16px; line-height: 1.75; color: var(--ink);
background: var(--paper); padding: 8px 20px 64px;
word-break: break-word;
}
.note-title { font-size: 26px; font-weight: 800; letter-spacing: -0.5px; color: var(--ink); margin: 20px 0 4px; line-height: 1.25; }
.note-meta { font-size: 12px; color: var(--concrete); margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
h1 { font-size: 22px; font-weight: 700; margin: 28px 0 10px; }
h2 { font-size: 19px; font-weight: 700; margin: 24px 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
h3 { font-size: 16px; font-weight: 600; margin: 20px 0 6px; }
h4, h5, h6 { font-size: 15px; font-weight: 600; margin: 14px 0 4px; color: var(--concrete); }
p { margin: 0 0 14px; }
ul, ol { padding-left: 24px; margin: 0 0 14px; }
li { margin-bottom: 6px; }
li::marker { color: var(--brand); }
blockquote { border-left: 3px solid var(--brand); margin: 16px 0; padding: 10px 14px; background: #f7f2ec; border-radius: 0 10px 10px 0; color: #5a5a52; font-style: italic; }
blockquote p { margin: 0; }
code { background: var(--code-bg); padding: 2px 7px; border-radius: 6px; font-size: 13px; font-family: 'Menlo', 'Courier New', monospace; color: var(--brand); }
pre { background: var(--dark-bg); padding: 16px; border-radius: 12px; overflow-x: auto; margin: 16px 0; }
pre code { background: none; padding: 0; color: #e8e6e0; font-size: 13px; line-height: 1.6; }
a { color: var(--brand); text-decoration: none; border-bottom: 1px solid #d4b896; }
img { max-width: 100%; border-radius: 12px; margin: 12px 0; display: block; }
hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
th { background: var(--code-bg); font-weight: 700; padding: 10px 12px; text-align: left; border-bottom: 2px solid var(--border); }
td { padding: 9px 12px; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
strong { font-weight: 700; }
em { font-style: italic; }
del { text-decoration: line-through; color: var(--concrete); }
</style>
</head>
<body>${content}</body>
<body>
<div class="note-title" id="title"></div>
<div class="note-meta" id="meta"></div>
<div id="content"></div>
<script>
(function() {
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
}
function inline(s) {
return s
.replace(/\*\*\*(.+?)\*\*\*/g,'<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/__(.+?)__/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/_(.+?)_/g,'<em>$1</em>')
.replace(/~~(.+?)~~/g,'<del>$1</del>')
.replace(/\`([^\`]+)\`/g,'<code>'+esc('$1')+'</code>')
.replace(/\[(.+?)\]\((.+?)\)/g,'<a href="$2">$1</a>')
}
function mdToHtml(md) {
var lines = md.split('\n')
var html = ''
var inCode = false, codeLang = '', codeLines = []
var inList = false, listType = '', listItems = []
function flushList() {
if (!inList) return
var tag = listType === 'ul' ? 'ul' : 'ol'
html += '<'+tag+'>' + listItems.map(function(l){return '<li>'+inline(l)+'</li>'}).join('') + '</'+tag+'>'
inList = false; listItems = []
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
if (/^\`\`\`/.test(line)) {
if (!inCode) { flushList(); inCode=true; codeLang=line.slice(3).trim(); codeLines=[]; continue }
else { inCode=false; html+='<pre><code>'+esc(codeLines.join('\n'))+'</code></pre>'; continue }
}
if (inCode) { codeLines.push(line); continue }
if (/^> /.test(line)) { flushList(); html+='<blockquote><p>'+inline(line.slice(2))+'</p></blockquote>'; continue }
if (/^#{6} /.test(line)) { flushList(); html+='<h6>'+inline(line.slice(7))+'</h6>'; continue }
if (/^#{5} /.test(line)) { flushList(); html+='<h5>'+inline(line.slice(6))+'</h5>'; continue }
if (/^#{4} /.test(line)) { flushList(); html+='<h4>'+inline(line.slice(5))+'</h4>'; continue }
if (/^### /.test(line)) { flushList(); html+='<h3>'+inline(line.slice(4))+'</h3>'; continue }
if (/^## /.test(line)) { flushList(); html+='<h2>'+inline(line.slice(3))+'</h2>'; continue }
if (/^# /.test(line)) { flushList(); html+='<h1>'+inline(line.slice(2))+'</h1>'; continue }
if (/^---+$/.test(line.trim()) || /^\*\*\*+$/.test(line.trim())) { flushList(); html+='<hr>'; continue }
var ulM = line.match(/^[-*+] (.*)/)
if (ulM) { if (inList&&listType!=='ul') flushList(); inList=true; listType='ul'; listItems.push(ulM[1]); continue }
var olM = line.match(/^\d+\. (.*)/)
if (olM) { if (inList&&listType!=='ol') flushList(); inList=true; listType='ol'; listItems.push(olM[1]); continue }
if (line.trim()==='') { flushList(); if (html && !html.endsWith('<br>')) html+='<br>'; continue }
flushList()
html += '<p>'+inline(line)+'</p>'
}
flushList()
return html
}
var md = ${safeContent}
var title = ${safeTitle}
document.getElementById('title').textContent = title
document.getElementById('content').innerHTML = mdToHtml(md)
})()
</script>
</body>
</html>`
}
@@ -80,7 +173,13 @@ export default function NoteScreen() {
{loading
? <View style={s.center}><ActivityIndicator color={C.brand} size="large" /></View>
: note
? <WebView source={{ html: buildHtml(note.content, note.title) }} style={{ flex: 1, backgroundColor: C.paper }} scrollEnabled showsVerticalScrollIndicator={false} />
? <WebView
source={{ html: buildHtml(note.content, note.title) }}
style={{ flex: 1, backgroundColor: C.paper }}
scrollEnabled
showsVerticalScrollIndicator={false}
originWhitelist={['*']}
/>
: <View style={s.center}><Text style={{ color: C.concrete }}>Note introuvable.</Text></View>}
</SafeAreaView>
)

View File

@@ -7,7 +7,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import { ArrowLeft } from 'lucide-react-native'
import { apiFetch } from '@/lib/api'
import { ENDPOINTS } from '@/lib/config'
import { C } from '../_layout'
import { C } from '@/lib/theme'
interface Note {
id: string
@@ -56,7 +56,7 @@ export default function NotebookScreen() {
contentContainerStyle={s.list}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load() }} tintColor={C.brand} />}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => router.push(`/note/${item.id}`)} style={s.card}>
<TouchableOpacity onPress={() => router.push({ pathname: '/note/[id]', params: { id: item.id } })} style={s.card}>
<Text style={s.cardTitle} numberOfLines={1}>{item.title || 'Sans titre'}</Text>
<Text style={s.cardDate}>
{new Date(item.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}

View File

@@ -1,6 +1,6 @@
// API base URL — change for dev/prod
export const API_URL = __DEV__
? 'http://192.168.1.190:3000' // local network dev server
? 'http://192.168.1.83:3000' // serveur de dev local
: 'https://memento-note.com'
export const ENDPOINTS = {

View File

@@ -13,6 +13,7 @@ interface AuthState {
user: User | null
loading: boolean
login: (email: string, password: string) => Promise<void>
loginWithToken: (token: string, user: User) => Promise<void>
logout: () => Promise<void>
restore: () => Promise<void>
}
@@ -28,7 +29,7 @@ export const useAuthStore = create<AuthState>((set) => ({
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const data = await res.json()
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Identifiants invalides')
}
const { token, user } = await res.json()
@@ -36,6 +37,11 @@ export const useAuthStore = create<AuthState>((set) => ({
set({ user })
},
loginWithToken: async (token, user) => {
await setToken(token)
set({ user })
},
logout: async () => {
await clearToken()
set({ user: null })

View File

@@ -0,0 +1,12 @@
// Design tokens partagés — ne pas importer depuis _layout pour éviter les circularités
export const C = {
brand: '#A47148',
ink: '#1A1A18',
paper: '#FAFAF8',
concrete: '#8A8A82',
border: '#E8E6E0',
white: '#FFFFFF',
rose: '#e11d48',
roseBg: '#fff1f2',
roseBorder: '#fecdd3',
}

View File

@@ -18,6 +18,7 @@
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"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",
@@ -6225,6 +6226,18 @@
"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",

View File

@@ -10,23 +10,24 @@
"build:ios": "eas build --platform ios"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~54.0.35",
"expo-router": "~6.0.24",
"expo-status-bar": "~3.0.9",
"expo-secure-store": "~15.0.8",
"expo-font": "~14.0.12",
"expo-splash-screen": "~31.0.13",
"expo-linking": "~8.0.12",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.12",
"expo-linking": "~8.0.12",
"expo-router": "~6.0.24",
"expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"lucide-react-native": "^0.477.0",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.0",
"react-native-webview": "13.15.0",
"@react-native-async-storage/async-storage": "2.2.0",
"zustand": "^5.0.2",
"lucide-react-native": "^0.477.0",
"react-native-svg": "15.12.0"
"expo-web-browser": "~14.1.6"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -34,4 +35,3 @@
"typescript": "~5.9.2"
}
}

View File

@@ -3,8 +3,16 @@
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"]
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts",
"expo-env.d.ts",
".expo/types/**/*.ts"
]
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { createMobileToken } from '@/lib/mobile-auth'
// Appelé après le flux Google OAuth — génère un token mobile et redirige vers l'app native
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.email) {
return NextResponse.redirect(`memento://auth?error=unauthorized`)
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, name: true, email: true, subscription: { select: { tier: true } } },
})
if (!user) {
return NextResponse.redirect(`memento://auth?error=not_found`)
}
const token = createMobileToken(user.id)
const params = new URLSearchParams({
token,
id: user.id,
name: user.name ?? '',
email: user.email ?? '',
tier: user.subscription?.tier ?? 'FREE',
})
return NextResponse.redirect(`memento://auth?${params.toString()}`)
} catch (e) {
console.error('[mobile/auth/google-callback]', e)
return NextResponse.redirect(`memento://auth?error=server_error`)
}
}

View File

@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
// Lance le flux Google OAuth et redirige vers le callback mobile
export async function GET(req: NextRequest) {
const baseUrl = process.env.NEXTAUTH_URL || 'https://memento-note.com'
const callbackUrl = `${baseUrl}/api/mobile/auth/google-callback`
const signinUrl = `${baseUrl}/api/auth/signin/google?callbackUrl=${encodeURIComponent(callbackUrl)}`
return NextResponse.redirect(signinUrl)
}