UI Stabilization: Global color theme updates (#75B2D6), AI Assistant styling refactor, and navigation fixes

This commit is contained in:
Antigravity
2026-05-09 12:58:16 +00:00
parent 1446463f04
commit 60a3fe5453
47 changed files with 3585 additions and 2149 deletions

View File

@@ -12,6 +12,7 @@ interface GeneralSettingsClientProps {
preferredLanguage: string
emailNotifications: boolean
desktopNotifications: boolean
autoSave: boolean
}
}
@@ -21,6 +22,7 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
const [autoSave, setAutoSave] = useState(initialSettings.autoSave ?? true)
const handleLanguageChange = async (value: string) => {
setLanguage(value)
@@ -47,6 +49,12 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
await updateAISettings({ desktopNotifications: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleAutoSaveChange = async (enabled: boolean) => {
setAutoSave(enabled)
await updateAISettings({ autoSave: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
return (
<div className="space-y-8">
@@ -140,6 +148,22 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div className="border-t border-border pt-5 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{t('settings.autoSave') || 'Auto-Save'}</p>
<p className="text-xs text-muted-foreground">{t('settings.autoSaveDesc') || 'Sauvegarder automatiquement les modifications'}</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoSave}
onClick={() => handleAutoSaveChange(!autoSave)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${autoSave ? 'bg-primary' : 'bg-muted-foreground/30'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSave ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
</div>
</div>

View File

@@ -8,7 +8,7 @@ export default function SettingsLayout({
children: React.ReactNode
}) {
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full bg-[#F2F0E9]">
{/* Architectural header — matches Agents page */}
<header className="flex flex-col px-12 pt-10 pb-0 border-b border-border/40 shrink-0">
<div className="flex items-end justify-between mb-6">

View File

@@ -24,6 +24,7 @@ export type UserAISettingsData = {
noteHistory?: boolean
noteHistoryMode?: 'manual' | 'auto'
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
autoSave?: boolean
}
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
@@ -47,6 +48,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
'noteHistory',
'noteHistoryMode',
'fontFamily',
'autoSave',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
@@ -158,6 +160,7 @@ const getCachedAISettings = unstable_cache(
noteHistory: false,
noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const,
autoSave: true,
}
}
@@ -192,6 +195,7 @@ const getCachedAISettings = unstable_cache(
noteHistory: settings.noteHistory ?? false,
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
autoSave: settings.autoSave ?? true,
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -217,6 +221,7 @@ const getCachedAISettings = unstable_cache(
noteHistory: false,
noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const,
autoSave: true,
}
}
},

View File

@@ -214,7 +214,7 @@ async function syncNoteLabels(noteId: string, labelNames: string[], notebookId:
if (Array.isArray(parsed)) {
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
}
} catch {}
} catch { }
}
}
// Delete labels not in use
@@ -680,91 +680,91 @@ export async function createNote(data: {
const notebookId = data.notebookId
const hasUserLabels = data.labels && data.labels.length > 0
// Use setImmediate-like pattern to not block the response
;(async () => {
try {
// Background task 1: Generate embedding
const bgConfig = await getSystemConfig()
const provider = getAIProvider(bgConfig)
const embedding = await provider.getEmbeddings(content)
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
}
} catch (e) {
console.error('[BG] Embedding generation failed:', e)
}
// Background task 2: Auto-labeling (only if no user labels and has notebook)
if (!hasUserLabels && notebookId) {
// Use setImmediate-like pattern to not block the response
; (async () => {
try {
const userAISettings = await getAISettings(userId)
const autoLabelingEnabled = userAISettings.autoLabeling !== false
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
// Background task 1: Generate embedding
const bgConfig = await getSystemConfig()
const provider = getAIProvider(bgConfig)
const embedding = await provider.getEmbeddings(content)
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
}
} catch (e) {
console.error('[BG] Embedding generation failed:', e)
}
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
// Background task 2: Auto-labeling (only if no user labels and has notebook)
if (!hasUserLabels && notebookId) {
try {
const userAISettings = await getAISettings(userId)
const autoLabelingEnabled = userAISettings.autoLabeling !== false
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
if (autoLabelingEnabled) {
// Detect user's language from their existing notes for localized prompts
let userLang = 'en'
try {
const langResult = await prisma.note.groupBy({
by: ['language'],
where: { userId, language: { not: null } },
_count: true,
orderBy: { _count: { language: 'desc' } },
take: 1,
})
if (langResult.length > 0 && langResult[0].language) {
userLang = langResult[0].language
}
} catch {}
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
const suggestions = await contextualAutoTagService.suggestLabels(
content,
notebookId,
userId,
userLang
)
if (autoLabelingEnabled) {
// Detect user's language from their existing notes for localized prompts
let userLang = 'en'
try {
const langResult = await prisma.note.groupBy({
by: ['language'],
where: { userId, language: { not: null } },
_count: true,
orderBy: { _count: { language: 'desc' } },
take: 1,
})
if (langResult.length > 0 && langResult[0].language) {
userLang = langResult[0].language
}
} catch { }
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
const suggestions = await contextualAutoTagService.suggestLabels(
content,
notebookId,
userId,
userLang
)
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label)
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
if (appliedLabels.length > 0) {
// Merge with existing labels
const existing = await prisma.note.findUnique({
where: { id: noteId },
select: { labels: true },
})
let existingNames: string[] = []
if (existing?.labels) {
try {
const parsed = existing.labels as unknown
existingNames = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
: []
} catch { existingNames = [] }
}
const merged = [...new Set([...existingNames, ...appliedLabels])]
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
if (!data.skipRevalidation) {
revalidatePath('/')
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label)
if (appliedLabels.length > 0) {
// Merge with existing labels
const existing = await prisma.note.findUnique({
where: { id: noteId },
select: { labels: true },
})
let existingNames: string[] = []
if (existing?.labels) {
try {
const parsed = existing.labels as unknown
existingNames = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
: []
} catch { existingNames = [] }
}
const merged = [...new Set([...existingNames, ...appliedLabels])]
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
if (!data.skipRevalidation) {
revalidatePath('/')
}
}
}
} catch (error) {
console.error('[BG] Auto-labeling failed:', error)
}
} catch (error) {
console.error('[BG] Auto-labeling failed:', error)
} else {
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
}
} else {
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
}
})()
})()
return parseNote(note)
} catch (error) {
@@ -819,21 +819,21 @@ export async function updateNote(id: string, data: {
if (data.content !== undefined) {
const noteId = id
const content = data.content
;(async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
; (async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
}
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
}
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
}
})()
})()
}
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null

View File

@@ -30,11 +30,12 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style } = body as {
const { noteId, type, theme, style, language } = body as {
noteId: string
type: GenerateType
theme?: string
style?: string
language?: string
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
@@ -50,15 +51,26 @@ export async function POST(req: NextRequest) {
}
const defaults = TYPE_DEFAULTS[type]
const isEn = language === 'English'
let role = defaults.role
if (isEn) {
if (type === 'slide-generator') {
role = 'Create a professional and visual PowerPoint presentation from the provided note content.'
} else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
}
}
const agentName = type === 'slide-generator'
? `Slides${(note.title || 'Note').substring(0, 40)}`
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}`
? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}`
: `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}`
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role: defaults.role,
role,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',

View File

@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { existingContent, resourceText, mode, language } = await request.json()
const { existingContent, resourceText, mode, language, format } = await request.json()
if (!resourceText || typeof resourceText !== 'string') {
return NextResponse.json({ error: 'resourceText is required' }, { status: 400 })
@@ -20,6 +20,7 @@ export async function POST(request: NextRequest) {
}
const lang = language || 'fr'
const outputFormat = format === 'html' ? 'HTML (with proper tags like <h2>, <p>, <ul>, <li>)' : 'Markdown (with ##, -, **, etc.)'
const config = await getSystemConfig()
const provider = getTagsProvider(config)
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
FORMAT RULE: Respond in ${outputFormat}.
EXISTING NOTE:
---
@@ -46,13 +48,14 @@ INSTRUCTIONS:
- Append ONLY new, non-redundant information from the resource below the existing content
- Use a clear separator (e.g., "---" or a new section heading) between existing and new content
- Skip information already covered in the existing note
- Format the new content consistently with the existing note style
- Format the new content consistently with the existing note style and the requested FORMAT RULE
- Respond ONLY with the enriched note content, no explanations`
} else {
// Merge: intelligently rewrite integrating both sources
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
FORMAT RULE: Respond in ${outputFormat}.
EXISTING NOTE:
---
@@ -69,7 +72,7 @@ INSTRUCTIONS:
- Eliminate redundancy — include each piece of information only once
- Preserve the key ideas from both sources
- Maintain a logical structure with clear headings if appropriate
- Keep the tone and style consistent
- Keep the tone and style consistent with the requested FORMAT RULE
- Respond ONLY with the merged content, no meta-commentary or explanations`
}

View File

@@ -6,7 +6,6 @@ import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { stepCountIs } from 'ai'
import { readFile } from 'fs/promises'
import path from 'path'
@@ -47,36 +46,32 @@ export async function POST(req: Request) {
}
const userId = session.user.id
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
// 2. Parse request body
const body = await req.json()
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext } = body as {
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext, format } = body as {
messages: UIMessage[]
conversationId?: string
notebookId?: string
language?: string
webSearch?: boolean
noteContext?: { title: string; content: string; tone: string; images?: string[] }
format?: 'html' | 'markdown'
}
// Convert UIMessages to CoreMessages for streamText
const incomingMessages = toCoreMessages(rawMessages)
// 3. Manage conversation (create or fetch)
// 3. Manage conversation
let conversation: { id: string; messages: Array<{ role: string; content: string }> }
if (conversationId) {
const existing = await prisma.conversation.findUnique({
where: { id: conversationId, userId },
include: { messages: { orderBy: { createdAt: 'asc' } } },
})
if (!existing) {
return new Response('Conversation not found', { status: 404 })
}
if (!existing) return new Response('Conversation not found', { status: 404 })
conversation = existing
} else {
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
const created = await prisma.conversation.create({
conversation = await prisma.conversation.create({
data: {
userId,
notebookId: notebookId || null,
@@ -84,33 +79,21 @@ export async function POST(req: Request) {
},
include: { messages: true },
})
conversation = created
}
// 4. RAG retrieval
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
// Load translations for the requested language
const lang = (language || 'en') as SupportedLanguage
const translations = await loadTranslations(lang)
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
// If a notebook is selected, fetch its recent notes directly as context
// This ensures the AI always has access to the notebook content,
// even for vague queries like "what's in this notebook?"
let notebookContext = ''
let searchNotes = ''
// When scope is "this note" (noteContext present), skip RAG retrieval entirely
// The note content is already injected as copilotContext below
if (!noteContext) {
if (notebookId) {
const notebookNotes = await prisma.note.findMany({
where: {
notebookId,
userId,
trashedAt: null,
},
where: { notebookId, userId, trashedAt: null },
orderBy: { updatedAt: 'desc' },
take: 20,
select: { id: true, title: true, content: true, updatedAt: true },
@@ -122,7 +105,6 @@ export async function POST(req: Request) {
}
}
// Also run semantic search for the specific query
let searchResults: any[] = []
try {
searchResults = await semanticSearchService.search(currentMessage, {
@@ -131,21 +113,16 @@ export async function POST(req: Request) {
threshold: notebookId ? 0.3 : 0.5,
defaultTitle: untitledText,
})
} catch {
// Search failure should not block chat
}
} catch {}
searchNotes = searchResults
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
.join('\n\n---\n\n')
}
// Combine: full notebook context + semantic search results (deduplicated)
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
// 5. System prompt synthesis with RAG context
// Language-aware prompts to avoid forcing French responses
// Note: lang is already declared above when loading translations
// 5. System prompt synthesis
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
en: {
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
@@ -153,14 +130,24 @@ export async function POST(req: Request) {
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
## Format rules
- Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.
- ${format === 'html' ? `Respond MANDATORILY using valid HTML fragments (e.g., <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
- Do NOT use Markdown symbols (no #, *, -, etc.).
- Do not wrap your HTML code in a Markdown code block.` : 'Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.'}
- Structure your response with sections for technical questions or complex topics.
- For simple, short questions, a direct paragraph is enough.
- For simple, short questions, a direct paragraph is enough.` + (format === 'html' ? `
## HTML OUTPUT EXAMPLE
<h3>Section Title</h3>
<p>Here is an explanation with <strong>bold text</strong> and a list:</p>
<ul>
<li>First important point</li>
<li>Second important point</li>
</ul>` : '') + `
## Tone rules
- Natural tone, neither corporate nor too casual.
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly.
- No upsell questions at the end ("Would you like me to...", "Do you want..."). If you have useful additional info, just give it.
- No unnecessary intro phrases. Answer directly.
- No upsell questions at the end. If you have useful additional info, just give it.
- If the user says "Momento" they mean Momento (this app).
## About Momento
@@ -170,171 +157,90 @@ Momento is an intelligent note-taking application. Key features include:
- **Search**: Advanced semantic search to find notes by meaning, not just keywords, and Web Search integration.
- **Agents**: Create specialized AI Agents with custom system prompts for specific recurring tasks.
- **Lab**: Experimental AI tools for data analysis and deeper insights.
If the user asks how to use this tool, explain these features simply and helpfully.
## Available tools
You have access to these tools for deeper research:
- **note_search**: Search the user's notes by keyword or meaning. Use when the initial context above is insufficient or when the user asks about specific content in their notes. If a notebook is selected, pass its ID to restrict results.
- **note_read**: Read a specific note by ID. Use when note_search returns a note you need the full content of.
- **web_search**: Search the web for information. Use when the user asks about something not in their notes.
- **web_scrape**: Scrape a web page and return its content as markdown. Use when web_search returns a URL you need to read.
## Tool usage rules
- You already have context from the user's notes above. Only use tools if you need more specific or additional information.
- Never invent note IDs, URLs, or notebook IDs. Use the IDs provided in the context or from tool results.
- For simple conversational questions (greetings, opinions, general knowledge), answer directly without using any tools.`,
You have access to: note_search, note_read, web_search, web_scrape.
Only use tools if you need more information. Never invent note IDs or URLs.`,
},
fr: {
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule. Si les notes ne couvrent pas le sujet, dis-le et complète avec tes connaissances générales.`,
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule.`,
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
## Règles de format
- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables — tout ce qui rend la réponse lisible.
- ${format === 'html' ? `Réponds OBLIGATOIREMENT en utilisant des fragments HTML valides (ex: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
- N'utilise PAS de symboles Markdown.
- Ne mets pas ton code HTML dans un bloc de code Markdown.` : '- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables.'}
- Structure ta réponse avec des sections quand c'est une question technique ou un sujet complexe.
- Pour les questions simples et courtes, un paragraphe direct suffit.
- Pour les questions simples et courtes, un paragraphe direct suffit.` + (format === 'html' ? `
## EXEMPLE DE SORTIE HTML
<h3>Titre de section</h3>
<p>Voici une explication avec du <strong>texte en gras</strong> et une liste :</p>
<ul>
<li>Premier point important</li>
<li>Deuxième point important</li>
</ul>` : '') + `
## Règles de ton
- Ton naturel, ni corporate ni trop familier.
- Pas de phrase d'intro inutile ("Voici ce que j'ai trouvé", "Basé sur vos notes"). Réponds directement.
- Pas de question upsell à la fin ("Souhaitez-vous que je...", "Acceptez-vous que..."). Si tu as une info complémentaire utile, donne-la.
- Ton naturel, direct, sans phrases d'intro inutiles.
- Pas de question upsell à la fin.
- Si l'utilisateur dit "Momento" il parle de Momento (cette application).
## À propos de Momento
Momento est une application de prise de notes intelligente. Ses fonctionnalités principales :
- **Éditeur de notes** : Prise de notes en Markdown riche avec un Copilot IA intégré pour réécrire, résumer ou traduire du texte.
- **Organisation** : Regroupement des notes dans des Carnets (Notebooks) et utilisation d'Étiquettes (Labels).
- **Recherche** : Recherche sémantique avancée pour trouver des notes par le sens, et recherche Web intégrée.
- **Agents** : Création d'Agents IA spécialisés avec des instructions personnalisées pour des tâches récurrentes.
- **Lab** : Outils IA expérimentaux pour l'analyse de données et les insights.
Si l'utilisateur demande comment utiliser cet outil, explique ces fonctionnalités simplement et avec bienveillance.
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
## Outils disponibles
Tu as accès à ces outils pour des recherches approfondies :
- **note_search** : Cherche dans les notes de l'utilisateur par mot-clé ou sens. Utilise quand le contexte initial ci-dessus est insuffisant ou quand l'utilisateur demande du contenu spécifique dans ses notes. Si un carnet est sélectionné, passe son ID pour restreindre les résultats.
- **note_read** : Lit une note spécifique par son ID. Utilise quand note_search retourne une note dont tu as besoin du contenu complet.
- **web_search** : Recherche sur le web. Utilise quand l'utilisateur demande quelque chose qui n'est pas dans ses notes.
- **web_scrape** : Scrape une page web et retourne son contenu en markdown. Utilise quand web_search retourne une URL que tu veux lire.
## Règles d'utilisation des outils
- Tu as déjà du contexte des notes de l'utilisateur ci-dessus. Utilise les outils seulement si tu as besoin d'informations plus spécifiques.
- N'invente jamais d'IDs de notes, d'URLs ou d'IDs de carnet. Utilise les IDs fournis dans le contexte ou les résultats d'outils.
- Pour les questions conversationnelles simples (salutations, opinions, connaissances générales), réponds directement sans utiliser d'outils.`,
Tu as accès à : note_search, note_read, web_search, web_scrape.`,
},
fa: {
contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشت‌ها موضوع را پوشش نمی‌دهند، بگویید و با دانش عمومی خود تکمیل کنید.`,
contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژه‌ها، مستندات فنی و یادداشت‌هایش سؤال می‌کند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
## قوانین قالب‌بندی
- از مارک‌داون آزادانه استفاده کنید: عناوین (##, ###)، لیست‌ها، بلوک‌های کد، پررنگ، جداول.
- ${format === 'html' ? `حتماً از تگ‌های HTML معتبر استفاده کنید (مانند <p>, <strong>, <em>, <ul>, <li>, <h3>).
- از نمادهای مارک‌داون استفاده نکنید.` : 'از مارک‌داون آزادانه استفاده کنید: عناوین (##, ###)، لیست‌ها، بلوک‌های کد، پررنگ، جداول.'}
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخش‌بندی کنید.
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.` + (format === 'html' ? `
## نمونه خروجی HTML
<h3>عنوان بخش</h3>
<p>این یک توضیح با <strong>متن برجسته</strong> و یک لیست است:</p>
<ul>
<li>نکته اول</li>
<li>نکته دوم</li>
</ul>` : '') + `
## قوانین لحن
- لحن طبیعی، نه رسمی بیش از حد و نه خیلی غیررسمی.
- بدون جمله مقدمه اضافی. مستقیم پاسخ دهید.
- بدون سؤال فروشی در انتها. اگر اطلاعات تکمیلی مفید دارید، مستقیم بدهید.
- اگر کاربر "Momento" می‌گوید، منظورش Memento (این برنامه) است.
## ابزارهای موجود
- **note_search**: جستجو در یادداشت‌های کاربر با کلیدواژه یا معنی. زمانی استفاده کنید که زمینه اولیه کافی نباشد. اگر دفترچه انتخاب شده، شناسه آن را ارسال کنید.
- **note_read**: خواندن یک یادداشت خاص با شناسه. زمانی استفاده کنید که note_search یادداشتی برگرداند که محتوای کامل آن را نیاز دارید.
- **web_search**: جستجو در وب. زمانی استفاده کنید که کاربر درباره چیزی خارج از یادداشت‌هایش می‌پرسد.
- **web_scrape**: استخراج محتوای صفحه وب. زمانی استفاده کنید که web_search نشانی‌ای برگرداند که می‌خواهید بخوانید.
## قوانین استفاده از ابزارها
- شما از قبل زمینه‌ای از یادداشت‌های کاربر دارید. فقط در صورت نیاز به اطلاعات بیشتر از ابزارها استفاده کنید.
- هرگز شناسه یادداشت، نشانی یا شناسه دفترچه نسازید. از شناسه‌های موجود در زمینه یا نتایج ابزار استفاده کنید.
- برای سؤالات مکالمه‌ای ساده (سلام، نظرات، دانش عمومی)، مستقیم پاسخ دهید.`,
- لحن طبیعی، مستقیم، بدون مقدمه اضافی.
- اگر کاربر "Momento" می‌گوید، منظورش Memento (این برنامه) است.`,
},
es: {
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis. No copies palabra por palabra — reformula. Si las notas no cubren el tema, dilo y complementa con tu conocimiento general.`,
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis.`,
contextNoNotes: "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas. Debes responder de forma estructurada y útil.
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas.
## Reglas de formato
- Usa markdown libremente: títulos (##, ###), listas, bloques de código, negritas, tablas.
- Estructura tu respuesta con secciones para preguntas técnicas o temas complejos.
- Para preguntas simples y cortas, un párrafo directo es suficiente.
- ${format === 'html' ? `Responde OBLIGATORIAMENTE usando fragmentos HTML válidos (ej: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
- NO uses símbolos Markdown.` : 'Usa markdown libremente: títulos (##, ###), listas, negritas, tablas.'}
- Estructura tu respuesta con secciones para temas complejos.
- Para preguntas simples, un párrafo directo es suficiente.` + (format === 'html' ? `
## Reglas de tono
- Tono natural, ni corporativo ni demasiado informal.
- Sin frases de introducción innecesarias. Responde directamente.
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente.
## Herramientas disponibles
- **note_search**: Busca en las notas del usuario por palabra clave o significado. Úsalo cuando el contexto inicial sea insuficiente. Si hay una libreta seleccionada, pasa su ID para restringir los resultados.
- **note_read**: Lee una nota específica por su ID. Úsalo cuando note_search devuelva una nota cuyo contenido completo necesites.
- **web_search**: Busca en la web. Úsalo cuando el usuario pregunte sobre algo que no está en sus notas.
- **web_scrape**: Extrae el contenido de una página web como markdown. Úsalo cuando web_search devuelva una URL que quieras leer.
## Reglas de uso de herramientas
- Ya tienes contexto de las notas del usuario arriba. Solo usa herramientas si necesitas información más específica.
- Nunca inventes IDs de notas, URLs o IDs de libreta. Usa los IDs proporcionados en el contexto o en los resultados de herramientas.
- Para preguntas conversacionales simples (saludos, opiniones, conocimiento general), responde directamente sin herramientas.`,
},
de: {
contextWithNotes: `## Notizen des Benutzers\n\n${contextNotes}\n\nWenn du Infos aus den obigen Notizen verwendest, zitiere den Titel der Quellnotiz in Klammern. Nicht Wort für Wort kopieren — umformulieren. Wenn die Notizen das Thema nicht abdecken, sag es und ergänze mit deinem Allgemeinwissen.`,
contextNoNotes: "Keine relevanten Notizen für diese Frage gefunden. Antworte mit deinem Allgemeinwissen.",
system: `Du bist der KI-Assistent von Memento. Der Benutzer stellt dir Fragen zu seinen Projekten, technischen Dokumentationen und Notizen. Du musst strukturiert und hilfreich antworten.
## Formatregeln
- Verwende Markdown frei: Überschriften (##, ###), Listen, Code-Blöcke, Fettdruck, Tabellen.
- Strukturiere deine Antwort mit Abschnitten bei technischen Fragen oder komplexen Themen.
- Bei einfachen, kurzen Fragen reicht ein direkter Absatz.
## Tonregeln
- Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich.
- Keine unnötigen Einleitungssätze. Antworte direkt.
- Keine Upsell-Fragen am Ende. Gib nützliche Zusatzinfos einfach direkt.
## Verfügbare Werkzeuge
- **note_search**: Durchsuche die Notizen des Benutzers nach Schlagwort oder Bedeutung. Verwende es, wenn der obige Kontext unzureichend ist. Wenn ein Notizbuch ausgewählt ist, gib dessen ID an, um die Ergebnisse einzuschränken.
- **note_read**: Lese eine bestimmte Notiz anhand ihrer ID. Verwende es, wenn note_search eine Notiz zurückgibt, deren vollständigen Inhalt du benötigst.
- **web_search**: Suche im Web. Verwende es, wenn der Benutzer nach etwas fragt, das nicht in seinen Notizen steht.
- **web_scrape**: Lese eine Webseite und gib den Inhalt als Markdown zurück. Verwende es, wenn web_search eine URL zurückgibt, die du lesen möchtest.
## Werkzeugregeln
- Du hast bereits Kontext aus den Notizen des Benutzers oben. Verwende Werkzeuge nur, wenn du spezifischere Informationen benötigst.
- Erfinde niemals Notiz-IDs, URLs oder Notizbuch-IDs. Verwende die im Kontext oder in Werkzeugergebnissen bereitgestellten IDs.
- Bei einfachen Gesprächsfragen (Begrüßungen, Meinungen, Allgemeinwissen) antworte direkt ohne Werkzeuge.`,
},
it: {
contextWithNotes: `## Note dell'utente\n\n${contextNotes}\n\nQuando usi informazioni dalle note sopra, cita il titolo della nota fonte tra parentesi. Non copiare parola per parola — riformula. Se le note non coprono l'argomento, dillo e integra con la tua conoscenza generale.`,
contextNoNotes: "Nessuna nota rilevante trovata per questa domanda. Rispondi con la tua conoscenza generale.",
system: `Sei l'assistente IA di Memento. L'utente ti fa domande sui suoi progetti, documentazione tecnica e note. Devi rispondere in modo strutturato e utile.
## Regole di formato
- Usa markdown liberamente: titoli (##, ###), elenchi, blocchi di codice, grassetto, tabelle.
- Struttura la risposta con sezioni per domande tecniche o argomenti complessi.
- Per domande semplici e brevi, un paragrafo diretto basta.
## Regole di tono
- Tono naturale, né aziendale né troppo informale.
- Nessuna frase introduttiva non necessaria. Rispondi direttamente.
- Nessuna domanda di upsell alla fine. Se hai informazioni aggiuntive utili, dalle direttamente.
## Strumenti disponibili
- **note_search**: Cerca nelle note dell'utente per parola chiave o significato. Usa quando il contesto iniziale è insufficiente. Se un quaderno è selezionato, passa il suo ID per restringere i risultati.
- **note_read**: Leggi una nota specifica per ID. Usa quando note_search restituisce una nota di cui hai bisogno del contenuto completo.
- **web_search**: Cerca sul web. Usa quando l'utente chiede qualcosa che non è nelle sue note.
- **web_scrape**: Estrai il contenuto di una pagina web come markdown. Usa quando web_search restituisce un URL che vuoi leggere.
## Regole di utilizzo degli strumenti
- Hai già contesto dalle note dell'utente sopra. Usa gli strumenti solo se hai bisogno di informazioni più specifiche.
- Non inventare mai ID di note, URL o ID di quaderno. Usa gli ID forniti nel contesto o nei risultati degli strumenti.
- Per domande conversazionali semplici (saluti, opinioni, conoscenza generale), rispondi direttamente senza strumenti.`,
## EJEMPLO DE SALIDA HTML
<h3>Título de sección</h3>
<p>Aquí hay una explicación con <strong>texto en negrita</strong> y una lista:</p>
<ul>
<li>Primer punto importante</li>
<li>Segundo punto importante</li>
</ul>` : ''),
},
}
// Fallback to English if language not supported
const prompts = promptLang[lang] || promptLang.en
const contextBlock = contextNotes.length > 0
? prompts.contextWithNotes
: prompts.contextNoNotes
const contextBlock = contextNotes.length > 0 ? prompts.contextWithNotes : prompts.contextNoNotes
// Load note images as base64 for vision-capable models
// Load note images for vision
let imageContextParts: Array<{ type: 'image'; image: string }> = []
if (noteContext?.images && noteContext.images.length > 0) {
for (const imgPath of noteContext.images.slice(0, 4)) {
@@ -343,8 +249,7 @@ Tu as accès à ces outils pour des recherches approfondies :
const buffer = await readFile(fullPath)
const ext = path.extname(imgPath).toLowerCase()
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
const base64 = `data:${mime};base64,${buffer.toString('base64')}`
imageContextParts.push({ type: 'image', image: base64 })
imageContextParts.push({ type: 'image', image: `data:${mime};base64,${buffer.toString('base64')}` })
} catch {}
}
}
@@ -352,113 +257,38 @@ Tu as accès à ces outils pour des recherches approfondies :
let copilotContext = ''
if (noteContext) {
copilotContext = `\n\n## Current Note Context
You are currently helping the user edit a specific note. Here is the current content of the note:
Title: ${noteContext.title || 'Untitled'}
Content:
${noteContext.content || '(empty)'}
${imageContextParts.length > 0 ? `\nImages: ${imageContextParts.length} image(s) attached. When the user asks about images, describe what you see in them.` : ''}
The user wants you to write in a **${noteContext.tone || 'professional'}** tone.
IMPORTANT: Focus ONLY on this note. Do NOT reference other notes or external information unless the user explicitly asks. Your job is to help with this specific note — suggest rewrites, answer questions about it, or draft new sections.`
You are helping the user edit a specific note: ${noteContext.title || 'Untitled'}.
Tone: ${noteContext.tone || 'professional'}.
Content: ${noteContext.content || '(empty)'}
Focus ONLY on this note unless asked otherwise.`
}
const systemPrompt = `${prompts.system}
${copilotContext}
const systemPrompt = `${prompts.system}\n${copilotContext}\n\n${contextBlock}\n\n## LANGUAGE RULE (MANDATORY)\nYou MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : 'English'}.`
${contextBlock}
## LANGUAGE RULE (MANDATORY)
You MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : lang === 'de' ? 'German' : lang === 'it' ? 'Italian' : 'English'}.
Never switch to another language. Even if the user writes in a different language, respond in the configured language.`
// 6. Build message history from DB + current messages
const dbHistory = conversation.messages.map((m: { role: string; content: string }) => ({
role: m.role as 'user' | 'assistant' | 'system',
content: m.content,
}))
// Only add the current user message if it's not already in DB history
const lastIncoming = incomingMessages[incomingMessages.length - 1]
const currentDbMessage = dbHistory[dbHistory.length - 1]
const isNewMessage =
lastIncoming &&
(!currentDbMessage ||
currentDbMessage.role !== 'user' ||
currentDbMessage.content !== lastIncoming.content)
let allMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string | Array<any> }> = isNewMessage
? [...dbHistory, { role: lastIncoming.role, content: lastIncoming.content }]
: dbHistory
// Inject note images as a context message for vision models
if (imageContextParts.length > 0) {
allMessages = [
{ role: 'user', content: [{ type: 'text' as const, text: '[Attached note images — use these when the user asks about images]' }, ...imageContextParts] },
{ role: 'assistant', content: 'Understood. I can see the attached images and will describe or analyze them when asked.' },
...allMessages,
]
}
// Sliding window: keep first 2 messages (context) + last 48 to avoid context overflow
const WINDOW = 50
if (allMessages.length > WINDOW) {
allMessages = [...allMessages.slice(0, 2), ...allMessages.slice(-(WINDOW - 2))]
}
// 7. Get chat provider model
const config = await getSystemConfig()
const provider = getChatProvider(config)
const model = provider.getModel()
// 7b. Build chat tools
const chatToolContext = {
userId,
conversationId: conversation.id,
notebookId,
webSearch: !!webSearch,
config,
}
// When scoped to "this note", only provide web tools — no note_search/note_read
// to prevent the AI from pulling information from other notes
// 6. Execute stream
const sysConfig = await getSystemConfig()
const chatTools = noteContext
? toolRegistry.buildToolsForChat({ ...chatToolContext, webOnly: true })
: toolRegistry.buildToolsForChat(chatToolContext)
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch })
// 8. Save user message to DB before streaming
if (isNewMessage && lastIncoming) {
await prisma.chatMessage.create({
data: {
conversationId: conversation.id,
role: 'user',
content: lastIncoming.content,
},
})
}
// 9. Stream response
const result = streamText({
model,
const provider = getChatProvider(sysConfig)
const result = await streamText({
model: provider.chatModel,
system: systemPrompt,
messages: allMessages as any,
messages: incomingMessages,
tools: chatTools,
stopWhen: stepCountIs(5),
async onFinish({ text }) {
// Save assistant message to DB after streaming completes
await prisma.chatMessage.create({
data: {
conversationId: conversation.id,
role: 'assistant',
content: text,
},
maxSteps: 5,
onFinish: async (final) => {
// Save messages to DB
const userContent = incomingMessages[incomingMessages.length - 1].content
await prisma.message.create({
data: { conversationId: conversation.id, role: 'user', content: userContent }
})
},
await prisma.message.create({
data: { conversationId: conversation.id, role: 'assistant', content: final.text }
})
}
})
// 10. Return streaming response with conversation ID header
return result.toUIMessageStreamResponse({
headers: {
'X-Conversation-Id': conversation.id,
},
})
return result.toDataStreamResponse()
}

File diff suppressed because it is too large Load Diff

View File

@@ -147,7 +147,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
return (
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105"
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105 bg-[#E9ECEF] text-[#1C1C1C] hover:bg-[#E9ECEF]/80 border border-black/5"
size="icon"
title={t('ai.openAssistant')}
>
@@ -202,7 +202,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('chat')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'chat' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
activeTab === 'chat' ? "border-[#75B2D6] text-[#75B2D6]" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Bot className="h-4 w-4" /> {t('ai.chatTab')}
@@ -211,7 +211,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('insights')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'insights' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
activeTab === 'insights' ? "border-[#75B2D6] text-[#75B2D6]" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
@@ -220,7 +220,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('history')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'history' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
activeTab === 'history' ? "border-[#75B2D6] text-[#75B2D6]" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<History className="h-4 w-4" /> {t('ai.historyTab')}
@@ -234,10 +234,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{/* AI Welcome Message */}
{messages.length === 0 && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<div className="w-8 h-8 rounded-full bg-[#75B2D6]/10 text-[#75B2D6] flex items-center justify-center flex-shrink-0 border border-[#75B2D6]/20">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<div className="bg-[#FDFDFE] border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<p className="text-sm text-foreground leading-relaxed">
{t('ai.welcomeMsg')}
</p>
@@ -256,15 +256,15 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
msg.role === 'user'
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
: 'bg-primary/10 text-primary border-primary/20',
: 'bg-[#75B2D6]/10 text-[#75B2D6] border-[#75B2D6]/20',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
</div>
<div className={cn(
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
? 'bg-[#75B2D6] text-white rounded-tr-sm'
: 'bg-[#FDFDFE] border border-border/50 rounded-tl-sm text-foreground',
)}>
{msg.role === 'assistant'
? <MarkdownContent content={text} />
@@ -276,10 +276,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<div className="w-8 h-8 rounded-full bg-[#75B2D6]/10 text-[#75B2D6] flex items-center justify-center flex-shrink-0 border border-[#75B2D6]/20">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<div className="bg-[#FDFDFE] border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</div>
@@ -290,7 +290,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{activeTab === 'insights' && (
<div className="h-full">
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> {t('ai.summaryLast5')}</h3>
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-[#75B2D6]" /> {t('ai.summaryLast5')}</h3>
{insightsLoading ? (
<div className="flex flex-col items-center justify-center py-10 opacity-60">
<Loader2 className="h-8 w-8 animate-spin mb-4 text-muted-foreground" />
@@ -318,7 +318,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
history.map(conv => (
<button
key={conv.id}
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-primary/30 transition-all flex flex-col gap-1"
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-[#75B2D6]/30 transition-all flex flex-col gap-1"
onClick={() => {
setConversationId(conv.id)
setMessages(conv.messages.map((m: any) => ({
@@ -345,7 +345,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</div>
{/* Input Area & Tone Controls (Only in Chat tab) */}
<div className={cn("p-4 border-t border-border/40 bg-muted/10 shrink-0", activeTab !== 'chat' && "hidden")}>
<div className={cn("p-4 border-t border-border/40 bg-[#FDFDFE] shrink-0", activeTab !== 'chat' && "hidden")}>
{/* Context Scope */}
<div className="mb-3">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
@@ -387,7 +387,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
className={cn(
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
isSelected
? "border-primary bg-primary/10 text-primary shadow-sm"
? "border-[#75B2D6] bg-[#75B2D6]/10 text-[#75B2D6] shadow-sm"
: "border-border/60 bg-card text-muted-foreground hover:bg-muted hover:border-border"
)}
>
@@ -400,7 +400,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</div>
{/* Text Input */}
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-all shadow-sm">
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-[#75B2D6] focus-within:ring-1 focus-within:ring-[#75B2D6]/20 transition-all shadow-sm">
<textarea
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
placeholder={t('ai.chatPlaceholder')}
@@ -435,7 +435,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
) : (
<Button
size="icon"
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
className="h-8 w-8 rounded-lg bg-[#75B2D6] text-white shadow-sm hover:shadow-md transition-all"
onClick={handleSend}
disabled={!input.trim()}
>

View File

@@ -81,7 +81,7 @@ export function BatchOrganizationDialog({
setSelectedNotes(new Set())
setFetchError(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handleOpenChange = (isOpen: boolean) => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Share2 } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles } from 'lucide-react'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
import { useReminderCheck } from '@/hooks/use-reminder-check'
@@ -262,8 +262,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
? await searchNotes(search, semanticMode, notebook || undefined)
: await getAllNotes(false, notebook || undefined)
if (!notebook && !search) {
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
const sharedOnly = searchParams.get('shared') === '1'
if (sharedOnly) {
allNotes = allNotes.filter((note: any) => note._isShared)
} else if (!notebook && !search) {
allNotes = allNotes.filter((note: any) => !note.notebookId && !note._isShared)
}
if (labelFilter.length > 0) {
@@ -295,10 +299,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
return () => { cancelled.value = true }
} else {
let filtered = initialNotes
const sharedOnly = searchParams.get('shared') === '1'
if (notebook) {
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
} else if (sharedOnly) {
filtered = initialNotes.filter((n: any) => n._isShared)
} else {
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
filtered = initialNotes.filter((n: any) => !n.notebookId && !n._isShared)
}
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
@@ -306,7 +313,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
})
setPinnedNotes(filtered.filter(n => n.isPinned))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey])
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
@@ -384,7 +391,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
)}>
<div className="flex justify-between items-start">
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
{currentNotebook ? currentNotebook.name : t('notes.title')}
{currentNotebook ? currentNotebook.name : (searchParams.get('shared') === '1' ? 'Partagées avec moi' : t('notes.title'))}
</h1>
</div>
@@ -463,10 +470,23 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
<span>{t('notes.search') || 'Search'}</span>
</button>
)}
{!searchParams.get('notebook') && searchParams.get('shared') !== '1' && (
<button
onClick={() => setBatchOrganizationOpen(true)}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Sparkles size={16} />
<span>Réorganiser les notes</span>
</button>
)}
</div>
<button className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity">
<Share2 size={16} />
<span>{t('notes.share') || 'Share'}</span>
<button
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : 'newest')}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<ArrowUpDown size={16} />
<span>{sortLabels[sortOrder]}</span>
</button>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Download, Presentation } from 'lucide-react'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
@@ -107,6 +108,8 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
const [localId, setLocalId] = useState<string | null>(canvasId || null)
const router = useRouter()
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
const filesRef = useRef<BinaryFiles>({})
@@ -147,12 +150,22 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
saveTimeoutRef.current = setTimeout(async () => {
try {
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
await fetch('/api/canvas', {
const res = await fetch('/api/canvas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: canvasId || null, name, data: snapshot })
body: JSON.stringify({ id: localId || null, name, data: snapshot })
})
setSaveStatus('saved')
const data = await res.json()
if (data.success && data.canvas?.id) {
if (!localId) {
setLocalId(data.canvas.id)
router.replace(`/lab?id=${data.canvas.id}`, { scroll: false })
}
setSaveStatus('saved')
} else {
throw new Error(data.error || 'Failed to save')
}
} catch (e) {
console.error('[CanvasBoard] Save failure:', e)
setSaveStatus('error')
@@ -164,7 +177,7 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
return <PptxViewer data={scene.pptx} name={name} />
}
const excalKey = canvasId ? `excal-${canvasId}` : 'excal-new'
const excalKey = localId ? `excal-${localId}` : 'excal-new'
return (
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">

View File

@@ -1,13 +1,14 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { X, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LABEL_COLORS } from '@/lib/types'
import { useNotebooks } from '@/context/notebooks-context'
interface LabelBadgeProps {
label: string
type?: 'ai' | 'user' // Optional: if provided, applies AI vs User styling
onRemove?: () => void
variant?: 'default' | 'filter' | 'clickable'
onClick?: () => void
@@ -17,6 +18,7 @@ interface LabelBadgeProps {
export function LabelBadge({
label,
type,
onRemove,
variant = 'default',
onClick,
@@ -27,13 +29,16 @@ export function LabelBadge({
const colorName = getLabelColor(label)
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
// AI labels get special Blueprint styling with Sparkles icon
const isAI = type === 'ai'
return (
<Badge
className={cn(
'text-xs border gap-1',
colorClasses.bg,
colorClasses.text,
colorClasses.border,
'text-xs border gap-1 transition-all',
isAI
? 'bg-blue-100/70 border-blue-200/50 text-sky-700 dark:bg-sky-900/30 dark:border-sky-700/50 dark:text-sky-300 hover:bg-blue-200/70'
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`,
variant === 'filter' && 'cursor-pointer hover:opacity-80',
variant === 'clickable' && 'cursor-pointer',
isDisabled && 'opacity-50',
@@ -41,6 +46,7 @@ export function LabelBadge({
)}
onClick={onClick}
>
{isAI && <Sparkles className="h-3 w-3 text-sky-500 dark:text-sky-400" />}
{label}
{onRemove && (
<button
@@ -53,6 +59,12 @@ export function LabelBadge({
<X className="h-3 w-3" />
</button>
)}
{isAI && (
<span className="relative flex h-1.5 w-1.5 ml-0.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-sky-500"></span>
</span>
)}
</Badge>
)
}

View File

@@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import 'katex/dist/katex.min.css'
interface MarkdownContentProps {
@@ -17,7 +18,7 @@ export const MarkdownContent = memo(function MarkdownContent({ content, classNam
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={{
a: ({ node, ...props }) => (
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />

View File

@@ -5,13 +5,14 @@ import { Note } from '@/lib/types'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight } from 'lucide-react'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge'
import { NoteHistoryModal } from './note-history-modal'
import { enableNoteHistory, commitNoteHistory } from '@/app/actions/notes'
import { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
import { useEffect } from 'react'
type Tab = 'info' | 'versions'
@@ -49,8 +50,56 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false)
const [isSavingVersion, setIsSavingVersion] = useState(false)
const [versionSaved, setVersionSaved] = useState(false)
const [historyEntries, setHistoryEntries] = useState<any[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
const [isDeleting, setIsDeleting] = useState<string | null>(null)
const [isRestoring, setIsRestoring] = useState<string | null>(null)
const locale = getLocale(language)
useEffect(() => {
if (activeTab === 'versions' && historyEnabled) {
loadHistory()
}
}, [activeTab, historyEnabled, note.id])
const loadHistory = async () => {
setIsLoadingHistory(true)
try {
const entries = await getNoteHistory(note.id, 50)
setHistoryEntries(entries)
} catch (e) {
console.error(e)
} finally {
setIsLoadingHistory(false)
}
}
const handleDeleteVersion = async (entryId: string) => {
if (!confirm('Supprimer cette version ?')) return
setIsDeleting(entryId)
try {
await deleteNoteHistoryEntry(note.id, entryId)
setHistoryEntries(prev => prev.filter(e => e.id !== entryId))
} catch (e) {
console.error(e)
} finally {
setIsDeleting(null)
}
}
const handleRestoreVersion = async (entryId: string) => {
setIsRestoring(entryId)
try {
const restored = await restoreNoteVersion(note.id, entryId)
onNoteRestored?.(restored)
loadHistory()
} catch (e) {
console.error(e)
} finally {
setIsRestoring(null)
}
}
const notebook = useMemo(
() => notebooks.find(nb => nb.id === note.notebookId),
[notebooks, note.notebookId]
@@ -201,17 +250,17 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
</button>
</div>
) : (
<div className="space-y-3">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Versions sauvegardées</p>
<div className="space-y-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">Versions sauvegardées</p>
{/* Save version button */}
<button
disabled={isSavingVersion}
className={cn(
'w-full flex items-center justify-center gap-2 p-3 rounded-xl border transition-colors text-sm font-medium',
'w-full flex items-center justify-center gap-2 p-3 rounded-xl border transition-all text-xs font-bold uppercase tracking-widest',
versionSaved
? 'border-emerald-500/40 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400'
: 'border-foreground/20 bg-foreground text-background hover:opacity-80',
: 'border-foreground/10 bg-foreground text-background hover:opacity-90 shadow-sm',
isSavingVersion && 'opacity-50 cursor-not-allowed'
)}
onClick={async () => {
@@ -219,6 +268,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
try {
await commitNoteHistory(note.id)
setVersionSaved(true)
loadHistory()
setTimeout(() => setVersionSaved(false), 3000)
} catch (e) {
console.error(e)
@@ -228,24 +278,95 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
}}
>
{isSavingVersion ? (
<><span className="h-3.5 w-3.5 rounded-full border-2 border-current border-t-transparent animate-spin" />Sauvegarde…</>
<><Loader2 className="h-3.5 w-3.5 animate-spin" />Sauvegarde…</>
) : versionSaved ? (
<><span className="text-base">✓</span> Version sauvegardée !</>
<><Check className="h-3.5 w-3.5" /> Version sauvegardée !</>
) : (
<><span className="text-base">⎘</span> Sauvegarder cette version</>
<>Sauvegarder cette version</>
)}
</button>
{/* View history */}
<div className="h-px bg-border/30 my-2" />
{/* Timeline */}
{isLoadingHistory && historyEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 opacity-40">
<Loader2 className="h-6 w-6 animate-spin mb-2" />
<p className="text-[10px] uppercase tracking-widest">Chargement...</p>
</div>
) : historyEntries.length === 0 ? (
<div className="text-center py-8 opacity-40 border border-dashed rounded-xl">
<Clock className="h-6 w-6 mx-auto mb-2" />
<p className="text-[10px] uppercase tracking-widest">Aucune version</p>
</div>
) : (
<div className="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-px before:bg-border/40">
{historyEntries.map((entry, idx) => {
const colors = ['#E2E8F0', '#ACB995', '#E9ECEF']
const dotColor = colors[idx % colors.length]
const isLatest = idx === 0
return (
<div key={entry.id} className="relative group">
{/* Dot */}
<div
className="absolute -left-[19px] top-1.5 h-3 w-3 rounded-full border-2 border-background z-10 shadow-sm"
style={{ backgroundColor: dotColor }}
/>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-bold font-mono">v{entry.version}</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-0.5 rounded-md bg-primary/10 text-primary font-bold uppercase tracking-widest">Latest</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleRestoreVersion(entry.id)}
disabled={!!isRestoring || !!isDeleting}
className="p-1.5 rounded-lg hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors"
title="Restaurer"
>
{isRestoring === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
</button>
<button
onClick={() => handleDeleteVersion(entry.id)}
disabled={!!isRestoring || !!isDeleting}
className="p-1.5 rounded-lg hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors"
title="Supprimer"
>
{isDeleting === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</button>
</div>
</div>
<p className="text-[10px] text-muted-foreground font-medium">
{format(new Date(entry.createdAt), 'd MMM · HH:mm', { locale })}
<span className="mx-1.5 opacity-30">·</span>
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true, locale })}
</p>
</div>
</div>
)
})}
</div>
)}
{/* Button to open the full modal (optional, but good to keep if user wants diff) */}
<button
className="w-full flex items-center justify-between p-3 rounded-xl border border-border hover:bg-muted transition-colors text-left"
className="w-full flex items-center justify-between p-3 rounded-xl border border-border/40 hover:bg-muted/50 transition-colors text-left group mt-4"
onClick={() => setShowHistory(true)}
>
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
<div className="w-8 h-8 rounded-full bg-primary/5 flex items-center justify-center text-primary group-hover:bg-primary/10 transition-colors">
<HistoryIcon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium">Voir l'historique</p>
<p className="text-[11px] text-muted-foreground">Comparer et restaurer des versions</p>
<p className="text-xs font-bold uppercase tracking-wider">Mode Comparaison</p>
<p className="text-[10px] text-muted-foreground">Comparer les versions côte à côte</p>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />

View File

@@ -34,8 +34,8 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
{/* BODY — max-w-4xl, px-12, py-16 */}
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
{/* BODY — max-w-4xl, responsive px, py-16 */}
<div className="max-w-4xl mx-auto w-full px-6 sm:px-12 py-16 space-y-12 min-w-0">
{/* Breadcrumb + Title block */}
<div className="space-y-4">

View File

@@ -19,8 +19,9 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2
Trash2, LogOut, Wand2, Share2
} from 'lucide-react'
import { NoteShareDialog } from './note-share-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { useRefresh } from '@/lib/use-refresh'
import { useLanguage } from '@/lib/i18n'
@@ -39,6 +40,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const { t } = useLanguage()
const { refreshNotes } = useRefresh()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
@@ -187,6 +189,19 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button>
)}
{/* Share button */}
{!readOnly && (
<button
title="Partager la note"
aria-label="Partager la note"
onClick={() => setShareOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Share2 size={16} />
</button>
)}
{/* Three-dot options menu */}
{!readOnly && (
<DropdownMenu>
@@ -214,6 +229,15 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenu>
)}
{/* Share Dialog portal */}
{shareOpen && (
<NoteShareDialog
noteId={note.id}
noteTitle={state.title}
onClose={() => setShareOpen(false)}
/>
)}
{/* Info panel toggle — rightmost, icon only */}
<button
aria-label="Informations du document"

View File

@@ -0,0 +1,222 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { createShareRequest, removeCollaborator, getNoteCollaborators } from '@/app/actions/notes'
import { toast } from 'sonner'
import { X, UserPlus, Users, Mail, Trash2, Loader2, Share2, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Collaborator {
id: string
name: string | null
email: string | null
image: string | null
}
interface NoteShareDialogProps {
noteId: string
noteTitle: string
onClose: () => void
}
export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogProps) {
const [email, setEmail] = useState('')
const [permission, setPermission] = useState<'view' | 'edit'>('view')
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [loading, setLoading] = useState(true)
const [sending, setSending] = useState(false)
const [removingId, setRemovingId] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
// Close on Escape
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const loadCollaborators = useCallback(async () => {
try {
const list = await getNoteCollaborators(noteId)
setCollaborators(list as Collaborator[])
} catch {
// owner-only view — silently ignore
} finally {
setLoading(false)
}
}, [noteId])
useEffect(() => { loadCollaborators() }, [loadCollaborators])
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = email.trim()
if (!trimmed) return
setSending(true)
try {
await createShareRequest(noteId, trimmed, permission)
setSent(true)
setEmail('')
toast.success(`Invitation envoyée à ${trimmed}`)
setTimeout(() => setSent(false), 2000)
loadCollaborators()
} catch (err: any) {
const msg = err?.message || 'Erreur lors du partage'
if (msg.includes('not found')) toast.error('Aucun compte trouvé avec cet email.')
else if (msg.includes('already shared')) toast.error('Cette note est déjà partagée avec cet utilisateur.')
else toast.error(msg)
} finally {
setSending(false)
}
}
const handleRemove = async (collaboratorId: string, collaboratorEmail: string | null) => {
setRemovingId(collaboratorId)
try {
await removeCollaborator(noteId, collaboratorId)
setCollaborators(prev => prev.filter(c => c.id !== collaboratorId))
toast.success(`Accès retiré à ${collaboratorEmail || "l'utilisateur"}`)
} catch {
toast.error("Impossible de retirer l'accès.")
} finally {
setRemovingId(null)
}
}
if (!mounted) return null
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden border border-black/10 dark:border-white/10"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b border-black/10 dark:border-white/10 flex items-start justify-between">
<div className="flex items-center gap-2">
<Share2 size={15} className="text-[#75B2D6]" />
<h2 className="text-sm font-bold text-foreground tracking-tight">Partager</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-foreground/40 hover:text-foreground transition-colors"
>
<X size={15} />
</button>
</div>
{/* Invite form */}
<form onSubmit={handleInvite} className="px-6 py-5 space-y-3">
<label className="text-[9px] uppercase tracking-[0.25em] font-bold text-foreground/40">
Inviter par email
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Mail size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/30" />
<input
type="email"
placeholder="email@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoFocus
className="w-full pl-9 pr-3 py-2.5 text-[13px] rounded-xl border border-black/15 dark:border-white/15 bg-transparent outline-none focus:ring-2 ring-[#75B2D6]/30 focus:border-[#75B2D6] transition-all placeholder:text-foreground/30"
/>
</div>
{/* Permission toggle */}
<div className="flex rounded-xl border border-black/15 dark:border-white/15 overflow-hidden shrink-0">
{(['view', 'edit'] as const).map(p => (
<button
key={p}
type="button"
onClick={() => setPermission(p)}
className={cn(
'px-3 py-2 text-[10px] font-bold uppercase tracking-wide transition-colors',
permission === p
? 'bg-[#75B2D6] text-white'
: 'text-foreground/50 hover:bg-black/5 dark:hover:bg-white/5'
)}
>
{p === 'view' ? 'Lire' : 'Éditer'}
</button>
))}
</div>
</div>
<button
type="submit"
disabled={sending || !email.trim()}
className={cn(
'w-full py-2.5 rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 transition-all',
sending || !email.trim()
? 'bg-black/5 dark:bg-white/5 text-foreground/30 cursor-not-allowed'
: sent
? 'bg-emerald-500 text-white'
: 'bg-[#75B2D6] text-white hover:opacity-90 shadow-sm shadow-[#75B2D6]/30'
)}
>
{sending
? <Loader2 size={13} className="animate-spin" />
: sent
? <><Check size={13} /> Invitation envoyée</>
: <><UserPlus size={13} /> Envoyer l&apos;invitation</>
}
</button>
</form>
{/* Collaborators list */}
<div className="px-6 pb-6 space-y-3">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
<span className="text-[9px] uppercase tracking-[0.25em] font-bold text-foreground/30 flex items-center gap-1.5">
<Users size={10} /> Accès partagé
</span>
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
</div>
{loading ? (
<div className="flex justify-center py-4">
<Loader2 size={16} className="animate-spin text-foreground/30" />
</div>
) : collaborators.length === 0 ? (
<p className="text-center text-[11px] text-foreground/30 py-4">
Aucun collaborateur pour l&apos;instant.
</p>
) : (
<ul className="space-y-2">
{collaborators.map(c => (
<li key={c.id} className="flex items-center gap-3 p-2.5 rounded-xl bg-black/[0.03] dark:bg-white/[0.03] border border-black/[0.06] dark:border-white/[0.06]">
<div className="h-8 w-8 rounded-full bg-[#E9ECEF]/20 flex items-center justify-center shrink-0 overflow-hidden">
{c.image
? <img src={c.image} alt={c.name || ''} className="h-full w-full object-cover" />
: <span className="text-[11px] font-bold text-[#E9ECEF]">{(c.name || c.email || '?')[0].toUpperCase()}</span>
}
</div>
<div className="flex-1 min-w-0">
<p className="text-[12px] font-semibold text-foreground truncate">{c.name || 'Utilisateur'}</p>
<p className="text-[10px] text-foreground/40 truncate">{c.email}</p>
</div>
<button
onClick={() => handleRemove(c.id, c.email)}
disabled={removingId === c.id}
className="p-1.5 rounded-lg text-foreground/30 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
title="Retirer l'accès"
>
{removingId === c.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>,
document.body
)
}

View File

@@ -12,9 +12,18 @@ export function NoteTitleBlock() {
const { t } = useLanguage()
if (fullPage) {
// Adaptive font size: short = big editorial, long = smaller but still premium
const titleLen = (state.title || '').length
const titleSizeClass =
titleLen === 0 ? 'text-5xl md:text-6xl' :
titleLen < 40 ? 'text-5xl md:text-6xl' :
titleLen < 70 ? 'text-4xl md:text-5xl' :
titleLen < 100 ? 'text-3xl md:text-4xl' :
'text-2xl md:text-3xl'
return (
<div className="space-y-4">
{/* Title — auto-resizing textarea to prevent overflow */}
{/* Title — auto-resizing textarea, adaptive size */}
<div className="group relative">
<textarea
dir="auto"
@@ -29,36 +38,41 @@ export function NoteTitleBlock() {
}}
disabled={readOnly}
className={cn(
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight break-words',
'placeholder:text-foreground/20 resize-none overflow-hidden break-words',
'w-full font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground',
'leading-[1.15] tracking-tight',
'placeholder:text-foreground/20 resize-none overflow-hidden',
titleSizeClass,
!readOnly && 'pr-12'
)}
style={{ height: 'auto' }}
ref={(el) => {
// Force correct initial height on mount
if (el) {
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
}}
/>
{/* AI title generation — always visible on hover */}
{/* AI title generation — visible on hover */}
{!readOnly && (
<button
type="button"
onClick={async () => {
console.log('[TITLE] Sparkles button clicked')
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
console.log('[TITLE] Content length:', plain.length, 'Word count:', wordCount)
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return
}
actions.setIsProcessingAI(true)
try {
console.log('[TITLE] Calling /api/ai/title-suggestions...')
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
console.log('[TITLE] API response:', res.status)
if (res.ok) {
const data = await res.json()
console.log('[TITLE] Suggestions:', data.suggestions)
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
@@ -67,12 +81,9 @@ export function NoteTitleBlock() {
toast.error('Impossible de générer un titre.')
}
} else {
const err = await res.text()
console.error('[TITLE] API error:', err)
toast.error('Erreur lors de la génération du titre.')
}
} catch (e) {
console.error('[TITLE] Fetch failed:', e)
toast.error('Erreur réseau.')
} finally { actions.setIsProcessingAI(false) }
}}
@@ -97,6 +108,7 @@ export function NoteTitleBlock() {
)
}
// Dialog mode title block
return (
<div className="relative">

View File

@@ -486,34 +486,34 @@ export function NoteHistoryModal({
{enabled && !isLoading && entries.length >= 2 && (
<div className="flex items-center justify-end border-t border-border/60 px-5 py-2.5">
<div className="flex items-center gap-1 rounded-lg border border-border/60 p-0.5">
<button
type="button"
onClick={() => { setViewMode('preview'); setDiffLeftId(null); setDiffRightId(null) }}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
viewMode === 'preview' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
{t('notes.history') || 'Historique'}
</button>
<button
type="button"
onClick={() => {
setViewMode('diff')
if (!diffLeftId && entries.length >= 2) {
setDiffLeftId(entries[1].id)
setDiffRightId(entries[0].id)
}
}}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors inline-flex items-center gap-1',
viewMode === 'diff' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<GitCompare className="h-3 w-3" />
{t('notes.compareVersions') || 'Comparer'}
</button>
</div>
<button
type="button"
onClick={() => { setViewMode('preview'); setDiffLeftId(null); setDiffRightId(null) }}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
viewMode === 'preview' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
{t('notes.history') || 'Historique'}
</button>
<button
type="button"
onClick={() => {
setViewMode('diff')
if (!diffLeftId && entries.length >= 2) {
setDiffLeftId(entries[1].id)
setDiffRightId(entries[0].id)
}
}}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors inline-flex items-center gap-1',
viewMode === 'diff' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<GitCompare className="h-3 w-3" />
{t('notes.compareVersions') || 'Comparer'}
</button>
</div>
</div>
)}
</DialogContent>

View File

@@ -107,16 +107,15 @@ export function NoteInlineEditor({
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
const userId = session.user.id
import('@/app/actions/ai-settings').then(({ getAISettings }) => {
getAISettings(userId).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
})
getAISettings(session.user.id).then((settings) => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
setAutoSaveEnabled(settings.autoSave !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
}
}, [session?.user?.id])
const { labels: globalLabels, addLabel } = useNotebooks()
@@ -207,6 +206,10 @@ export function NoteInlineEditor({
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
const scheduleSave = useCallback(() => {
if (!autoSaveEnabled) {
setIsDirty(true)
return
}
setIsDirty(true)
clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
@@ -567,10 +570,11 @@ export function NoteInlineEditor({
)}
{previousContent !== null && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600"
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30 font-medium"
title={t('ai.undoAI') }
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
<RotateCcw className="h-3.5 w-3.5" />
<span className="text-[11px]">{t('general.undo') || 'Annuler'}</span>
</Button>
)}
</div>
@@ -601,7 +605,31 @@ export function NoteInlineEditor({
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
) : isDirty ? (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
!autoSaveEnabled ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[11px] text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={() => {
setIsSaving(true)
saveInline(note.id, { title, content, checkItems, type: noteType, isMarkdown: showMarkdownPreview && noteType === 'markdown' })
.then(() => {
setIsSaving(false)
setIsDirty(false)
toast.success(t('notes.savedStatus'))
})
.catch(() => {
setIsSaving(false)
toast.error(t('general.error'))
})
}}
>
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 mr-1.5" />
{t('notes.saveNow') || 'Enregistrer'}
</Button>
) : (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
)
) : (
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
)}
@@ -707,13 +735,22 @@ export function NoteInlineEditor({
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
{/* Title */}
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
<input
type="text"
<textarea
dir="auto"
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
rows={1}
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40 resize-none overflow-hidden min-h-[1.5em]"
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title}
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
onChange={(e) => {
changeTitle(e.target.value);
scheduleSave();
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
onFocus={(e) => {
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
/>
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button"
@@ -920,9 +957,20 @@ export function NoteInlineEditor({
noteImages={allImages}
noteId={note.id}
onApplyToNote={(newContent) => {
setPreviousContent(content)
const current = content
setPreviousContent(current)
changeContent(newContent)
scheduleSave()
toast.success(t('ai.appliedToNote') || 'Applied to note', {
action: {
label: t('general.undo') || 'Undo',
onClick: () => {
changeContent(current)
setPreviousContent(null)
scheduleSave()
}
}
})
}}
onUndoLastAction={previousContent !== null ? () => {
changeContent(previousContent)

View File

@@ -1070,13 +1070,22 @@ export function NoteInput({
noteContent={content}
noteImages={allImages}
onApplyToNote={(newContent) => {
// Save current state to history before applying AI content
setHistory(prev => [...prev.slice(0, historyIndex + 1), { title, content }])
setHistoryIndex(prev => prev + 1)
if (type === 'richtext') {
// If content looks like markdown, convert to HTML before injecting into richtext
const looksLikeMarkdown = /^#{1,6}\s|^[-*]\s|\*\*[^*]+\*\*|^>\s/.test(newContent)
setContent(looksLikeMarkdown ? markdownToBasicHtml(newContent) : newContent)
} else {
setContent(newContent)
}
toast.success(t('ai.appliedToNote'), {
action: {
label: t('general.undo'),
onClick: () => handleUndo()
}
})
}}
lastActionApplied={false}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}

View File

@@ -46,6 +46,15 @@ interface ReminderNote {
isReminderDone: boolean
}
// ── Memento brand tokens ──────────────────────────────────────────────────────
const C = {
blue: '#E9ECEF',
gold: '#D4A373',
green: '#A3B18A',
dark: '#1C1C1C',
beige: '#F2F0E9',
}
export function NotificationPanel() {
const { refreshNotes } = useRefresh()
const { t } = useLanguage()
@@ -100,7 +109,6 @@ export function NotificationPanel() {
refreshNotes(null)
setOpen(false)
} catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || t('general.error'))
}
}
@@ -112,7 +120,6 @@ export function NotificationPanel() {
toast.info(t('notification.declined'))
if (requests.length <= 1) setOpen(false)
} catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || t('general.error'))
}
}
@@ -139,6 +146,23 @@ export function NotificationPanel() {
const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
// ── icon bg/color per notification type ──────────────────────────────────
const notifIconStyle = (type: string) => {
if (type === 'agent_success') return { bg: `${C.green}20`, color: C.green }
if (type === 'agent_slides_ready') return { bg: `${C.blue}20`, color: C.blue }
if (type === 'agent_canvas_ready') return { bg: `${C.blue}20`, color: C.blue }
if (type === 'agent_failure') return { bg: '#EF444420', color: '#EF4444' }
return { bg: `${C.gold}20`, color: C.gold }
}
const notifLabelColor = (type: string) => {
if (type === 'agent_success') return C.green
if (type === 'agent_slides_ready') return C.blue
if (type === 'agent_canvas_ready') return C.blue
if (type === 'agent_failure') return '#EF4444'
return C.gold
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -147,163 +171,172 @@ export function NotificationPanel() {
>
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
{pendingCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full bg-rose-500 text-white text-[9px] font-bold border border-white shadow-sm">
<span
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm"
style={{ background: C.gold }}
>
{pendingCount > 9 ? '9+' : pendingCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="px-4 py-3 border-b bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
</div>
<div className="flex items-center gap-2">
{appNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title={t('notification.markAllRead') || 'Mark all read'}
>
<Check className="h-3.5 w-3.5" />
</button>
)}
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
</div>
<PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-xl border border-black/10">
{/* Header */}
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: `${C.beige}` }}>
<div className="flex items-center gap-2">
<Bell className="h-4 w-4" style={{ color: C.dark }} />
<span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
{t('notification.notifications')}
</span>
</div>
<div className="flex items-center gap-2">
{appNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
title={t('notification.markAllRead') || 'Mark all read'}
>
<Check className="h-3.5 w-3.5" />
</button>
)}
{pendingCount > 0 && (
<span
className="h-5 px-1.5 flex items-center justify-center rounded-full text-white text-[9px] font-bold"
style={{ background: C.gold }}
>
{pendingCount}
</span>
)}
</div>
</div>
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
<div className="animate-spin h-6 w-6 border-2 border-t-transparent rounded-full mx-auto mb-2" style={{ borderColor: C.blue, borderTopColor: 'transparent' }} />
</div>
) : !hasContent ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">{t('notification.noNotifications') || 'No new notifications'}</p>
<div className="p-8 text-center">
<Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
<p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{/* App notifications (agents, system) */}
<div className="max-h-96 overflow-y-auto divide-y divide-black/5">
{/* ── App notifications (agents, system) ── */}
{appNotifications.map((notif) => {
const isSlides = notif.type === 'agent_slides_ready'
const isCanvas = notif.type === 'agent_canvas_ready'
const canvasId = notif.relatedId
const iconStyle = notifIconStyle(notif.type)
return (
<div
key={notif.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
<div className={cn(
"mt-0.5 flex-none rounded-full p-1",
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
notif.type === 'agent_slides_ready' && 'bg-purple-100 dark:bg-purple-900/30 text-purple-600',
notif.type === 'agent_canvas_ready' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
)}>
{isSlides ? (
<Presentation className="w-3.5 h-3.5" />
) : isCanvas ? (
<Pencil className="w-3.5 h-3.5" />
) : notif.type.startsWith('agent') ? (
<Bot className="w-3.5 h-3.5" />
) : (
<AlertCircle className="w-3.5 h-3.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
notif.type === 'agent_slides_ready' && 'text-purple-600 dark:text-purple-400',
notif.type === 'agent_canvas_ready' && 'text-blue-600 dark:text-blue-400',
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
)}>
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Slides Ready')}
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagram Ready')}
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
{notif.type === 'system' && 'System'}
</span>
</div>
<p className="text-sm font-medium truncate">{notif.title}</p>
{notif.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
)}
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
className="mt-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
title={t('notification.dismiss') || 'Dismiss'}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{isSlides && canvasId && (
<div className="mt-2 ml-8">
<button
onClick={async () => {
<div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
window.open(`/api/canvas/download?id=${canvasId}`, '_blank')
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md bg-purple-500 text-white hover:bg-purple-600 shadow-sm transition-all active:scale-95"
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
{/* Icon badge */}
<div
className="mt-0.5 flex-none rounded-lg p-1.5"
style={{ background: iconStyle.bg, color: iconStyle.color }}
>
<Download className="w-3 h-3" />
{t('notification.downloadPptx') || 'Download .pptx'}
{isSlides ? <Presentation className="w-3.5 h-3.5" />
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
: notif.type.startsWith('agent') ? <Bot className="w-3.5 h-3.5" />
: <AlertCircle className="w-3.5 h-3.5" />}
</div>
<div className="flex-1 min-w-0">
<span
className="text-[9px] font-bold uppercase tracking-[0.2em]"
style={{ color: notifLabelColor(notif.type) }}
>
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
{notif.type === 'system' && 'Système'}
</span>
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
{notif.message && (
<p className="text-[11px] text-foreground/50 mt-0.5 line-clamp-2">{notif.message}</p>
)}
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
className="mt-0.5 text-foreground/20 hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Download PPTX button */}
{isSlides && canvasId && (
<div className="mt-2 ml-8">
<button
onClick={async () => {
handleMarkNotifRead(notif.id)
try {
const res = await fetch(`/api/canvas?id=${canvasId}`)
const data = await res.json()
if (!data.canvas?.data) throw new Error()
const parsed = JSON.parse(data.canvas.data)
if (!parsed.base64) throw new Error()
const bytes = Uint8Array.from(atob(parsed.base64), c => c.charCodeAt(0))
const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = parsed.filename || `${data.canvas.name || 'presentation'}.pptx`
document.body.appendChild(a); a.click()
document.body.removeChild(a); URL.revokeObjectURL(url)
} catch { toast.error('Échec du téléchargement') }
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
style={{ background: C.blue }}
>
<Download className="w-3 h-3" />
{t('notification.downloadPptx') || 'Télécharger .pptx'}
</button>
</div>
)}
</div>
)
})}
{/* Overdue reminders */}
{/* ── Overdue reminders ── */}
{overdueReminders.map((note) => (
<div
key={note.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div className="flex items-start gap-3">
<button
onClick={() => handleToggleReminder(note.id, true)}
className="mt-0.5 flex-none text-amber-500 hover:text-green-500 transition-colors"
className="mt-0.5 flex-none transition-colors hover:opacity-70"
style={{ color: C.gold }}
title={t('reminders.markDone')}
>
<Circle className="w-4 h-4" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<AlertCircle className="w-3 h-3 text-amber-500" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
<AlertCircle className="w-3 h-3" style={{ color: C.gold }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.gold }}>
{t('reminders.overdue')}
</span>
</div>
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p>
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
<Clock className="w-3 h-3" />
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
</div>
@@ -312,17 +345,14 @@ export function NotificationPanel() {
</div>
))}
{/* Upcoming reminders */}
{/* ── Upcoming reminders ── */}
{upcomingReminders.slice(0, 5).map((note) => (
<div
key={note.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div className="flex items-start gap-3">
<Clock className="w-4 h-4 mt-0.5 flex-none text-primary" />
<Clock className="w-4 h-4 mt-0.5 flex-none" style={{ color: C.blue }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p>
<div className="text-xs text-muted-foreground mt-0.5">
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
<div className="text-[11px] text-foreground/40 mt-0.5">
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})}
@@ -332,56 +362,48 @@ export function NotificationPanel() {
</div>
))}
{/* Share requests */}
{/* ── Share requests ── */}
{requests.map((request) => (
<div
key={request.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3 mb-2">
<div className="h-7 w-7 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-[10px] shadow-md shrink-0">
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
<div className="flex items-start gap-3">
{/* Avatar */}
<div
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
style={{ background: `linear-gradient(135deg, ${C.blue}, ${C.green})` }}
>
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">
<div className="flex items-center gap-1.5 mb-0.5">
<Share2 className="w-3 h-3" style={{ color: C.blue }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.blue }}>
Partage
</span>
</div>
<p className="text-[13px] font-semibold truncate">
{request.sharer.name || request.sharer.email}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
<p className="text-[11px] text-foreground/50 truncate">
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
</p>
</div>
</div>
<div className="flex gap-2 mt-2">
<div className="flex gap-2 ml-11">
<button
onClick={() => handleDecline(request.id)}
className={cn(
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
"border border-border bg-background",
"text-muted-foreground",
"hover:bg-muted hover:text-foreground",
"transition-all duration-200",
"flex items-center justify-center gap-1",
"active:scale-95"
)}
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
>
<X className="h-3 w-3" />
{t('notification.decline') || t('general.cancel')}
{t('notification.decline') || 'Refuser'}
</button>
<button
onClick={() => handleAccept(request.id)}
className={cn(
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
"bg-primary text-primary-foreground",
"hover:bg-primary/90",
"shadow-sm",
"transition-all duration-200",
"flex items-center justify-center gap-1",
"active:scale-95"
)}
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
style={{ background: C.blue }}
>
<Check className="h-3 w-3" />
{t('notification.accept') || t('general.confirm')}
{t('notification.accept') || 'Accepter'}
</button>
</div>
</div>
@@ -389,14 +411,15 @@ export function NotificationPanel() {
</div>
)}
{/* Footer link to reminders page */}
{/* Footer */}
{activeReminders.length > 0 && (
<div className="px-4 py-2 border-t bg-muted/30">
<div className="px-4 py-2.5 border-t bg-black/[0.02]">
<a
href="/reminders"
className="text-[11px] font-medium text-primary hover:underline"
className="text-[11px] font-semibold hover:opacity-70 transition-opacity"
style={{ color: C.blue }}
>
{t('reminders.viewAll') || t('reminders.title') || 'Voir tous les rappels'}
{t('reminders.viewAll') || 'Voir tous les rappels'}
</a>
</div>
)}

View File

@@ -14,6 +14,10 @@ import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import Superscript from '@tiptap/extension-superscript'
import Subscript from '@tiptap/extension-subscript'
import Typography from '@tiptap/extension-typography'
@@ -26,7 +30,8 @@ import {
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen } from 'lucide-react'
SpellCheck, Languages, BookOpen, Presentation
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -45,13 +50,13 @@ interface RichTextEditorProps {
type SlashItem = {
title: string
description: string
icon: typeof Bold
icon: any
category?: string
shortcut?: string
isImage?: boolean
isAi?: boolean
aiOption?: 'clarify' | 'shorten' | 'improve'
command: (editor: Editor) => void
command: (editor: Editor, range?: any) => void
}
const CustomImage = Image.extend({
@@ -71,28 +76,50 @@ const CustomImage = Image.extend({
})
const slashCommands: SlashItem[] = [
// Basic blocks (indices 0-9)
// Basic blocks
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
// Media (index 10)
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => {} },
// Formatting (indices 11-13) — super/subscript removed, use BubbleMenu
// Media
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
// Formatting
{ title: 'Align Left', description: 'Align text left', icon: AlignLeft, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('left').run() },
{ title: 'Align Center', description: 'Center text', icon: AlignCenter, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('center').run() },
{ title: 'Align Right', description: 'Align text right', icon: AlignRight, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('right').run() },
// IA Note (indices 14-17)
{ title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
{ title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => {} },
{ title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => {} },
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
// IA Note
{ title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } },
{ title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => { } },
{ title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => { } },
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } },
// Formatting extensions
{ title: 'Bold', description: 'Make text bold', icon: Bold, category: 'Formatting', command: (e) => e.chain().focus().toggleBold().run() },
{ title: 'Italic', description: 'Make text italic', icon: Italic, category: 'Formatting', command: (e) => e.chain().focus().toggleItalic().run() },
{ title: 'Underline', description: 'Underline text', icon: UnderlineIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleUnderline().run() },
{ title: 'Strike', description: 'Strikethrough text', icon: Strikethrough, category: 'Formatting', command: (e) => e.chain().focus().toggleStrike().run() },
{ title: 'Highlight', description: 'Highlight text', icon: Highlighter, category: 'Formatting', command: (e) => e.chain().focus().toggleHighlight().run() },
{ title: 'Superscript', description: 'Text above the baseline', icon: SuperscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSuperscript().run() },
{ title: 'Subscript', description: 'Text below the baseline', icon: SubscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSubscript().run() },
// AI Tools
{
title: 'Diagramme', description: 'Générer un diagramme Excalidraw', icon: BookOpen, category: 'IA Note', command: (e) => {
const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'diagram' } })
window.dispatchEvent(event)
}
},
{
title: 'Présentation', description: 'Générer des slides HTML/PPTX', icon: Presentation, category: 'IA Note', command: (e) => {
const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'slides' } })
window.dispatchEvent(event)
}
},
]
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
@@ -146,6 +173,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
Superscript,
Subscript,
Typography,
@@ -202,7 +233,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
editor={editor}
className="notion-bubble-menu"
{...({
tippyOptions: {
tippyOptions: {
appendTo: () => document.body,
zIndex: 99999,
fallbackPlacements: ['bottom', 'top']
@@ -278,7 +309,7 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
)
}
const AI_LANGS = ['Francais','English','Espanol','Deutsch','Persan','Portugais','Italiano','Chinois','Japonais']
const AI_LANGS = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais']
function BubbleToolbar({ editor }: { editor: Editor | null }) {
const { t, language } = useLanguage()
@@ -472,10 +503,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
const [aiLoading, setAiLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const selectedItemRef = useRef<HTMLButtonElement>(null)
// Flag: true while user is interacting with the menu (prevents selectionUpdate from closing it)
const menuInteracting = useRef(false)
// Translated category names (keys match slashCommands category field)
const CAT_LABELS: Record<string, string> = {
'Basic blocks': t('richTextEditor.slashCatBasic'),
'Media': t('richTextEditor.slashCatMedia'),
@@ -483,26 +512,35 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
'IA Note': t('richTextEditor.slashCatAi'),
}
// Translated command list (keeps same order/icons/shortcuts as global slashCommands)
const localCommands: SlashItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: 'Basic blocks' },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: 'Basic blocks' },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: 'Basic blocks' },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: 'Basic blocks' },
{ ...slashCommands[4], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: 'Basic blocks' },
{ ...slashCommands[5], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: 'Basic blocks' },
{ ...slashCommands[6], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: 'Basic blocks' },
{ ...slashCommands[7], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: 'Basic blocks' },
{ ...slashCommands[8], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: 'Basic blocks' },
{ ...slashCommands[9], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: 'Basic blocks' },
{ ...slashCommands[10], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: 'Media' },
{ ...slashCommands[11], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: 'Formatting' },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: 'Formatting' },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: 'Formatting' },
{ ...slashCommands[14], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: 'IA Note' },
{ ...slashCommands[15], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: 'IA Note' },
{ ...slashCommands[16], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: 'IA Note' },
{ ...slashCommands[17], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: 'IA Note' },
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: t('richTextEditor.slashCatMedia') },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), category: t('richTextEditor.slashCatAi') },
]
const closeMenu = useCallback(() => {
@@ -536,13 +574,11 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
}
}, [editor, closeMenu, deleteSlashText, onInsertImage])
// All category names in order
const allCategories = Array.from(new Set(localCommands.map(c => c.category || 'Basic blocks')))
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
const filtered = activeCategory ? textFiltered.filter(c => (c.category || 'Basic blocks') === activeCategory) : textFiltered
// Compute categories based on full search to keep tabs visible even when one is selected
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
const cat = item.category || 'Basic blocks'
if (!acc[cat]) acc[cat] = []
@@ -571,8 +607,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
e.preventDefault()
const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])]
const currentIndex = availableTabs.indexOf(activeCategory)
const nextIndex = e.key === 'ArrowRight'
? (currentIndex + 1) % availableTabs.length
const nextIndex = e.key === 'ArrowRight'
? (currentIndex + 1) % availableTabs.length
: (currentIndex - 1 + availableTabs.length) % availableTabs.length
setActiveCategory(availableTabs[nextIndex])
setSelectedIndex(0)
@@ -602,8 +638,17 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
if (!isOpen) return
const { from } = editor.state.selection
const c = editor.view.coordsAtPos(from)
setCoords({ top: c.bottom + 8, left: c.left })
}, [isOpen, editor, query])
// Check if menu would overflow bottom
const menuHeight = menuRef.current?.offsetHeight || 300
const wouldOverflow = c.bottom + menuHeight + 20 > window.innerHeight
if (wouldOverflow) {
setCoords({ top: c.top - menuHeight - 8, left: c.left })
} else {
setCoords({ top: c.bottom + 8, left: c.left })
}
}, [isOpen, editor, query, filtered.length])
useEffect(() => {
const handleClick = (e: MouseEvent) => {

View File

@@ -40,10 +40,10 @@ export function SettingsNav({ className }: SettingsNavProps) {
key={section.id}
href={section.href}
className={cn(
'flex items-center gap-2 pb-3 pt-4 text-xs font-semibold uppercase tracking-wider transition-colors whitespace-nowrap border-b-2',
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
isActive(section.href)
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
? 'border-[#D4A373] text-[#1C1C1C]'
: 'border-transparent text-[#1C1C1C]/40 hover:text-[#1C1C1C]'
)}
>
{section.icon}

View File

@@ -21,6 +21,8 @@ import {
LogOut,
Shield,
GripVertical,
Users,
Bell,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -60,7 +62,7 @@ function NoteLink({
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={cn(
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg',
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg text-left',
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
)}
>
@@ -68,7 +70,7 @@ function NoteLink({
'w-1.5 h-1.5 rounded-full shrink-0',
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
)} />
<span className="truncate">{title}</span>
<span className="break-words line-clamp-2 leading-tight">{title}</span>
</motion.button>
)
}
@@ -333,7 +335,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<aside
className={cn(
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
'border-e border-border/40 bg-[#F6F4F0] backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
className
)}
>
@@ -346,11 +348,11 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
className="shrink-0 rounded-full outline-none ring-offset-background transition-shadow hover:ring-2 hover:ring-primary/30 focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t('sidebar.accountMenu') || 'Menu du compte'}
>
<div className="w-10 h-10 rounded-full bg-muted border border-border flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm">
<div className="w-10 h-10 rounded-full bg-[#E9ECEF] border border-black/10 flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm">
{user?.image ? (
<Avatar className="size-10 ring-1 ring-border/60">
<AvatarImage src={user.image} alt="" />
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initial}</AvatarFallback>
<AvatarFallback className="bg-[#E9ECEF] text-sm font-semibold text-[#1C1C1C]/60">{initial}</AvatarFallback>
</Avatar>
) : (
<span>{initial}</span>
@@ -485,6 +487,48 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{t('sidebar.inbox') || 'Inbox'}
</span>
</button>
{/* Partagées avec moi */}
<Link
href="/shared"
className={cn('sidebar-inbox-item', pathname === '/shared' && 'active')}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
pathname === '/shared'
? 'bg-foreground text-background border-foreground'
: 'bg-white/60 text-foreground border-border'
)}>
<Users size={14} />
</div>
<span className={cn(
'text-[13px] font-medium truncate',
pathname === '/shared' ? 'text-foreground' : 'text-muted-foreground'
)}>
{t('sidebar.sharedWithMe') || 'Partagées avec moi'}
</span>
</Link>
{/* Rappels */}
<Link
href="/reminders"
className={cn('sidebar-inbox-item', pathname === '/reminders' && 'active')}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
pathname === '/reminders'
? 'bg-foreground text-background border-foreground'
: 'bg-white/60 text-foreground border-border'
)}>
<Bell size={14} />
</div>
<span className={cn(
'text-[13px] font-medium truncate',
pathname === '/reminders' ? 'text-foreground' : 'text-muted-foreground'
)}>
{t('sidebar.reminders') || 'Rappels'}
</span>
</Link>
{/* Divider */}
<div className="mx-4 my-3 h-px bg-border/40" />
@@ -500,27 +544,37 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const notes = notebookNotes[notebook.id] || []
const isDragging = draggedId === notebook.id
return (
<div
<motion.div
key={notebook.id}
draggable
onDragStart={(e) => handleDragStart(e, notebook.id)}
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragEnd={handleDragEnd}
layout
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
mass: 0.8
}}
>
<SidebarCarnetItem
carnet={{
id: notebook.id,
name: notebook.name,
initial: notebook.name.charAt(0).toUpperCase(),
}}
isActive={isActive}
notes={notes}
activeNoteId={currentNoteId}
onCarnetClick={() => handleCarnetClick(notebook.id)}
onNoteClick={handleNoteClick}
isDragging={isDragging}
/>
</div>
<div
draggable
onDragStart={(e) => handleDragStart(e, notebook.id)}
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragEnd={handleDragEnd}
>
<SidebarCarnetItem
carnet={{
id: notebook.id,
name: notebook.name,
initial: notebook.name.charAt(0).toUpperCase(),
}}
isActive={isActive}
notes={notes}
activeNoteId={currentNoteId}
onCarnetClick={() => handleCarnetClick(notebook.id)}
onNoteClick={handleNoteClick}
isDragging={isDragging}
/>
</div>
</motion.div>
)
})}
@@ -548,7 +602,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{[
{ id: 'agents', href: '/agents', label: t('agents.myAgents') || 'Mes Agents', icon: Bot },
{ id: 'lab', href: '/lab', label: t('nav.lab') || 'Le Lab AI', icon: FlaskConical },
{ id: 'chat', href: '/chat', label: t('nav.chat') || 'Conversations', icon: MessageSquare },
].map(item => {
const isActive = pathname.startsWith(item.href)
return (
@@ -572,17 +625,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</Link>
)
})}
{/* General Chat button (opens floating panel) */}
<button
onClick={() => window.dispatchEvent(new Event('toggle-ai-chat'))}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group text-muted-foreground hover:bg-white/40 hover:text-foreground"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0 bg-white/60 border-border group-hover:border-foreground/20">
<Sparkles size={16} />
</div>
<span className="text-[13px] font-medium">{t('ai.openAssistant') || 'Assistant IA'}</span>
</button>
</div>
</motion.div>
)}
@@ -593,21 +635,21 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className="pt-4 p-5 border-t border-border space-y-1">
<Link
href="/archive"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
>
<Archive size={16} />
<span>{t('sidebar.archive') || 'Archives'}</span>
</Link>
<Link
href="/trash"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
>
<Trash2 size={16} />
<span>{t('sidebar.trash') || 'Corbeille'}</span>
</Link>
<Link
href="/settings"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
>
<Settings size={16} />
<span>{t('nav.settings') || 'Paramètres'}</span>

View File

@@ -36,7 +36,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-[#1C1C1C]/40 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}

View File

@@ -39,7 +39,7 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
"flex h-full w-full items-center justify-center rounded-full bg-[#E9ECEF]",
className
)}
{...props}

View File

@@ -38,7 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-[#1C1C1C]/40 backdrop-blur-sm",
className
)}
{...props}

View File

@@ -18,18 +18,18 @@ export function Toaster() {
toastOptions={{
classNames: {
toast: [
'toast pointer-events-auto',
'!bg-[#1C1C1C] !text-[#F2F0E9] !border !border-white/10',
'!rounded-xl !shadow-xl !shadow-black/30',
'!text-[13px] !font-medium !py-3 !px-4',
'toast pointer-events-auto border-none',
'bg-[var(--color-memento-ink)] text-[var(--color-memento-paper)]',
'rounded-xl shadow-acrylic',
'text-[13px] font-medium py-3 px-4',
].join(' '),
description: '!text-[#F2F0E9]/70 !text-[12px]',
actionButton: '!bg-[#F2F0E9] !text-[#1C1C1C] !text-[11px] !font-bold !rounded-lg !px-3 !py-1',
closeButton: '!bg-white/10 !text-[#F2F0E9]/70 !border-white/10 hover:!bg-white/20',
success: '!border-l-4 !border-l-emerald-400/70',
error: '!border-l-4 !border-l-red-400/70',
warning: '!border-l-4 !border-l-amber-400/70',
info: '!border-l-4 !border-l-sky-400/70',
description: 'text-[var(--color-memento-paper)]/70 text-[12px]',
actionButton: 'bg-[var(--color-memento-paper)] text-[var(--color-memento-ink)] text-[11px] font-bold rounded-lg px-4 py-1.5 hover:opacity-90 transition-opacity',
closeButton: 'bg-white/10 text-[var(--color-memento-paper)]/70 border-white/10 hover:bg-white/20',
success: 'border-l-4 border-l-emerald-400',
error: 'border-l-4 border-l-red-400',
warning: 'border-l-4 border-l-amber-400',
info: 'border-l-4 border-l-sky-400',
},
}}
/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -910,15 +910,7 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
## STRICT RULES
1. **4 to 10 nodes** — no less, no more
2. **First node = ellipse** (entry point)
3. **ALL nodes connected** — every node must have at least 1 incoming or outgoing edge
4. **Short labels** — max 40 chars per label
5. **Edge labels** — add labels on important arrows to explain the relationship
6. **Use "diamond"** for at least one node if the content involves a decision
7. **No orphan nodes** — every node must be reachable from the central node
8. **Analyze content first** — identify key concepts and their relationships BEFORE creating JSON
9. **Call generate_excalidraw DIRECTLY**, do not respond with text`,
1. **4 to 10 nodes** — no less, no more`,
},
'slide-generator': {
@@ -931,42 +923,22 @@ RÈGLES DE DESIGN IMPÉRATIVES :
- Slide 1 : "title" (titre fort + sous-titre accrocheur)
- Slide 2 : "toc" (sommaire numéroté)
- Utilise AU MOINS 2 layouts "diagramme" parmi : "timeline", "process", "metrics", "comparison"
- "timeline" : étapes chronologiques ou roadmap (content items : "Étape: description")
- "process" : étapes numérotées avec détails (content items : "Action: explication")
- "metrics" : KPIs visuels avec grandes valeurs colorées (content items : "VALEUR: libellé")
- "comparison" : deux colonnes contrastées (subtitle="Avant | Après" ou "Option A | Option B")
- "cards" : fonctionnalités en grille 2-3 colonnes (3-6 items)
- "section" : séparateur de section (title=titre, content=[] — le numéro est auto-généré)
- "quote" : citation impactante (title=texte, subtitle=auteur)
- "summary" : récapitulatif final
- "content" : liste de points SEULEMENT si aucun layout visuel n'est adapté (max 7 points)
- Ne JAMAIS répéter le même layout consécutivement
- Pour "section" : ne pas mettre le numéro dans content, laisser content=[]
- Thèmes recommandés pour un rendu moderne : vibrant_tech, platinum_white_gold, business_authority, pure_tech_blue, tech_night
- Thèmes recommandés : architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
- Tu DOIS utiliser le thème et le style spécifiés dans la requête de l'utilisateur.
- Points concis (max 100 chars), titres percutants et courts
- JSON strict pour generate_pptx, sans texte hors JSON.`,
en: `You are a world-class visual presentation designer (Manus AI / Beautiful.ai style). You receive note content and must create a professional, modern, visually rich PowerPoint (.pptx) presentation.
You MUST call the generate_pptx tool. NEVER respond with text — call the tool directly.
MANDATORY DESIGN RULES:
IMPERATIVE DESIGN RULES:
- 8-12 slides, each slide has a distinct layout
- Slide 1: "title" (strong title + punchy subtitle)
- Slide 1: "title" (strong title + catchy subtitle)
- Slide 2: "toc" (numbered table of contents)
- Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison"
- "timeline": chronological steps or roadmap (content items: "Step: description")
- "process": numbered steps with details (content items: "Action: explanation")
- "metrics": visual KPIs with large colored values (content items: "VALUE: label")
- "comparison": two contrasting columns (subtitle="Before | After" or "Option A | Option B")
- "cards": feature grid 2-3 columns (3-6 items)
- "section": section divider (title=heading, content=[] — number is auto-generated)
- "quote": impactful quote (title=text, subtitle=author)
- "summary": closing recap
- "content": bullet list ONLY if no visual layout fits (max 7 points)
- NEVER repeat the same layout consecutively
- For "section": do NOT put numbers in content, leave content=[]
- Recommended themes for modern look: vibrant_tech, platinum_white_gold, business_authority, pure_tech_blue, tech_night
- Concise points (max 100 chars), short impactful titles
- Recommended themes: architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
- You MUST use the theme and style specified in the user's request.
- Concise points (max 100 chars), punchy and short titles
- Strict JSON for generate_pptx, no text outside JSON.`,
},
}

View File

@@ -437,10 +437,11 @@ Deine Antwort (nur JSON):
const label = await tx.label.upsert({
where: { notebookId_name: { notebookId, name: suggestedLabel.name } as any },
update: {},
update: { type: 'ai' }, // Update type to AI if it exists as user label
create: {
name: suggestedLabel.name,
color: 'gray',
type: 'ai', // Mark as AI-generated
notebookId,
userId,
},

View File

@@ -60,6 +60,8 @@ const PALETTES: Record<string, Theme> = {
coastal_coral: { primary: '005f73', secondary: '0a9396', accent: 'ee9b00', light: 'e9f5f5', bg: 'ffffff' },
vibrant_orange_mint: { primary: 'e05c00', secondary: '2ec4b6', accent: 'ff9f1c', light: 'edfaf9', bg: 'ffffff' },
platinum_white_gold: { primary: '0a0a0a', secondary: '404040', accent: 'c9a84c', light: 'f5f5f0', bg: 'ffffff' },
architectural_mono: { primary: '1C1C1C', secondary: '75B2D6', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' },
minimal_silk: { primary: '212529', secondary: '6c757d', accent: 'dee2e6', light: 'f8f9fa', bg: 'ffffff' },
}
const PALETTE_ALIASES: Record<string, string> = {
@@ -68,6 +70,7 @@ const PALETTE_ALIASES: Record<string, string> = {
ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
architectural: 'architectural_mono', silk: 'minimal_silk',
}
function resolveTheme(spec: PresentationSpec): { theme: Theme; key: string } {
@@ -1085,13 +1088,13 @@ LAYOUTS — choose the most visual for each slide:
- summary: closing key takeaways
RULES:
- Use the THEME and STYLE provided in the prompt context.
- First slide MUST be "title"
- Second slide: "toc"
- Use "section" as dividers between major topics
- Prefer DIAGRAM layouts (timeline, process, metrics, comparison) over plain content
- Use at least 2 diagram layouts per presentation
- 8-12 slides, never repeat same layout consecutively
- For "section" layout: title = section heading, content = [] (the slide number is auto-generated)
- All text content: max 100 chars per item, concise and impactful`,
inputSchema: z.object({

View File

@@ -50,6 +50,8 @@ const PALETTES: Record<string, Palette> = {
coastal_coral: { primary: '#0081a7', secondary: '#00afb9', accent: '#f07167', light: '#fed9b7', bg: '#fdfcdc', isDark: false },
vibrant_orange_mint: { primary: '#1a1a2e', secondary: '#2ec4b6', accent: '#ff9f1c', light: '#cbf3f0', bg: '#ffffff', isDark: false },
platinum_white_gold: { primary: '#0a0a0a', secondary: '#0070F3', accent: '#D4AF37', light: '#f5f5f5', bg: '#ffffff', isDark: false },
architectural_mono: { primary: '#1C1C1C', secondary: '#D4A373', accent: '#ACB995', light: '#F9F8F6', bg: '#F9F8F6', isDark: false },
minimal_silk: { primary: '#212529', secondary: '#6c757d', accent: '#dee2e6', light: '#f8f9fa', bg: '#ffffff', isDark: false },
}
const PALETTE_ALIASES: Record<string, string> = {
@@ -58,6 +60,7 @@ const PALETTE_ALIASES: Record<string, string> = {
ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
architectural: 'architectural_mono', silk: 'minimal_silk',
}
const THEME_NAMES: Record<string, string> = {
@@ -70,6 +73,7 @@ const THEME_NAMES: Record<string, string> = {
art_food: 'Art & Food', luxury_mystery: 'Luxury & Mystery',
pure_tech_blue: 'Pure Tech Blue', coastal_coral: 'Coastal Coral',
vibrant_orange_mint: 'Vibrant Orange Mint', platinum_white_gold: 'Platinum White Gold',
architectural_mono: 'Architectural Mono', minimal_silk: 'Minimal Silk',
}
function resolvePalette(spec: PresentationSpec): { palette: Palette; key: string } {
@@ -100,7 +104,7 @@ function safeHtml(str: string): string {
.replace(/javascript\s*:/gi, '')
}
function buildThemeCSS(p: Palette, radius: string): string {
function buildThemeCSS(p: Palette, radius: string, key: string): string {
const text = p.isDark ? '#f0f0f0' : '#1a1a1a'
const muted = p.isDark ? '#999' : '#555'
const heading = p.isDark ? '#ffffff' : p.primary
@@ -145,12 +149,40 @@ function buildThemeCSS(p: Palette, radius: string): string {
--r-link-color-hover: ${p.secondary};
--r-selection-background-color: ${p.accent};
--r-selection-color: ${bgText};
}`
}
${key === 'architectural_mono' ? `
.reveal-viewport {
background-color: #F9F8F6 !important;
background-image:
linear-gradient(rgba(28, 28, 28, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(28, 28, 28, 0.08) 1px, transparent 1px),
linear-gradient(rgba(28, 28, 28, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(28, 28, 28, 0.04) 1px, transparent 1px) !important;
background-size: 100px 100px, 100px 100px, 20px 20px, 20px 20px !important;
}
.reveal {
font-family: 'JetBrains Mono', monospace !important;
}
.reveal h1, .reveal h2, .reveal h3 {
font-family: 'JetBrains Mono', monospace !important;
text-transform: uppercase !important;
letter-spacing: -0.02em !important;
font-weight: 700 !important;
}
.reveal h1 { border-left: 12px solid #D4A373; padding-left: 40px; }
.reveal section { text-align: left; padding: 60px; }
.reveal p, .reveal li { font-weight: 300; font-family: 'JetBrains Mono', monospace !important; }
` : ''}`
}
function buildLayoutCSS(): string {
return `
.reveal-viewport { background: var(--p-bg); }
.reveal-viewport {
background: var(--p-bg);
background-image: linear-gradient(var(--p-border) 1px, transparent 1px), linear-gradient(90deg, var(--p-border) 1px, transparent 1px);
background-size: 40px 40px;
}
.reveal {
font-family: var(--r-main-font);
@@ -296,10 +328,14 @@ function buildLayoutCSS(): string {
}
.reveal .s-cards .card:nth-child(odd) {
background: var(--p-primary); border-color: transparent;
box-shadow: 0 10px 20px rgba(0,0,0,0.15);
}
.reveal .s-cards .card:nth-child(odd) .card-num { color: rgba(255,255,255,0.25); }
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; }
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.5; }
.reveal .s-cards .card:nth-child(odd) .card-num { color: rgba(255,255,255,0.4); }
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; font-weight: 300; }
.reveal .s-cards .card:nth-child(even) {
background: #ffffff; border: 1px solid var(--p-border-accent);
}
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.6; }
.reveal .s-cards .card:nth-child(even) .card-text { color: var(--p-text); }
.reveal .s-cards .card-num {
font-size: 18pt; font-weight: 800; line-height: 1;
@@ -557,7 +593,7 @@ function buildRevealHtml(spec: PresentationSpec): string {
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<style>
${buildThemeCSS(palette, radius)}
${buildThemeCSS(palette, radius, key)}
${buildLayoutCSS()}
</style>

View File

@@ -1,225 +1,19 @@
import { marked } from 'marked'
/**
* Server-side Markdown → HTML converter.
* Converts AI-generated markdown notes into TipTap-compatible rich text HTML.
* Uses a lightweight regex-based approach to avoid heavy remark/rehype dependencies.
*
* Handles: headings, bold, italic, strikethrough, code blocks, inline code,
* links, images, lists (ul/ol), blockquotes, horizontal rules, tables, paragraphs.
* Uses 'marked' for standard GFM compliance and reliability.
*/
export function markdownToHtml(markdown: string): string {
if (!markdown || !markdown.trim()) return ''
let html = markdown
// Escape HTML entities (but preserve markdown)
html = html.replace(/&/g, '&amp;')
html = html.replace(/</g, '&lt;')
html = html.replace(/>/g, '&gt;')
// Code blocks (``` ... ```) — protect from further processing
const codeBlocks: string[] = []
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
const idx = codeBlocks.length
codeBlocks.push(`<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`)
return `%%CODEBLOCK_${idx}%%`
})
// Inline code (`...`)
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Images (![alt](url))
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
// Links ([text](url))
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Headings (h1-h6)
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
// Bold (**text** or __text__)
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>')
// Italic (*text* or _text_)
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>')
// Strikethrough (~~text~~)
html = html.replace(/~~([^~]+)~~/g, '<s>$1</s>')
// Horizontal rules (---, ***, ___)
html = html.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr />')
// Tables
html = convertTables(html)
// Blockquotes (> text)
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote><p>$1</p></blockquote>')
// Merge consecutive blockquotes
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n')
// Unordered lists (- item or * item)
html = convertUnorderedLists(html)
// Ordered lists (1. item)
html = convertOrderedLists(html)
// Restore code blocks
codeBlocks.forEach((block, idx) => {
html = html.replace(`%%CODEBLOCK_${idx}%%`, block)
})
// Paragraphs — wrap remaining loose text in <p> tags
html = wrapParagraphs(html)
// Clean up empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '')
// marked.parse returns a string (or a promise if async is true, but we use sync)
const html = marked.parse(markdown, {
gfm: true,
breaks: true,
}) as string
return html.trim()
}
function convertTables(html: string): string {
// Simple table conversion: | header | header |\n| --- | --- |\n| cell | cell |
const tableRegex = /(?:^|\n)((?:\|[^\n]+\|\n)+)/g
return html.replace(tableRegex, (match) => {
const rows = match.trim().split('\n').filter(r => r.trim())
if (rows.length < 2) return match
// Check if second row is separator
const separator = rows[1].trim()
if (!/^[\s|:-]+$/.test(separator)) return match
let table = '<table>'
// Header row
const headers = parseTableRow(rows[0])
if (headers.length > 0) {
table += '<thead><tr>'
headers.forEach(h => { table += `<th>${h}</th>` })
table += '</tr></thead>'
}
// Body rows (skip separator)
const bodyRows = rows.slice(2)
if (bodyRows.length > 0) {
table += '<tbody>'
bodyRows.forEach(row => {
const cells = parseTableRow(row)
table += '<tr>'
cells.forEach(c => { table += `<td>${c}</td>` })
table += '</tr>'
})
table += '</tbody>'
}
table += '</table>'
return '\n' + table + '\n'
})
}
function parseTableRow(row: string): string[] {
return row.split('|')
.map(cell => cell.trim())
.filter((_, i, arr) => i > 0 && i < arr.length) // Skip first and last empty from leading/trailing |
}
function convertUnorderedLists(html: string): string {
const lines = html.split('\n')
const result: string[] = []
let inList = false
for (const line of lines) {
const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/)
if (listMatch) {
if (!inList) {
result.push('<ul>')
inList = true
}
result.push(`<li>${listMatch[2]}</li>`)
} else {
if (inList) {
result.push('</ul>')
inList = false
}
result.push(line)
}
}
if (inList) result.push('</ul>')
return result.join('\n')
}
function convertOrderedLists(html: string): string {
const lines = html.split('\n')
const result: string[] = []
let inList = false
for (const line of lines) {
const listMatch = line.match(/^(\s*)\d+\.\s+(.+)$/)
if (listMatch) {
if (!inList) {
result.push('<ol>')
inList = true
}
result.push(`<li>${listMatch[2]}</li>`)
} else {
if (inList) {
result.push('</ol>')
inList = false
}
result.push(line)
}
}
if (inList) result.push('</ol>')
return result.join('\n')
}
function wrapParagraphs(html: string): string {
const blockTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'p', 'div', 'img'])
const lines = html.split('\n')
const result: string[] = []
let buffer: string[] = []
const flushBuffer = () => {
const text = buffer.join('\n').trim()
if (text) {
// Don't double-wrap if already starts with a block tag
const firstTag = text.match(/^<(\w+)/)
if (firstTag && blockTags.has(firstTag[1].toLowerCase())) {
result.push(text)
} else {
result.push(`<p>${text}</p>`)
}
}
buffer = []
}
for (const line of lines) {
const trimmed = line.trim()
// Check if this line is a block-level element
const isBlockLine = trimmed.startsWith('<') && (() => {
const tag = trimmed.match(/^<(\w+)/)
return tag ? blockTags.has(tag[1].toLowerCase()) : false
})()
if (isBlockLine || trimmed === '') {
flushBuffer()
if (isBlockLine) result.push(trimmed)
} else {
buffer.push(trimmed)
}
}
flushBuffer()
return result.join('\n')
}

View File

@@ -28,6 +28,7 @@ export interface Label {
id: string;
name: string;
color: LabelColorName;
type?: 'ai' | 'user'; // "ai" for auto-generated, "user" for manually created
notebookId: string | null; // NEW: Belongs to a notebook
userId?: string | null; // DEPRECATED: Kept for backward compatibility
createdAt: Date;

View File

@@ -448,43 +448,36 @@
"explain": "Explain"
},
"generate": {
"sectionLabel": "Generate from this note",
"slides": "Generate a presentation",
"diagram": "Generate a diagram",
"loading": "Generating…",
"slidesReady": "Presentation generated!",
"diagramReady": "Diagram generated!",
"downloadPptx": "Download .pptx",
"openDiagram": "Open in Lab",
"error": "Error during generation",
"noNoteId": "Save the note first",
"slides": "Generate Slides",
"sectionLabel": "Generation Tools",
"theme": "Theme",
"themeArchitecturalMono": "Architectural Mono",
"themeVibrantTech": "Vibrant Tech",
"themeMinimalSilk": "Minimal Silk",
"style": "Style",
"diagramType": "Type",
"typeAuto": "Auto",
"styleSoft": "Soft",
"styleSharp": "Sharp",
"styleRounded": "Rounded",
"stylePill": "Pill",
"styleProfessional": "Professional",
"diagram": "Generate Diagram",
"diagramReadyHint": "Convert note into visual flow",
"diagramType": "Diagram Type",
"typeAuto": "Auto-detect",
"typeFlowchart": "Flowchart",
"typeMindMap": "Mind Map",
"typeTimeline": "Timeline",
"typeOrgChart": "Org Chart",
"typeArchitecture": "Architecture",
"typeProcessMap": "Process Map",
"styleSketchy": "Sketchy",
"styleAustere": "Austere",
"styleSketchPlus": "Sketch+",
"toastLoading": {
"slides": "⏳ Generating presentation…",
"diagram": "⏳ Generating diagram…"
},
"toastLoadingDesc": "You can navigate freely, a notification will appear.",
"toastSuccessSlides": "Click Download in the AI panel.",
"toastSuccessDiagram": "Your diagram is available in the Lab.",
"diagramReadyHint": "Use the panel below: Excalidraw or insert into the note.",
"openInExcalidraw": "Open in Excalidraw (Lab)",
"insertDiagramInNote": "Insert as image into note",
"insertNeedEditor": "Can't insert here — open a note with the assistant or use the Lab.",
"insertFetchError": "Couldn't load the diagram.",
"insertExportError": "Failed to export the diagram as an image.",
"insertUploadError": "Failed to upload the image.",
"diagramImageAlt": "Generated diagram",
"insertedInNote": "Diagram added to the note"
"styleSoft": "Soft",
"styleMinimal": "Minimal",
"styleDraft": "Draft",
"stylePolished": "Polished",
"styleHandwritten": "Handwritten",
"diagramReady": "Diagram is ready!",
"openInExcalidraw": "Open in Excalidraw Lab",
"insertDiagramInNote": "Embed PNG in current note",
"diagramImageAlt": "AI Generated Diagram",
"insertedInNote": "Diagram inserted in note",
"insertExportError": "Error exporting/uploading diagram"
},
"openAssistant": "Open AI Assistant",
"poweredByMomento": "Powered by Momento AI",
@@ -689,7 +682,7 @@
"recent": "Recent",
"proPlan": "Pro Plan",
"chat": "AI Chat",
"lab": "The Lab",
"lab": "The Workshop",
"agents": "Agents"
},
"settings": {
@@ -1774,7 +1767,7 @@
"timeoutWarning": "Response is taking longer than expected..."
},
"labHeader": {
"title": "The Lab",
"title": "The Workshop",
"live": "Live",
"currentProject": "Current Project",
"choose": "Choose...",
@@ -1828,6 +1821,12 @@
"slashCodeDesc": "Code snippet",
"slashDivider": "Divider",
"slashDividerDesc": "Horizontal separator",
"slashTable": "Table",
"slashTableDesc": "Insert a simple grid",
"slashDiagram": "Diagram",
"slashDiagramDesc": "Generate a flow or mindmap",
"slashSlides": "Presentation",
"slashSlidesDesc": "Generate a beautiful slide deck",
"slashImage": "Image",
"slashImageDesc": "Embed an image from URL",
"slashAlignLeft": "Align Left",

View File

@@ -227,7 +227,8 @@
"switchTypeTitle": "Changer le type de note ?",
"switchTypeWarning": "Certaines mises en forme peuvent être perdues lors du passage en {type}.",
"switchTypeContentPreserved": "Votre contenu sera préservé en texte brut.",
"switchType": "Passer en {type}"
"switchType": "Passer en {type}",
"saveNow": "Enregistrer maintenant"
},
"pagination": {
"previous": "←",
@@ -411,6 +412,10 @@
"appliedToNote": "Appliqué à la note",
"applyToNote": "Appliquer à la note",
"undoLastAction": "Annuler la dernière action IA",
"transformations": "Transformations",
"otherLanguage": "Autre langue",
"translateNow": "Traduire maintenant",
"generationTools": "Outils de génération",
"selectContext": "Sélectionner le contexte...",
"selectNotebook": "Sélectionner un carnet",
"chatPlaceholder": "Demandez à l'IA de modifier, résumer ou rédiger...",
@@ -442,49 +447,43 @@
"shorten": "Raccourcir",
"improve": "Améliorer",
"toMarkdown": "Convertir en Markdown",
"toRichText": "Convertir en texte enrichi",
"describeImages": "Décrire les images",
"fixGrammar": "Corriger les fautes",
"translate": "Traduire",
"explain": "Expliquer"
},
"generate": {
"sectionLabel": "Générer depuis cette note",
"slides": "Générer une présentation",
"diagram": "Générer un diagramme",
"loading": "Génération en cours…",
"slidesReady": "Présentation générée !",
"diagramReady": "Diagramme généré !",
"downloadPptx": "Télécharger le .pptx",
"openDiagram": "Ouvrir dans le Lab",
"error": "Erreur lors de la génération",
"noNoteId": "Enregistrez d'abord la note",
"slides": "Générer Slides",
"sectionLabel": "Outils de Génération",
"theme": "Thème",
"themeArchitecturalMono": "Architectural Mono",
"themeVibrantTech": "Vibrant Tech",
"themeMinimalSilk": "Minimal Silk",
"style": "Style",
"diagramType": "Type",
"typeAuto": "Auto",
"styleSoft": "Soft",
"styleSharp": "Sharp",
"styleRounded": "Arrondi",
"stylePill": "Pill",
"styleSketchy": "Sketchy",
"styleAustere": "Austère",
"styleSketchPlus": "Sketch+",
"toastLoading": {
"slides": "⏳ Génération de la présentation en cours…",
"diagram": "⏳ Génération du diagramme en cours…"
},
"toastLoadingDesc": "Vous pouvez naviguer librement, une notification apparaîtra.",
"toastSuccessSlides": "Cliquez sur Télécharger dans le panneau IA.",
"toastSuccessDiagram": "Votre diagramme est disponible dans le Lab.",
"diagramReadyHint": "Utilisez le panneau ci-dessous : Excalidraw ou insertion dans la note.",
"openInExcalidraw": "Ouvrir dans Excalidraw (Lab)",
"insertDiagramInNote": "Insérer comme image dans la note",
"insertNeedEditor": "Impossible dinsérer ici — ouvrez une note avec lassistant ou ouvrez le Lab.",
"insertFetchError": "Impossible de récupérer le diagramme.",
"insertExportError": "Erreur lors de lexport du diagramme en image.",
"insertUploadError": "Erreur lors du téléchargement de limage.",
"diagramImageAlt": "Diagramme généré",
"insertedInNote": "Diagramme ajouté à la note"
"styleProfessional": "Professionnel",
"diagram": "Générer Diagramme",
"diagramReadyHint": "Convertir en flux visuel",
"diagramType": "Type de Diagramme",
"typeAuto": "Détection Auto",
"typeFlowchart": "Logigramme",
"typeMindMap": "Carte Mentale",
"typeTimeline": "Chronologie",
"typeOrgChart": "Organigramme",
"typeArchitecture": "Architecture",
"typeProcessMap": "Processus",
"styleSketchy": "Esquisse",
"styleSoft": "Doux",
"styleMinimal": "Minimaliste",
"styleDraft": "Brouillon",
"stylePolished": "Poli",
"styleHandwritten": "Manuscrit",
"diagramReady": "Diagramme prêt !",
"openInExcalidraw": "Ouvrir dans le Lab Excalidraw",
"insertDiagramInNote": "Insérer PNG dans la note",
"diagramImageAlt": "Diagramme généré par IA",
"insertedInNote": "Diagramme inséré dans la note",
"insertExportError": "Erreur lors de l'export/upload du diagramme"
},
"openAssistant": "Ouvrir IA Note",
"poweredByMomento": "Propulsé par Momento AI",
@@ -586,6 +585,12 @@
"slashCodeDesc": "Extrait de code",
"slashDivider": "Séparateur",
"slashDividerDesc": "Séparateur horizontal",
"slashTable": "Tableau",
"slashTableDesc": "Insérer un tableau simple",
"slashDiagram": "Diagramme",
"slashDiagramDesc": "Générer un flux ou une carte mentale",
"slashSlides": "Présentation",
"slashSlidesDesc": "Générer un jeu de diapositives",
"slashImage": "Image",
"slashImageDesc": "Intégrer une image depuis une URL",
"slashAlignLeft": "Aligner à gauche",
@@ -760,7 +765,7 @@
"recent": "Récent",
"proPlan": "Pro Plan",
"chat": "Chat IA",
"lab": "Le Lab",
"lab": "L'Atelier",
"agents": "Agents"
},
"settings": {
@@ -789,7 +794,7 @@
"security": "Sécurité",
"about": "À propos",
"version": "Version",
"settingsSaved": "Paramètres enregistrés",
"settingsSaved": "Paramètres enregistrés avec succès",
"cardSizeMode": "Taille des notes",
"cardSizeModeDescription": "Choisir entre des notes de tailles différentes ou uniformes",
"selectCardSizeMode": "Sélectionner le mode d'affichage",
@@ -813,9 +818,11 @@
"languageAuto": "Langue définie sur Auto",
"emailNotifications": "Notifications par email",
"emailNotificationsDesc": "Recevoir des notifications importantes par email",
"desktopNotifications": "Notifications bureau",
"desktopNotificationsDesc": "Recevoir des notifications dans votre navigateur",
"notificationsDesc": "Gérez vos préférences de notifications"
"desktopNotifications": "Notifications de bureau",
"desktopNotificationsDesc": "Recevoir des alertes sur votre bureau",
"notificationsDesc": "Gérez vos préférences de notifications",
"autoSave": "Auto-enregistrement",
"autoSaveDesc": "Enregistrer automatiquement les modifications pendant la frappe"
},
"profile": {
"title": "Profil",
@@ -1852,7 +1859,7 @@
"timeoutWarning": "La réponse met plus de temps que prévu..."
},
"labHeader": {
"title": "Le Lab",
"title": "L'Atelier",
"live": "Live",
"currentProject": "Projet Actuel",
"choose": "Choisir...",

View File

@@ -36,6 +36,10 @@
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-subscript": "^3.22.5",
"@tiptap/extension-superscript": "^3.22.5",
"@tiptap/extension-table": "^3.22.5",
"@tiptap/extension-table-cell": "^3.22.5",
"@tiptap/extension-table-header": "^3.22.5",
"@tiptap/extension-table-row": "^3.22.5",
"@tiptap/extension-task-item": "^3.22.5",
"@tiptap/extension-task-list": "^3.22.5",
"@tiptap/extension-text-align": "^3.22.5",
@@ -63,6 +67,7 @@
"jsdom": "^29.0.2",
"katex": "^0.16.27",
"lucide-react": "^0.562.0",
"marked": "^18.0.3",
"motion": "^12.38.0",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
@@ -75,6 +80,7 @@
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"resend": "^6.12.0",
@@ -6703,6 +6709,59 @@
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-table": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.5.tgz",
"integrity": "sha512-GMBM07bCwzHx1NK08zXRr2mNTDnP78Hd0VxFsRBIDFddDMZ2qG5jhwKHXN5cHMTrdWokWFUjvnEeJeV3guHoGg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.5.tgz",
"integrity": "sha512-Wn4asCgNLfOPH5EOpiMjzOJXTZvv+TTqUT+gzm2fV69ZkleCGNO0BZwuR/TCIDLGIArbvHzyYy2/lJAfG4UCtg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.5"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.5.tgz",
"integrity": "sha512-aJmbgbO6QbSj0Rw3X4ogGPyd+8FwP6RgG71Dpa3NovzVkqJc3ZUq0wC3XH48U9Hd89F8f4AggFgHjU6/kQAgQQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.5"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.5.tgz",
"integrity": "sha512-9A2BdX+R+P71f192Fo74OttMHj1WoFVO0ezaCzFbT8uNVG3nCJ7B5/1UkTlzqDdGOuWh1VpR63pFZP9LFsUv6A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.5"
}
},
"node_modules/@tiptap/extension-task-item": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.22.5.tgz",
@@ -9367,6 +9426,31 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-raw": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-from-parse5": "^8.0.0",
"hast-util-to-parse5": "^8.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"parse5": "^7.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -9394,6 +9478,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
@@ -9479,6 +9582,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -10407,9 +10520,9 @@
}
},
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -10846,6 +10959,18 @@
"npm": ">=10.2.3"
}
},
"node_modules/mermaid/node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mermaid/node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
@@ -13386,6 +13511,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-raw": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",

View File

@@ -53,6 +53,10 @@
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-subscript": "^3.22.5",
"@tiptap/extension-superscript": "^3.22.5",
"@tiptap/extension-table": "^3.22.5",
"@tiptap/extension-table-cell": "^3.22.5",
"@tiptap/extension-table-header": "^3.22.5",
"@tiptap/extension-table-row": "^3.22.5",
"@tiptap/extension-task-item": "^3.22.5",
"@tiptap/extension-task-list": "^3.22.5",
"@tiptap/extension-text-align": "^3.22.5",
@@ -80,6 +84,7 @@
"jsdom": "^29.0.2",
"katex": "^0.16.27",
"lucide-react": "^0.562.0",
"marked": "^18.0.3",
"motion": "^12.38.0",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
@@ -92,6 +97,7 @@
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"resend": "^6.12.0",

View File

@@ -100,6 +100,7 @@ model Label {
id String @id @default(cuid())
name String
color String @default("gray")
type String @default("user") // "ai" or "user"
notebookId String?
userId String?
createdAt DateTime @default(now())
@@ -282,6 +283,7 @@ model UserAISettings {
noteHistoryMode String @default("manual")
languageDetection Boolean @default(true)
fontFamily String @default("inter")
autoSave Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([memoryEcho])