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 preferredLanguage: string
emailNotifications: boolean emailNotifications: boolean
desktopNotifications: boolean desktopNotifications: boolean
autoSave: boolean
} }
} }
@@ -21,6 +22,7 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto') const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false) const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false) const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
const [autoSave, setAutoSave] = useState(initialSettings.autoSave ?? true)
const handleLanguageChange = async (value: string) => { const handleLanguageChange = async (value: string) => {
setLanguage(value) setLanguage(value)
@@ -47,6 +49,12 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
await updateAISettings({ desktopNotifications: enabled }) await updateAISettings({ desktopNotifications: enabled })
toast.success(t('settings.settingsSaved') || 'Saved') toast.success(t('settings.settingsSaved') || 'Saved')
} }
const handleAutoSaveChange = async (enabled: boolean) => {
setAutoSave(enabled)
await updateAISettings({ autoSave: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
return ( return (
<div className="space-y-8"> <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'}`} /> <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
</button> </button>
</div> </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> </div>
</div> </div>

View File

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

View File

@@ -24,6 +24,7 @@ export type UserAISettingsData = {
noteHistory?: boolean noteHistory?: boolean
noteHistoryMode?: 'manual' | 'auto' noteHistoryMode?: 'manual' | 'auto'
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system' fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
autoSave?: boolean
} }
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */ /** 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', 'noteHistory',
'noteHistoryMode', 'noteHistoryMode',
'fontFamily', 'fontFamily',
'autoSave',
] as const ] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number] type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
@@ -158,6 +160,7 @@ const getCachedAISettings = unstable_cache(
noteHistory: false, noteHistory: false,
noteHistoryMode: 'manual' as const, noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const, fontFamily: 'inter' as const,
autoSave: true,
} }
} }
@@ -192,6 +195,7 @@ const getCachedAISettings = unstable_cache(
noteHistory: settings.noteHistory ?? false, noteHistory: settings.noteHistory ?? false,
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto', noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system', fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
autoSave: settings.autoSave ?? true,
} }
} catch (error) { } catch (error) {
console.error('Error getting AI settings:', error) console.error('Error getting AI settings:', error)
@@ -217,6 +221,7 @@ const getCachedAISettings = unstable_cache(
noteHistory: false, noteHistory: false,
noteHistoryMode: 'manual' as const, noteHistoryMode: 'manual' as const,
fontFamily: 'inter' 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)) { if (Array.isArray(parsed)) {
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase())) parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
} }
} catch {} } catch { }
} }
} }
// Delete labels not in use // Delete labels not in use
@@ -680,91 +680,91 @@ export async function createNote(data: {
const notebookId = data.notebookId const notebookId = data.notebookId
const hasUserLabels = data.labels && data.labels.length > 0 const hasUserLabels = data.labels && data.labels.length > 0
// Use setImmediate-like pattern to not block the response // Use setImmediate-like pattern to not block the response
;(async () => { ; (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) {
try { try {
const userAISettings = await getAISettings(userId) // Background task 1: Generate embedding
const autoLabelingEnabled = userAISettings.autoLabeling !== false const bgConfig = await getSystemConfig()
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70) 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) { console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
// 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 {}
const suggestions = await contextualAutoTagService.suggestLabels( if (autoLabelingEnabled) {
content, // Detect user's language from their existing notes for localized prompts
notebookId, let userLang = 'en'
userId, try {
userLang 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 console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label)
if (appliedLabels.length > 0) { const appliedLabels = suggestions
// Merge with existing labels .filter(s => s.confidence >= autoLabelingConfidence)
const existing = await prisma.note.findUnique({ .map(s => s.label)
where: { id: noteId },
select: { labels: true }, if (appliedLabels.length > 0) {
}) // Merge with existing labels
let existingNames: string[] = [] const existing = await prisma.note.findUnique({
if (existing?.labels) { where: { id: noteId },
try { select: { labels: true },
const parsed = existing.labels as unknown })
existingNames = Array.isArray(parsed) let existingNames: string[] = []
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0) if (existing?.labels) {
: [] try {
} catch { existingNames = [] } const parsed = existing.labels as unknown
} existingNames = Array.isArray(parsed)
const merged = [...new Set([...existingNames, ...appliedLabels])] ? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
await syncNoteLabels(noteId, merged, notebookId ?? null, userId) : []
if (!data.skipRevalidation) { } catch { existingNames = [] }
revalidatePath('/') }
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) { } else {
console.error('[BG] Auto-labeling failed:', error) console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
} }
} else { })()
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
}
})()
return parseNote(note) return parseNote(note)
} catch (error) { } catch (error) {
@@ -819,21 +819,21 @@ export async function updateNote(id: string, data: {
if (data.content !== undefined) { if (data.content !== undefined) {
const noteId = id const noteId = id
const content = data.content const content = data.content
;(async () => { ; (async () => {
try { try {
const provider = getAIProvider(await getSystemConfig()); const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content); const embedding = await provider.getEmbeddings(content);
if (embedding) { if (embedding) {
await prisma.noteEmbedding.upsert({ await prisma.noteEmbedding.upsert({
where: { noteId: noteId }, where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) }, create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { 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 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 userId = session.user.id
const body = await req.json() const body = await req.json()
const { noteId, type, theme, style } = body as { const { noteId, type, theme, style, language } = body as {
noteId: string noteId: string
type: GenerateType type: GenerateType
theme?: string theme?: string
style?: string style?: string
language?: string
} }
if (!noteId || !type || !TYPE_DEFAULTS[type]) { if (!noteId || !type || !TYPE_DEFAULTS[type]) {
@@ -50,15 +51,26 @@ export async function POST(req: NextRequest) {
} }
const defaults = TYPE_DEFAULTS[type] 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' const agentName = type === 'slide-generator'
? `Slides${(note.title || 'Note').substring(0, 40)}` ? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}`
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}` : `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}`
const agent = await prisma.agent.create({ const agent = await prisma.agent.create({
data: { data: {
name: agentName, name: agentName,
type, type,
role: defaults.role, role,
tools: JSON.stringify(defaults.tools), tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps, maxSteps: defaults.maxSteps,
frequency: 'one-shot', frequency: 'one-shot',

View File

@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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') { if (!resourceText || typeof resourceText !== 'string') {
return NextResponse.json({ error: 'resourceText is required' }, { status: 400 }) return NextResponse.json({ error: 'resourceText is required' }, { status: 400 })
@@ -20,6 +20,7 @@ export async function POST(request: NextRequest) {
} }
const lang = language || 'fr' 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 config = await getSystemConfig()
const provider = getTagsProvider(config) 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. 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. LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
FORMAT RULE: Respond in ${outputFormat}.
EXISTING NOTE: EXISTING NOTE:
--- ---
@@ -46,13 +48,14 @@ INSTRUCTIONS:
- Append ONLY new, non-redundant information from the resource below the existing content - 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 - Use a clear separator (e.g., "---" or a new section heading) between existing and new content
- Skip information already covered in the existing note - 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` - Respond ONLY with the enriched note content, no explanations`
} else { } else {
// Merge: intelligently rewrite integrating both sources // 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. 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. LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
FORMAT RULE: Respond in ${outputFormat}.
EXISTING NOTE: EXISTING NOTE:
--- ---
@@ -69,7 +72,7 @@ INSTRUCTIONS:
- Eliminate redundancy — include each piece of information only once - Eliminate redundancy — include each piece of information only once
- Preserve the key ideas from both sources - Preserve the key ideas from both sources
- Maintain a logical structure with clear headings if appropriate - 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` - 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 { auth } from '@/auth'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n' import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools' import { toolRegistry } from '@/lib/ai/tools'
import { stepCountIs } from 'ai'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import path from 'path' import path from 'path'
@@ -47,36 +46,32 @@ export async function POST(req: Request) {
} }
const userId = session.user.id 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 body = await req.json()
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext, format } = body as {
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext } = body as {
messages: UIMessage[] messages: UIMessage[]
conversationId?: string conversationId?: string
notebookId?: string notebookId?: string
language?: string language?: string
webSearch?: boolean webSearch?: boolean
noteContext?: { title: string; content: string; tone: string; images?: string[] } noteContext?: { title: string; content: string; tone: string; images?: string[] }
format?: 'html' | 'markdown'
} }
// Convert UIMessages to CoreMessages for streamText
const incomingMessages = toCoreMessages(rawMessages) const incomingMessages = toCoreMessages(rawMessages)
// 3. Manage conversation (create or fetch) // 3. Manage conversation
let conversation: { id: string; messages: Array<{ role: string; content: string }> } let conversation: { id: string; messages: Array<{ role: string; content: string }> }
if (conversationId) { if (conversationId) {
const existing = await prisma.conversation.findUnique({ const existing = await prisma.conversation.findUnique({
where: { id: conversationId, userId }, where: { id: conversationId, userId },
include: { messages: { orderBy: { createdAt: 'asc' } } }, include: { messages: { orderBy: { createdAt: 'asc' } } },
}) })
if (!existing) { if (!existing) return new Response('Conversation not found', { status: 404 })
return new Response('Conversation not found', { status: 404 })
}
conversation = existing conversation = existing
} else { } else {
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation' const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
const created = await prisma.conversation.create({ conversation = await prisma.conversation.create({
data: { data: {
userId, userId,
notebookId: notebookId || null, notebookId: notebookId || null,
@@ -84,33 +79,21 @@ export async function POST(req: Request) {
}, },
include: { messages: true }, include: { messages: true },
}) })
conversation = created
} }
// 4. RAG retrieval // 4. RAG retrieval
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || '' const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
// Load translations for the requested language
const lang = (language || 'en') as SupportedLanguage const lang = (language || 'en') as SupportedLanguage
const translations = await loadTranslations(lang) const translations = await loadTranslations(lang)
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled' 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 notebookContext = ''
let searchNotes = '' 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 (!noteContext) {
if (notebookId) { if (notebookId) {
const notebookNotes = await prisma.note.findMany({ const notebookNotes = await prisma.note.findMany({
where: { where: { notebookId, userId, trashedAt: null },
notebookId,
userId,
trashedAt: null,
},
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 20, take: 20,
select: { id: true, title: true, content: true, updatedAt: true }, 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[] = [] let searchResults: any[] = []
try { try {
searchResults = await semanticSearchService.search(currentMessage, { searchResults = await semanticSearchService.search(currentMessage, {
@@ -131,21 +113,16 @@ export async function POST(req: Request) {
threshold: notebookId ? 0.3 : 0.5, threshold: notebookId ? 0.3 : 0.5,
defaultTitle: untitledText, defaultTitle: untitledText,
}) })
} catch { } catch {}
// Search failure should not block chat
}
searchNotes = searchResults searchNotes = searchResults
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`) .map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
.join('\n\n---\n\n') .join('\n\n---\n\n')
} }
// Combine: full notebook context + semantic search results (deduplicated)
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n') const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
// 5. System prompt synthesis with RAG context // 5. System prompt synthesis
// Language-aware prompts to avoid forcing French responses
// Note: lang is already declared above when loading translations
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = { const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
en: { 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.`, 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. 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 ## 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. - 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 ## Tone rules
- Natural tone, neither corporate nor too casual. - Natural tone, neither corporate nor too casual.
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly. - No unnecessary intro phrases. 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 upsell questions at the end. If you have useful additional info, just give it.
- If the user says "Momento" they mean Momento (this app). - If the user says "Momento" they mean Momento (this app).
## About Momento ## 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. - **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. - **Agents**: Create specialized AI Agents with custom system prompts for specific recurring tasks.
- **Lab**: Experimental AI tools for data analysis and deeper insights. - **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 ## Available tools
You have access to these tools for deeper research: You have access to: note_search, note_read, web_search, web_scrape.
- **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. Only use tools if you need more information. Never invent note IDs or URLs.`,
- **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.`,
}, },
fr: { 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.", 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. 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 ## 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. - 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 ## Règles de ton
- Ton naturel, ni corporate ni trop familier. - Ton naturel, direct, sans phrases d'intro inutiles.
- 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.
- Pas de question upsell à la fin ("Souhaitez-vous que je...", "Acceptez-vous que..."). Si tu as une info complémentaire utile, donne-la.
- Si l'utilisateur dit "Momento" il parle de Momento (cette application). - Si l'utilisateur dit "Momento" il parle de Momento (cette application).
## À propos de Momento ## À propos de Momento
Momento est une application de prise de notes intelligente. Ses fonctionnalités principales : 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.
- **É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.
## Outils disponibles ## Outils disponibles
Tu as accès à ces outils pour des recherches approfondies : Tu as accès à : note_search, note_read, web_search, web_scrape.`,
- **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.`,
}, },
fa: { fa: {
contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشت‌ها موضوع را پوشش نمی‌دهند، بگویید و با دانش عمومی خود تکمیل کنید.`, contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.", contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژه‌ها، مستندات فنی و یادداشت‌هایش سؤال می‌کند. باید به شکلی ساختاریافته و مفید پاسخ دهید. 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 (این برنامه) است.`,
- بدون سؤال فروشی در انتها. اگر اطلاعات تکمیلی مفید دارید، مستقیم بدهید.
- اگر کاربر "Momento" می‌گوید، منظورش Memento (این برنامه) است.
## ابزارهای موجود
- **note_search**: جستجو در یادداشت‌های کاربر با کلیدواژه یا معنی. زمانی استفاده کنید که زمینه اولیه کافی نباشد. اگر دفترچه انتخاب شده، شناسه آن را ارسال کنید.
- **note_read**: خواندن یک یادداشت خاص با شناسه. زمانی استفاده کنید که note_search یادداشتی برگرداند که محتوای کامل آن را نیاز دارید.
- **web_search**: جستجو در وب. زمانی استفاده کنید که کاربر درباره چیزی خارج از یادداشت‌هایش می‌پرسد.
- **web_scrape**: استخراج محتوای صفحه وب. زمانی استفاده کنید که web_search نشانی‌ای برگرداند که می‌خواهید بخوانید.
## قوانین استفاده از ابزارها
- شما از قبل زمینه‌ای از یادداشت‌های کاربر دارید. فقط در صورت نیاز به اطلاعات بیشتر از ابزارها استفاده کنید.
- هرگز شناسه یادداشت، نشانی یا شناسه دفترچه نسازید. از شناسه‌های موجود در زمینه یا نتایج ابزار استفاده کنید.
- برای سؤالات مکالمه‌ای ساده (سلام، نظرات، دانش عمومی)، مستقیم پاسخ دهید.`,
}, },
es: { 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.", 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 ## Reglas de formato
- Usa markdown libremente: títulos (##, ###), listas, bloques de código, negritas, tablas. - ${format === 'html' ? `Responde OBLIGATORIAMENTE usando fragmentos HTML válidos (ej: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
- Estructura tu respuesta con secciones para preguntas técnicas o temas complejos. - NO uses símbolos Markdown.` : 'Usa markdown libremente: títulos (##, ###), listas, negritas, tablas.'}
- Para preguntas simples y cortas, un párrafo directo es suficiente. - Estructura tu respuesta con secciones para temas complejos.
- Para preguntas simples, un párrafo directo es suficiente.` + (format === 'html' ? `
## Reglas de tono ## EJEMPLO DE SALIDA HTML
- Tono natural, ni corporativo ni demasiado informal. <h3>Título de sección</h3>
- Sin frases de introducción innecesarias. Responde directamente. <p>Aquí hay una explicación con <strong>texto en negrita</strong> y una lista:</p>
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente. <ul>
<li>Primer punto importante</li>
## Herramientas disponibles <li>Segundo punto importante</li>
- **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. </ul>` : ''),
- **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.`,
}, },
} }
// Fallback to English if language not supported
const prompts = promptLang[lang] || promptLang.en const prompts = promptLang[lang] || promptLang.en
const contextBlock = contextNotes.length > 0 const contextBlock = contextNotes.length > 0 ? prompts.contextWithNotes : prompts.contextNoNotes
? 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 }> = [] let imageContextParts: Array<{ type: 'image'; image: string }> = []
if (noteContext?.images && noteContext.images.length > 0) { if (noteContext?.images && noteContext.images.length > 0) {
for (const imgPath of noteContext.images.slice(0, 4)) { 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 buffer = await readFile(fullPath)
const ext = path.extname(imgPath).toLowerCase() const ext = path.extname(imgPath).toLowerCase()
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg' 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: `data:${mime};base64,${buffer.toString('base64')}` })
imageContextParts.push({ type: 'image', image: base64 })
} catch {} } catch {}
} }
} }
@@ -352,113 +257,38 @@ Tu as accès à ces outils pour des recherches approfondies :
let copilotContext = '' let copilotContext = ''
if (noteContext) { if (noteContext) {
copilotContext = `\n\n## Current Note Context copilotContext = `\n\n## Current Note Context
You are currently helping the user edit a specific note. Here is the current content of the note: You are helping the user edit a specific note: ${noteContext.title || 'Untitled'}.
Title: ${noteContext.title || 'Untitled'} Tone: ${noteContext.tone || 'professional'}.
Content: ${noteContext.content || '(empty)'}
Content: Focus ONLY on this note unless asked otherwise.`
${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.`
} }
const systemPrompt = `${prompts.system} 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'}.`
${copilotContext}
${contextBlock} // 6. Execute stream
const sysConfig = await getSystemConfig()
## 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
const chatTools = noteContext const chatTools = noteContext
? toolRegistry.buildToolsForChat({ ...chatToolContext, webOnly: true }) ? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
: toolRegistry.buildToolsForChat(chatToolContext) : toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch })
// 8. Save user message to DB before streaming const provider = getChatProvider(sysConfig)
if (isNewMessage && lastIncoming) { const result = await streamText({
await prisma.chatMessage.create({ model: provider.chatModel,
data: {
conversationId: conversation.id,
role: 'user',
content: lastIncoming.content,
},
})
}
// 9. Stream response
const result = streamText({
model,
system: systemPrompt, system: systemPrompt,
messages: allMessages as any, messages: incomingMessages,
tools: chatTools, tools: chatTools,
stopWhen: stepCountIs(5), maxSteps: 5,
async onFinish({ text }) { onFinish: async (final) => {
// Save assistant message to DB after streaming completes // Save messages to DB
await prisma.chatMessage.create({ const userContent = incomingMessages[incomingMessages.length - 1].content
data: { await prisma.message.create({
conversationId: conversation.id, data: { conversationId: conversation.id, role: 'user', content: userContent }
role: 'assistant',
content: text,
},
}) })
}, await prisma.message.create({
data: { conversationId: conversation.id, role: 'assistant', content: final.text }
})
}
}) })
// 10. Return streaming response with conversation ID header return result.toDataStreamResponse()
return result.toUIMessageStreamResponse({
headers: {
'X-Conversation-Id': conversation.id,
},
})
} }

File diff suppressed because it is too large Load Diff

View File

@@ -147,7 +147,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
return ( return (
<Button <Button
onClick={() => setIsOpen(true)} 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" size="icon"
title={t('ai.openAssistant')} title={t('ai.openAssistant')}
> >
@@ -202,7 +202,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('chat')} onClick={() => setActiveTab('chat')}
className={cn( className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all", "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')} <Bot className="h-4 w-4" /> {t('ai.chatTab')}
@@ -211,7 +211,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('insights')} onClick={() => setActiveTab('insights')}
className={cn( className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all", "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')} <Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
@@ -220,7 +220,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('history')} onClick={() => setActiveTab('history')}
className={cn( className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all", "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')} <History className="h-4 w-4" /> {t('ai.historyTab')}
@@ -234,10 +234,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{/* AI Welcome Message */} {/* AI Welcome Message */}
{messages.length === 0 && ( {messages.length === 0 && (
<div className="flex gap-3"> <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" /> <Bot className="h-4 w-4" />
</div> </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"> <p className="text-sm text-foreground leading-relaxed">
{t('ai.welcomeMsg')} {t('ai.welcomeMsg')}
</p> </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', 'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
msg.role === 'user' 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-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" />} {msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
</div> </div>
<div className={cn( <div className={cn(
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm', 'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
msg.role === 'user' msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm' ? 'bg-[#75B2D6] text-white rounded-tr-sm'
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground', : 'bg-[#FDFDFE] border border-border/50 rounded-tl-sm text-foreground',
)}> )}>
{msg.role === 'assistant' {msg.role === 'assistant'
? <MarkdownContent content={text} /> ? <MarkdownContent content={text} />
@@ -276,10 +276,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{isLoading && ( {isLoading && (
<div className="flex gap-3"> <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" /> <Bot className="h-4 w-4" />
</div> </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" /> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div> </div>
</div> </div>
@@ -290,7 +290,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{activeTab === 'insights' && ( {activeTab === 'insights' && (
<div className="h-full"> <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 ? ( {insightsLoading ? (
<div className="flex flex-col items-center justify-center py-10 opacity-60"> <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" /> <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 => ( history.map(conv => (
<button <button
key={conv.id} 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={() => { onClick={() => {
setConversationId(conv.id) setConversationId(conv.id)
setMessages(conv.messages.map((m: any) => ({ setMessages(conv.messages.map((m: any) => ({
@@ -345,7 +345,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</div> </div>
{/* Input Area & Tone Controls (Only in Chat tab) */} {/* 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 */} {/* Context Scope */}
<div className="mb-3"> <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> <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( className={cn(
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5", "py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
isSelected 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" : "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> </div>
{/* Text Input */} {/* 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 <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]" 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')} placeholder={t('ai.chatPlaceholder')}
@@ -435,7 +435,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
) : ( ) : (
<Button <Button
size="icon" 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} onClick={handleSend}
disabled={!input.trim()} disabled={!input.trim()}
> >

View File

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

View File

@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Download, Presentation } from 'lucide-react' import { Download, Presentation } from 'lucide-react'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types' 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) { export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false) const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved') 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 saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null) const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
const filesRef = useRef<BinaryFiles>({}) const filesRef = useRef<BinaryFiles>({})
@@ -147,12 +150,22 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
saveTimeoutRef.current = setTimeout(async () => { saveTimeoutRef.current = setTimeout(async () => {
try { try {
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current }) const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
await fetch('/api/canvas', { const res = await fetch('/api/canvas', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { } catch (e) {
console.error('[CanvasBoard] Save failure:', e) console.error('[CanvasBoard] Save failure:', e)
setSaveStatus('error') setSaveStatus('error')
@@ -164,7 +177,7 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
return <PptxViewer data={scene.pptx} name={name} /> return <PptxViewer data={scene.pptx} name={name} />
} }
const excalKey = canvasId ? `excal-${canvasId}` : 'excal-new' const excalKey = localId ? `excal-${localId}` : 'excal-new'
return ( return (
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr"> <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' 'use client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react' import { X, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { LABEL_COLORS } from '@/lib/types' import { LABEL_COLORS } from '@/lib/types'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
interface LabelBadgeProps { interface LabelBadgeProps {
label: string label: string
type?: 'ai' | 'user' // Optional: if provided, applies AI vs User styling
onRemove?: () => void onRemove?: () => void
variant?: 'default' | 'filter' | 'clickable' variant?: 'default' | 'filter' | 'clickable'
onClick?: () => void onClick?: () => void
@@ -17,6 +18,7 @@ interface LabelBadgeProps {
export function LabelBadge({ export function LabelBadge({
label, label,
type,
onRemove, onRemove,
variant = 'default', variant = 'default',
onClick, onClick,
@@ -27,13 +29,16 @@ export function LabelBadge({
const colorName = getLabelColor(label) const colorName = getLabelColor(label)
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
// AI labels get special Blueprint styling with Sparkles icon
const isAI = type === 'ai'
return ( return (
<Badge <Badge
className={cn( className={cn(
'text-xs border gap-1', 'text-xs border gap-1 transition-all',
colorClasses.bg, isAI
colorClasses.text, ? '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.border, : `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`,
variant === 'filter' && 'cursor-pointer hover:opacity-80', variant === 'filter' && 'cursor-pointer hover:opacity-80',
variant === 'clickable' && 'cursor-pointer', variant === 'clickable' && 'cursor-pointer',
isDisabled && 'opacity-50', isDisabled && 'opacity-50',
@@ -41,6 +46,7 @@ export function LabelBadge({
)} )}
onClick={onClick} onClick={onClick}
> >
{isAI && <Sparkles className="h-3 w-3 text-sky-500 dark:text-sky-400" />}
{label} {label}
{onRemove && ( {onRemove && (
<button <button
@@ -53,6 +59,12 @@ export function LabelBadge({
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </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> </Badge>
) )
} }

View File

@@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math' import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex' import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
interface MarkdownContentProps { 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}`}> <div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]} remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex, rehypeRaw]}
components={{ components={{
a: ({ node, ...props }) => ( a: ({ node, ...props }) => (
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" /> <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 { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr' import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US' 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 { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge' import { LabelBadge } from './label-badge'
import { NoteHistoryModal } from './note-history-modal' 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' type Tab = 'info' | 'versions'
@@ -49,8 +50,56 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false) const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false)
const [isSavingVersion, setIsSavingVersion] = useState(false) const [isSavingVersion, setIsSavingVersion] = useState(false)
const [versionSaved, setVersionSaved] = 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) 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( const notebook = useMemo(
() => notebooks.find(nb => nb.id === note.notebookId), () => notebooks.find(nb => nb.id === note.notebookId),
[notebooks, note.notebookId] [notebooks, note.notebookId]
@@ -201,17 +250,17 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
</button> </button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Versions sauvegardées</p> <p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">Versions sauvegardées</p>
{/* Save version button */} {/* Save version button */}
<button <button
disabled={isSavingVersion} disabled={isSavingVersion}
className={cn( 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 versionSaved
? 'border-emerald-500/40 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400' ? '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' isSavingVersion && 'opacity-50 cursor-not-allowed'
)} )}
onClick={async () => { onClick={async () => {
@@ -219,6 +268,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
try { try {
await commitNoteHistory(note.id) await commitNoteHistory(note.id)
setVersionSaved(true) setVersionSaved(true)
loadHistory()
setTimeout(() => setVersionSaved(false), 3000) setTimeout(() => setVersionSaved(false), 3000)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -228,24 +278,95 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
}} }}
> >
{isSavingVersion ? ( {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 ? ( ) : 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> </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 <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)} onClick={() => setShowHistory(true)}
> >
<div className="flex items-center gap-3"> <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> <div>
<p className="text-sm font-medium">Voir l'historique</p> <p className="text-xs font-bold uppercase tracking-wider">Mode Comparaison</p>
<p className="text-[11px] text-muted-foreground">Comparer et restaurer des versions</p> <p className="text-[10px] text-muted-foreground">Comparer les versions côte à côte</p>
</div> </div>
</div> </div>
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4 text-muted-foreground" />

View File

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

View File

@@ -19,8 +19,9 @@ import { Badge } from '@/components/ui/badge'
import { import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal, Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2 Trash2, LogOut, Wand2, Share2
} from 'lucide-react' } from 'lucide-react'
import { NoteShareDialog } from './note-share-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes' import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { useRefresh } from '@/lib/use-refresh' import { useRefresh } from '@/lib/use-refresh'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
@@ -39,6 +40,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const { t } = useLanguage() const { t } = useLanguage()
const { refreshNotes } = useRefresh() const { refreshNotes } = useRefresh()
const [isConverting, setIsConverting] = useState(false) const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
@@ -187,6 +189,19 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button> </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 */} {/* Three-dot options menu */}
{!readOnly && ( {!readOnly && (
<DropdownMenu> <DropdownMenu>
@@ -214,6 +229,15 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenu> </DropdownMenu>
)} )}
{/* Share Dialog portal */}
{shareOpen && (
<NoteShareDialog
noteId={note.id}
noteTitle={state.title}
onClose={() => setShareOpen(false)}
/>
)}
{/* Info panel toggle — rightmost, icon only */} {/* Info panel toggle — rightmost, icon only */}
<button <button
aria-label="Informations du document" 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() const { t } = useLanguage()
if (fullPage) { 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Title — auto-resizing textarea to prevent overflow */} {/* Title — auto-resizing textarea, adaptive size */}
<div className="group relative"> <div className="group relative">
<textarea <textarea
dir="auto" dir="auto"
@@ -29,36 +38,41 @@ export function NoteTitleBlock() {
}} }}
disabled={readOnly} disabled={readOnly}
className={cn( 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', 'w-full font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground',
'placeholder:text-foreground/20 resize-none overflow-hidden break-words', 'leading-[1.15] tracking-tight',
'placeholder:text-foreground/20 resize-none overflow-hidden',
titleSizeClass,
!readOnly && 'pr-12' !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 && ( {!readOnly && (
<button <button
type="button" type="button"
onClick={async () => { onClick={async () => {
console.log('[TITLE] Sparkles button clicked')
const plain = state.content.replace(/<[^>]+>/g, ' ').trim() const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length const wordCount = plain.split(/\s+/).filter(Boolean).length
console.log('[TITLE] Content length:', plain.length, 'Word count:', wordCount)
if (wordCount < 10) { if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.') toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return return
} }
actions.setIsProcessingAI(true) actions.setIsProcessingAI(true)
try { try {
console.log('[TITLE] Calling /api/ai/title-suggestions...')
const res = await fetch('/api/ai/title-suggestions', { const res = await fetch('/api/ai/title-suggestions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }), body: JSON.stringify({ content: plain }),
}) })
console.log('[TITLE] API response:', res.status)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
console.log('[TITLE] Suggestions:', data.suggestions)
const s = data.suggestions?.[0]?.title ?? '' const s = data.suggestions?.[0]?.title ?? ''
if (s) { if (s) {
actions.setTitle(s) actions.setTitle(s)
@@ -67,12 +81,9 @@ export function NoteTitleBlock() {
toast.error('Impossible de générer un titre.') toast.error('Impossible de générer un titre.')
} }
} else { } else {
const err = await res.text()
console.error('[TITLE] API error:', err)
toast.error('Erreur lors de la génération du titre.') toast.error('Erreur lors de la génération du titre.')
} }
} catch (e) { } catch (e) {
console.error('[TITLE] Fetch failed:', e)
toast.error('Erreur réseau.') toast.error('Erreur réseau.')
} finally { actions.setIsProcessingAI(false) } } finally { actions.setIsProcessingAI(false) }
}} }}
@@ -97,6 +108,7 @@ export function NoteTitleBlock() {
) )
} }
// Dialog mode title block // Dialog mode title block
return ( return (
<div className="relative"> <div className="relative">

View File

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

View File

@@ -107,16 +107,15 @@ export function NoteInlineEditor({
const { data: session } = useSession() const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true) const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true) const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
useEffect(() => { useEffect(() => {
if (session?.user?.id) { if (session?.user?.id) {
const userId = session.user.id getAISettings(session.user.id).then((settings) => {
import('@/app/actions/ai-settings').then(({ getAISettings }) => { setAiAssistantEnabled(settings.paragraphRefactor !== false)
getAISettings(userId).then(settings => { setAutoLabelingEnabled(settings.autoLabeling !== false)
setAiAssistantEnabled(settings.paragraphRefactor !== false) setAutoSaveEnabled(settings.autoSave !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false) }).catch(err => console.error("Failed to fetch AI settings", err))
}).catch(err => console.error("Failed to fetch AI settings", err))
})
} }
}, [session?.user?.id]) }, [session?.user?.id])
const { labels: globalLabels, addLabel } = useNotebooks() const { labels: globalLabels, addLabel } = useNotebooks()
@@ -207,6 +206,10 @@ export function NoteInlineEditor({
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ───────────────────── // ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
const scheduleSave = useCallback(() => { const scheduleSave = useCallback(() => {
if (!autoSaveEnabled) {
setIsDirty(true)
return
}
setIsDirty(true) setIsDirty(true)
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => { saveTimerRef.current = setTimeout(async () => {
@@ -567,10 +570,11 @@ export function NoteInlineEditor({
)} )}
{previousContent !== null && ( {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') } title={t('ai.undoAI') }
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}> onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
<span className="text-[11px]">{t('general.undo') || 'Annuler'}</span>
</Button> </Button>
)} )}
</div> </div>
@@ -601,7 +605,31 @@ export function NoteInlineEditor({
{isSaving ? ( {isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</> <><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
) : isDirty ? ( ) : 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')}</> <><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"> <div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
{/* Title */} {/* Title */}
<div className="group relative flex items-start gap-2 shrink-0 mb-1"> <div className="group relative flex items-start gap-2 shrink-0 mb-1">
<input <textarea
type="text"
dir="auto" 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…'} placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title} 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 && ( {!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button" <button type="button"
@@ -920,9 +957,20 @@ export function NoteInlineEditor({
noteImages={allImages} noteImages={allImages}
noteId={note.id} noteId={note.id}
onApplyToNote={(newContent) => { onApplyToNote={(newContent) => {
setPreviousContent(content) const current = content
setPreviousContent(current)
changeContent(newContent) changeContent(newContent)
scheduleSave() scheduleSave()
toast.success(t('ai.appliedToNote') || 'Applied to note', {
action: {
label: t('general.undo') || 'Undo',
onClick: () => {
changeContent(current)
setPreviousContent(null)
scheduleSave()
}
}
})
}} }}
onUndoLastAction={previousContent !== null ? () => { onUndoLastAction={previousContent !== null ? () => {
changeContent(previousContent) changeContent(previousContent)

View File

@@ -1070,13 +1070,22 @@ export function NoteInput({
noteContent={content} noteContent={content}
noteImages={allImages} noteImages={allImages}
onApplyToNote={(newContent) => { 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 (type === 'richtext') {
// If content looks like markdown, convert to HTML before injecting into richtext
const looksLikeMarkdown = /^#{1,6}\s|^[-*]\s|\*\*[^*]+\*\*|^>\s/.test(newContent) const looksLikeMarkdown = /^#{1,6}\s|^[-*]\s|\*\*[^*]+\*\*|^>\s/.test(newContent)
setContent(looksLikeMarkdown ? markdownToBasicHtml(newContent) : newContent) setContent(looksLikeMarkdown ? markdownToBasicHtml(newContent) : newContent)
} else { } else {
setContent(newContent) setContent(newContent)
} }
toast.success(t('ai.appliedToNote'), {
action: {
label: t('general.undo'),
onClick: () => handleUndo()
}
})
}} }}
lastActionApplied={false} lastActionApplied={false}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))} notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}

View File

@@ -46,6 +46,15 @@ interface ReminderNote {
isReminderDone: boolean isReminderDone: boolean
} }
// ── Memento brand tokens ──────────────────────────────────────────────────────
const C = {
blue: '#E9ECEF',
gold: '#D4A373',
green: '#A3B18A',
dark: '#1C1C1C',
beige: '#F2F0E9',
}
export function NotificationPanel() { export function NotificationPanel() {
const { refreshNotes } = useRefresh() const { refreshNotes } = useRefresh()
const { t } = useLanguage() const { t } = useLanguage()
@@ -100,7 +109,6 @@ export function NotificationPanel() {
refreshNotes(null) refreshNotes(null)
setOpen(false) setOpen(false)
} catch (error: any) { } catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || t('general.error')) toast.error(error.message || t('general.error'))
} }
} }
@@ -112,7 +120,6 @@ export function NotificationPanel() {
toast.info(t('notification.declined')) toast.info(t('notification.declined'))
if (requests.length <= 1) setOpen(false) if (requests.length <= 1) setOpen(false)
} catch (error: any) { } catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || t('general.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 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 ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -147,163 +171,172 @@ export function NotificationPanel() {
> >
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" /> <Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
{pendingCount > 0 && ( {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} {pendingCount > 9 ? '9+' : pendingCount}
</span> </span>
)} )}
</button> </button>
</PopoverTrigger> </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"> <PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-xl border border-black/10">
<div className="flex items-center justify-between"> {/* Header */}
<div className="flex items-center gap-2"> <div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: `${C.beige}` }}>
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" /> <div className="flex items-center gap-2">
<span className="font-semibold text-sm">{t('notification.notifications')}</span> <Bell className="h-4 w-4" style={{ color: C.dark }} />
</div> <span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
<div className="flex items-center gap-2"> {t('notification.notifications')}
{appNotifications.length > 0 && ( </span>
<button </div>
onClick={handleMarkAllRead} <div className="flex items-center gap-2">
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors" {appNotifications.length > 0 && (
title={t('notification.markAllRead') || 'Mark all read'} <button
> onClick={handleMarkAllRead}
<Check className="h-3.5 w-3.5" /> className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
</button> title={t('notification.markAllRead') || 'Mark all read'}
)} >
{pendingCount > 0 && ( <Check className="h-3.5 w-3.5" />
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md"> </button>
{pendingCount} )}
</Badge> {pendingCount > 0 && (
)} <span
</div> 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>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground"> <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> </div>
) : !hasContent ? ( ) : !hasContent ? (
<div className="p-6 text-center text-sm text-muted-foreground"> <div className="p-8 text-center">
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" /> <Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
<p className="font-medium">{t('notification.noNotifications') || 'No new notifications'}</p> <p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
</div> </div>
) : ( ) : (
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto divide-y divide-black/5">
{/* App notifications (agents, system) */}
{/* ── App notifications (agents, system) ── */}
{appNotifications.map((notif) => { {appNotifications.map((notif) => {
const isSlides = notif.type === 'agent_slides_ready' const isSlides = notif.type === 'agent_slides_ready'
const isCanvas = notif.type === 'agent_canvas_ready' const isCanvas = notif.type === 'agent_canvas_ready'
const canvasId = notif.relatedId const canvasId = notif.relatedId
const iconStyle = notifIconStyle(notif.type)
return ( return (
<div <div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
key={notif.id} <div
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150" className="flex items-start gap-3 cursor-pointer"
> onClick={() => {
<div if (notif.actionUrl) {
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 () => {
handleMarkNotifRead(notif.id) handleMarkNotifRead(notif.id)
window.open(`/api/canvas/download?id=${canvasId}`, '_blank') setOpen(false)
}} router.push(notif.actionUrl)
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" }
}}
>
{/* 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" /> {isSlides ? <Presentation className="w-3.5 h-3.5" />
{t('notification.downloadPptx') || 'Download .pptx'} : 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> </button>
</div> </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) => ( {overdueReminders.map((note) => (
<div <div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
key={note.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<button <button
onClick={() => handleToggleReminder(note.id, true)} 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')} title={t('reminders.markDone')}
> >
<Circle className="w-4 h-4" /> <Circle className="w-4 h-4" />
</button> </button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5"> <div className="flex items-center gap-1.5 mb-0.5">
<AlertCircle className="w-3 h-3 text-amber-500" /> <AlertCircle className="w-3 h-3" style={{ color: C.gold }} />
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400"> <span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.gold }}>
{t('reminders.overdue')} {t('reminders.overdue')}
</span> </span>
</div> </div>
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p> <p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground"> <div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })} {note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
</div> </div>
@@ -312,17 +345,14 @@ export function NotificationPanel() {
</div> </div>
))} ))}
{/* Upcoming reminders */} {/* ── Upcoming reminders ── */}
{upcomingReminders.slice(0, 5).map((note) => ( {upcomingReminders.slice(0, 5).map((note) => (
<div <div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
key={note.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3"> <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"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p> <p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-[11px] text-foreground/40 mt-0.5">
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, { {note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})} })}
@@ -332,56 +362,48 @@ export function NotificationPanel() {
</div> </div>
))} ))}
{/* Share requests */} {/* ── Share requests ── */}
{requests.map((request) => ( {requests.map((request) => (
<div <div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
key={request.id} <div className="flex items-start gap-3">
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150" {/* Avatar */}
> <div
<div className="flex items-start gap-3 mb-2"> className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
<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"> style={{ background: `linear-gradient(135deg, ${C.blue}, ${C.green})` }}
>
{(request.sharer.name || request.sharer.email)[0].toUpperCase()} {(request.sharer.name || request.sharer.email)[0].toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0"> <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} {request.sharer.name || request.sharer.email}
</p> </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') })} {t('notification.shared', { title: request.note.title || t('notification.untitled') })}
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2 mt-2"> <div className="flex gap-2 ml-11">
<button <button
onClick={() => handleDecline(request.id)} onClick={() => handleDecline(request.id)}
className={cn( 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"
"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"
)}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
{t('notification.decline') || t('general.cancel')} {t('notification.decline') || 'Refuser'}
</button> </button>
<button <button
onClick={() => handleAccept(request.id)} onClick={() => handleAccept(request.id)}
className={cn( 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"
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md", style={{ background: C.blue }}
"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"
)}
> >
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
{t('notification.accept') || t('general.confirm')} {t('notification.accept') || 'Accepter'}
</button> </button>
</div> </div>
</div> </div>
@@ -389,14 +411,15 @@ export function NotificationPanel() {
</div> </div>
)} )}
{/* Footer link to reminders page */} {/* Footer */}
{activeReminders.length > 0 && ( {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 <a
href="/reminders" 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> </a>
</div> </div>
)} )}

View File

@@ -14,6 +14,10 @@ import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align' import TextAlign from '@tiptap/extension-text-align'
import TaskList from '@tiptap/extension-task-list' import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item' 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 Superscript from '@tiptap/extension-superscript'
import Subscript from '@tiptap/extension-subscript' import Subscript from '@tiptap/extension-subscript'
import Typography from '@tiptap/extension-typography' import Typography from '@tiptap/extension-typography'
@@ -26,7 +30,8 @@ import {
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink, Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, 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 { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -45,13 +50,13 @@ interface RichTextEditorProps {
type SlashItem = { type SlashItem = {
title: string title: string
description: string description: string
icon: typeof Bold icon: any
category?: string category?: string
shortcut?: string shortcut?: string
isImage?: boolean isImage?: boolean
isAi?: boolean isAi?: boolean
aiOption?: 'clarify' | 'shorten' | 'improve' aiOption?: 'clarify' | 'shorten' | 'improve'
command: (editor: Editor) => void command: (editor: Editor, range?: any) => void
} }
const CustomImage = Image.extend({ const CustomImage = Image.extend({
@@ -71,28 +76,50 @@ const CustomImage = Image.extend({
}) })
const slashCommands: SlashItem[] = [ 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: '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 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 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: '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: '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: '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: '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: '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: '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() }, { title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
// Media (index 10) // Media
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => {} }, { title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
// Formatting (indices 11-13) — super/subscript removed, use BubbleMenu // Formatting
{ title: 'Align Left', description: 'Align text left', icon: AlignLeft, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('left').run() }, { 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 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() }, { title: 'Align Right', description: 'Align text right', icon: AlignRight, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('right').run() },
// IA Note (indices 14-17) // IA Note
{ title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} }, { 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: '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: '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: () => {} }, { 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> { 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'] }), TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
TaskList, TaskList,
TaskItem.configure({ nested: true }), TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
Superscript, Superscript,
Subscript, Subscript,
Typography, Typography,
@@ -202,7 +233,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
editor={editor} editor={editor}
className="notion-bubble-menu" className="notion-bubble-menu"
{...({ {...({
tippyOptions: { tippyOptions: {
appendTo: () => document.body, appendTo: () => document.body,
zIndex: 99999, zIndex: 99999,
fallbackPlacements: ['bottom', 'top'] 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 }) { function BubbleToolbar({ editor }: { editor: Editor | null }) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
@@ -472,10 +503,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
const [aiLoading, setAiLoading] = useState(false) const [aiLoading, setAiLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const selectedItemRef = useRef<HTMLButtonElement>(null) const selectedItemRef = useRef<HTMLButtonElement>(null)
// Flag: true while user is interacting with the menu (prevents selectionUpdate from closing it)
const menuInteracting = useRef(false) const menuInteracting = useRef(false)
// Translated category names (keys match slashCommands category field)
const CAT_LABELS: Record<string, string> = { const CAT_LABELS: Record<string, string> = {
'Basic blocks': t('richTextEditor.slashCatBasic'), 'Basic blocks': t('richTextEditor.slashCatBasic'),
'Media': t('richTextEditor.slashCatMedia'), 'Media': t('richTextEditor.slashCatMedia'),
@@ -483,26 +512,35 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
'IA Note': t('richTextEditor.slashCatAi'), 'IA Note': t('richTextEditor.slashCatAi'),
} }
// Translated command list (keeps same order/icons/shortcuts as global slashCommands)
const localCommands: SlashItem[] = [ const localCommands: SlashItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: 'Basic blocks' }, { ...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: 'Basic blocks' }, { ...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: 'Basic blocks' }, { ...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: 'Basic blocks' }, { ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[4], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: 'Basic blocks' }, { ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[5], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: 'Basic blocks' }, { ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[6], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: 'Basic blocks' }, { ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[7], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: 'Basic blocks' }, { ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[8], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: 'Basic blocks' }, { ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[9], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: 'Basic blocks' }, { ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[10], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: 'Media' }, { ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[11], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: 'Formatting' }, { ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: t('richTextEditor.slashCatMedia') },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: 'Formatting' }, { ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: 'Formatting' }, { ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[14], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: 'IA Note' }, { ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[15], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: 'IA Note' }, { ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[16], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: 'IA Note' }, { ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[17], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: 'IA Note' }, { ...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(() => { const closeMenu = useCallback(() => {
@@ -536,13 +574,11 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
} }
}, [editor, closeMenu, deleteSlashText, onInsertImage]) }, [editor, closeMenu, deleteSlashText, onInsertImage])
// All category names in order
const allCategories = Array.from(new Set(localCommands.map(c => c.category || 'Basic blocks'))) 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 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 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 availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
const cat = item.category || 'Basic blocks' const cat = item.category || 'Basic blocks'
if (!acc[cat]) acc[cat] = [] if (!acc[cat]) acc[cat] = []
@@ -571,8 +607,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
e.preventDefault() e.preventDefault()
const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])] const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])]
const currentIndex = availableTabs.indexOf(activeCategory) const currentIndex = availableTabs.indexOf(activeCategory)
const nextIndex = e.key === 'ArrowRight' const nextIndex = e.key === 'ArrowRight'
? (currentIndex + 1) % availableTabs.length ? (currentIndex + 1) % availableTabs.length
: (currentIndex - 1 + availableTabs.length) % availableTabs.length : (currentIndex - 1 + availableTabs.length) % availableTabs.length
setActiveCategory(availableTabs[nextIndex]) setActiveCategory(availableTabs[nextIndex])
setSelectedIndex(0) setSelectedIndex(0)
@@ -602,8 +638,17 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
if (!isOpen) return if (!isOpen) return
const { from } = editor.state.selection const { from } = editor.state.selection
const c = editor.view.coordsAtPos(from) 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(() => { useEffect(() => {
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {

View File

@@ -40,10 +40,10 @@ export function SettingsNav({ className }: SettingsNavProps) {
key={section.id} key={section.id}
href={section.href} href={section.href}
className={cn( 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) isActive(section.href)
? 'border-primary text-foreground' ? 'border-[#D4A373] text-[#1C1C1C]'
: 'border-transparent text-muted-foreground hover:text-foreground' : 'border-transparent text-[#1C1C1C]/40 hover:text-[#1C1C1C]'
)} )}
> >
{section.icon} {section.icon}

View File

@@ -21,6 +21,8 @@ import {
LogOut, LogOut,
Shield, Shield,
GripVertical, GripVertical,
Users,
Bell,
} from 'lucide-react' } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -60,7 +62,7 @@ function NoteLink({
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
onClick={onClick} onClick={onClick}
className={cn( 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' 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', 'w-1.5 h-1.5 rounded-full shrink-0',
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30' 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> </motion.button>
) )
} }
@@ -333,7 +335,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<aside <aside
className={cn( className={cn(
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex', '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 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" 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'} 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 ? ( {user?.image ? (
<Avatar className="size-10 ring-1 ring-border/60"> <Avatar className="size-10 ring-1 ring-border/60">
<AvatarImage src={user.image} alt="" /> <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> </Avatar>
) : ( ) : (
<span>{initial}</span> <span>{initial}</span>
@@ -485,6 +487,48 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{t('sidebar.inbox') || 'Inbox'} {t('sidebar.inbox') || 'Inbox'}
</span> </span>
</button> </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 */} {/* Divider */}
<div className="mx-4 my-3 h-px bg-border/40" /> <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 notes = notebookNotes[notebook.id] || []
const isDragging = draggedId === notebook.id const isDragging = draggedId === notebook.id
return ( return (
<div <motion.div
key={notebook.id} key={notebook.id}
draggable layout
onDragStart={(e) => handleDragStart(e, notebook.id)} transition={{
onDragOver={(e) => handleDragOver(e, notebook.id)} type: 'spring',
onDragEnd={handleDragEnd} stiffness: 300,
damping: 30,
mass: 0.8
}}
> >
<SidebarCarnetItem <div
carnet={{ draggable
id: notebook.id, onDragStart={(e) => handleDragStart(e, notebook.id)}
name: notebook.name, onDragOver={(e) => handleDragOver(e, notebook.id)}
initial: notebook.name.charAt(0).toUpperCase(), onDragEnd={handleDragEnd}
}} >
isActive={isActive} <SidebarCarnetItem
notes={notes} carnet={{
activeNoteId={currentNoteId} id: notebook.id,
onCarnetClick={() => handleCarnetClick(notebook.id)} name: notebook.name,
onNoteClick={handleNoteClick} initial: notebook.name.charAt(0).toUpperCase(),
isDragging={isDragging} }}
/> isActive={isActive}
</div> 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: '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: '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 => { ].map(item => {
const isActive = pathname.startsWith(item.href) const isActive = pathname.startsWith(item.href)
return ( return (
@@ -572,17 +625,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</Link> </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> </div>
</motion.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"> <div className="pt-4 p-5 border-t border-border space-y-1">
<Link <Link
href="/archive" 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} /> <Archive size={16} />
<span>{t('sidebar.archive') || 'Archives'}</span> <span>{t('sidebar.archive') || 'Archives'}</span>
</Link> </Link>
<Link <Link
href="/trash" 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} /> <Trash2 size={16} />
<span>{t('sidebar.trash') || 'Corbeille'}</span> <span>{t('sidebar.trash') || 'Corbeille'}</span>
</Link> </Link>
<Link <Link
href="/settings" 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} /> <Settings size={16} />
<span>{t('nav.settings') || 'Paramètres'}</span> <span>{t('nav.settings') || 'Paramètres'}</span>

View File

@@ -36,7 +36,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -39,7 +39,7 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -38,7 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -18,18 +18,18 @@ export function Toaster() {
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: [ toast: [
'toast pointer-events-auto', 'toast pointer-events-auto border-none',
'!bg-[#1C1C1C] !text-[#F2F0E9] !border !border-white/10', 'bg-[var(--color-memento-ink)] text-[var(--color-memento-paper)]',
'!rounded-xl !shadow-xl !shadow-black/30', 'rounded-xl shadow-acrylic',
'!text-[13px] !font-medium !py-3 !px-4', 'text-[13px] font-medium py-3 px-4',
].join(' '), ].join(' '),
description: '!text-[#F2F0E9]/70 !text-[12px]', description: 'text-[var(--color-memento-paper)]/70 text-[12px]',
actionButton: '!bg-[#F2F0E9] !text-[#1C1C1C] !text-[11px] !font-bold !rounded-lg !px-3 !py-1', 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-[#F2F0E9]/70 !border-white/10 hover:!bg-white/20', 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/70', success: 'border-l-4 border-l-emerald-400',
error: '!border-l-4 !border-l-red-400/70', error: 'border-l-4 border-l-red-400',
warning: '!border-l-4 !border-l-amber-400/70', warning: 'border-l-4 border-l-amber-400',
info: '!border-l-4 !border-l-sky-400/70', 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 ## STRICT RULES
1. **4 to 10 nodes** — no less, no more 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`,
}, },
'slide-generator': { 'slide-generator': {
@@ -931,42 +923,22 @@ RÈGLES DE DESIGN IMPÉRATIVES :
- Slide 1 : "title" (titre fort + sous-titre accrocheur) - Slide 1 : "title" (titre fort + sous-titre accrocheur)
- Slide 2 : "toc" (sommaire numéroté) - Slide 2 : "toc" (sommaire numéroté)
- Utilise AU MOINS 2 layouts "diagramme" parmi : "timeline", "process", "metrics", "comparison" - Utilise AU MOINS 2 layouts "diagramme" parmi : "timeline", "process", "metrics", "comparison"
- "timeline" : étapes chronologiques ou roadmap (content items : "Étape: description") - Thèmes recommandés : architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
- "process" : étapes numérotées avec détails (content items : "Action: explication") - Tu DOIS utiliser le thème et le style spécifiés dans la requête de l'utilisateur.
- "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
- Points concis (max 100 chars), titres percutants et courts - Points concis (max 100 chars), titres percutants et courts
- JSON strict pour generate_pptx, sans texte hors JSON.`, - 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. 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. 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 - 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) - Slide 2: "toc" (numbered table of contents)
- Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison" - Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison"
- "timeline": chronological steps or roadmap (content items: "Step: description") - Recommended themes: architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
- "process": numbered steps with details (content items: "Action: explanation") - You MUST use the theme and style specified in the user's request.
- "metrics": visual KPIs with large colored values (content items: "VALUE: label") - Concise points (max 100 chars), punchy and short titles
- "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
- Strict JSON for generate_pptx, no text outside JSON.`, - 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({ const label = await tx.label.upsert({
where: { notebookId_name: { notebookId, name: suggestedLabel.name } as any }, where: { notebookId_name: { notebookId, name: suggestedLabel.name } as any },
update: {}, update: { type: 'ai' }, // Update type to AI if it exists as user label
create: { create: {
name: suggestedLabel.name, name: suggestedLabel.name,
color: 'gray', color: 'gray',
type: 'ai', // Mark as AI-generated
notebookId, notebookId,
userId, userId,
}, },

View File

@@ -60,6 +60,8 @@ const PALETTES: Record<string, Theme> = {
coastal_coral: { primary: '005f73', secondary: '0a9396', accent: 'ee9b00', light: 'e9f5f5', bg: 'ffffff' }, 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' }, 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' }, 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> = { 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', ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness', berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech', warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
architectural: 'architectural_mono', silk: 'minimal_silk',
} }
function resolveTheme(spec: PresentationSpec): { theme: Theme; key: string } { function resolveTheme(spec: PresentationSpec): { theme: Theme; key: string } {
@@ -1085,13 +1088,13 @@ LAYOUTS — choose the most visual for each slide:
- summary: closing key takeaways - summary: closing key takeaways
RULES: RULES:
- Use the THEME and STYLE provided in the prompt context.
- First slide MUST be "title" - First slide MUST be "title"
- Second slide: "toc" - Second slide: "toc"
- Use "section" as dividers between major topics - Use "section" as dividers between major topics
- Prefer DIAGRAM layouts (timeline, process, metrics, comparison) over plain content - Prefer DIAGRAM layouts (timeline, process, metrics, comparison) over plain content
- Use at least 2 diagram layouts per presentation - Use at least 2 diagram layouts per presentation
- 8-12 slides, never repeat same layout consecutively - 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`, - All text content: max 100 chars per item, concise and impactful`,
inputSchema: z.object({ 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 }, 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 }, 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 }, 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> = { 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', ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness', berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech', warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
architectural: 'architectural_mono', silk: 'minimal_silk',
} }
const THEME_NAMES: Record<string, string> = { const THEME_NAMES: Record<string, string> = {
@@ -70,6 +73,7 @@ const THEME_NAMES: Record<string, string> = {
art_food: 'Art & Food', luxury_mystery: 'Luxury & Mystery', art_food: 'Art & Food', luxury_mystery: 'Luxury & Mystery',
pure_tech_blue: 'Pure Tech Blue', coastal_coral: 'Coastal Coral', pure_tech_blue: 'Pure Tech Blue', coastal_coral: 'Coastal Coral',
vibrant_orange_mint: 'Vibrant Orange Mint', platinum_white_gold: 'Platinum White Gold', 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 } { function resolvePalette(spec: PresentationSpec): { palette: Palette; key: string } {
@@ -100,7 +104,7 @@ function safeHtml(str: string): string {
.replace(/javascript\s*:/gi, '') .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 text = p.isDark ? '#f0f0f0' : '#1a1a1a'
const muted = p.isDark ? '#999' : '#555' const muted = p.isDark ? '#999' : '#555'
const heading = p.isDark ? '#ffffff' : p.primary 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-link-color-hover: ${p.secondary};
--r-selection-background-color: ${p.accent}; --r-selection-background-color: ${p.accent};
--r-selection-color: ${bgText}; --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 { function buildLayoutCSS(): string {
return ` 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 { .reveal {
font-family: var(--r-main-font); font-family: var(--r-main-font);
@@ -296,10 +328,14 @@ function buildLayoutCSS(): string {
} }
.reveal .s-cards .card:nth-child(odd) { .reveal .s-cards .card:nth-child(odd) {
background: var(--p-primary); border-color: transparent; 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-num { color: rgba(255,255,255,0.4); }
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; } .reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; font-weight: 300; }
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.5; } .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:nth-child(even) .card-text { color: var(--p-text); }
.reveal .s-cards .card-num { .reveal .s-cards .card-num {
font-size: 18pt; font-weight: 800; line-height: 1; 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 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"> <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> <style>
${buildThemeCSS(palette, radius)} ${buildThemeCSS(palette, radius, key)}
${buildLayoutCSS()} ${buildLayoutCSS()}
</style> </style>

View File

@@ -1,225 +1,19 @@
import { marked } from 'marked'
/** /**
* Server-side Markdown → HTML converter. * Server-side Markdown → HTML converter.
* Converts AI-generated markdown notes into TipTap-compatible rich text HTML. * Converts AI-generated markdown notes into TipTap-compatible rich text HTML.
* Uses a lightweight regex-based approach to avoid heavy remark/rehype dependencies. * Uses 'marked' for standard GFM compliance and reliability.
*
* Handles: headings, bold, italic, strikethrough, code blocks, inline code,
* links, images, lists (ul/ol), blockquotes, horizontal rules, tables, paragraphs.
*/ */
export function markdownToHtml(markdown: string): string { export function markdownToHtml(markdown: string): string {
if (!markdown || !markdown.trim()) return '' if (!markdown || !markdown.trim()) return ''
let html = markdown // marked.parse returns a string (or a promise if async is true, but we use sync)
const html = marked.parse(markdown, {
// Escape HTML entities (but preserve markdown) gfm: true,
html = html.replace(/&/g, '&amp;') breaks: true,
html = html.replace(/</g, '&lt;') }) as string
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, '')
return html.trim() 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; id: string;
name: string; name: string;
color: LabelColorName; color: LabelColorName;
type?: 'ai' | 'user'; // "ai" for auto-generated, "user" for manually created
notebookId: string | null; // NEW: Belongs to a notebook notebookId: string | null; // NEW: Belongs to a notebook
userId?: string | null; // DEPRECATED: Kept for backward compatibility userId?: string | null; // DEPRECATED: Kept for backward compatibility
createdAt: Date; createdAt: Date;

View File

@@ -448,43 +448,36 @@
"explain": "Explain" "explain": "Explain"
}, },
"generate": { "generate": {
"sectionLabel": "Generate from this note", "slides": "Generate Slides",
"slides": "Generate a presentation", "sectionLabel": "Generation Tools",
"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",
"theme": "Theme", "theme": "Theme",
"themeArchitecturalMono": "Architectural Mono",
"themeVibrantTech": "Vibrant Tech",
"themeMinimalSilk": "Minimal Silk",
"style": "Style", "style": "Style",
"diagramType": "Type", "styleProfessional": "Professional",
"typeAuto": "Auto", "diagram": "Generate Diagram",
"styleSoft": "Soft", "diagramReadyHint": "Convert note into visual flow",
"styleSharp": "Sharp", "diagramType": "Diagram Type",
"styleRounded": "Rounded", "typeAuto": "Auto-detect",
"stylePill": "Pill", "typeFlowchart": "Flowchart",
"typeMindMap": "Mind Map",
"typeTimeline": "Timeline",
"typeOrgChart": "Org Chart",
"typeArchitecture": "Architecture",
"typeProcessMap": "Process Map",
"styleSketchy": "Sketchy", "styleSketchy": "Sketchy",
"styleAustere": "Austere", "styleSoft": "Soft",
"styleSketchPlus": "Sketch+", "styleMinimal": "Minimal",
"toastLoading": { "styleDraft": "Draft",
"slides": "⏳ Generating presentation…", "stylePolished": "Polished",
"diagram": "⏳ Generating diagram…" "styleHandwritten": "Handwritten",
}, "diagramReady": "Diagram is ready!",
"toastLoadingDesc": "You can navigate freely, a notification will appear.", "openInExcalidraw": "Open in Excalidraw Lab",
"toastSuccessSlides": "Click Download in the AI panel.", "insertDiagramInNote": "Embed PNG in current note",
"toastSuccessDiagram": "Your diagram is available in the Lab.", "diagramImageAlt": "AI Generated Diagram",
"diagramReadyHint": "Use the panel below: Excalidraw or insert into the note.", "insertedInNote": "Diagram inserted in note",
"openInExcalidraw": "Open in Excalidraw (Lab)", "insertExportError": "Error exporting/uploading diagram"
"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"
}, },
"openAssistant": "Open AI Assistant", "openAssistant": "Open AI Assistant",
"poweredByMomento": "Powered by Momento AI", "poweredByMomento": "Powered by Momento AI",
@@ -689,7 +682,7 @@
"recent": "Recent", "recent": "Recent",
"proPlan": "Pro Plan", "proPlan": "Pro Plan",
"chat": "AI Chat", "chat": "AI Chat",
"lab": "The Lab", "lab": "The Workshop",
"agents": "Agents" "agents": "Agents"
}, },
"settings": { "settings": {
@@ -1774,7 +1767,7 @@
"timeoutWarning": "Response is taking longer than expected..." "timeoutWarning": "Response is taking longer than expected..."
}, },
"labHeader": { "labHeader": {
"title": "The Lab", "title": "The Workshop",
"live": "Live", "live": "Live",
"currentProject": "Current Project", "currentProject": "Current Project",
"choose": "Choose...", "choose": "Choose...",
@@ -1828,6 +1821,12 @@
"slashCodeDesc": "Code snippet", "slashCodeDesc": "Code snippet",
"slashDivider": "Divider", "slashDivider": "Divider",
"slashDividerDesc": "Horizontal separator", "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", "slashImage": "Image",
"slashImageDesc": "Embed an image from URL", "slashImageDesc": "Embed an image from URL",
"slashAlignLeft": "Align Left", "slashAlignLeft": "Align Left",

View File

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

View File

@@ -36,6 +36,10 @@
"@tiptap/extension-placeholder": "^3.22.5", "@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-subscript": "^3.22.5", "@tiptap/extension-subscript": "^3.22.5",
"@tiptap/extension-superscript": "^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-item": "^3.22.5",
"@tiptap/extension-task-list": "^3.22.5", "@tiptap/extension-task-list": "^3.22.5",
"@tiptap/extension-text-align": "^3.22.5", "@tiptap/extension-text-align": "^3.22.5",
@@ -63,6 +67,7 @@
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"katex": "^0.16.27", "katex": "^0.16.27",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"marked": "^18.0.3",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
@@ -75,6 +80,7 @@
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"resend": "^6.12.0", "resend": "^6.12.0",
@@ -6703,6 +6709,59 @@
"@tiptap/pm": "3.22.5" "@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": { "node_modules/@tiptap/extension-task-item": {
"version": "3.22.5", "version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.22.5.tgz", "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" "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": { "node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", "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" "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": { "node_modules/hast-util-to-text": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", "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" "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": { "node_modules/htmlparser2": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -10407,9 +10520,9 @@
} }
}, },
"node_modules/marked": { "node_modules/marked": {
"version": "16.4.2", "version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
@@ -10846,6 +10959,18 @@
"npm": ">=10.2.3" "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": { "node_modules/mermaid/node_modules/points-on-curve": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
@@ -13386,6 +13511,21 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/remark-gfm": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", "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-placeholder": "^3.22.5",
"@tiptap/extension-subscript": "^3.22.5", "@tiptap/extension-subscript": "^3.22.5",
"@tiptap/extension-superscript": "^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-item": "^3.22.5",
"@tiptap/extension-task-list": "^3.22.5", "@tiptap/extension-task-list": "^3.22.5",
"@tiptap/extension-text-align": "^3.22.5", "@tiptap/extension-text-align": "^3.22.5",
@@ -80,6 +84,7 @@
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"katex": "^0.16.27", "katex": "^0.16.27",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"marked": "^18.0.3",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
@@ -92,6 +97,7 @@
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"resend": "^6.12.0", "resend": "^6.12.0",

View File

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