UI Stabilization: Global color theme updates (#75B2D6), AI Assistant styling refactor, and navigation fixes
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
1049
memento-note/components/contextual-ai-chat.tsx.bak
Normal file
1049
memento-note/components/contextual-ai-chat.tsx.bak
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
222
memento-note/components/note-editor/note-share-dialog.tsx
Normal file
222
memento-note/components/note-editor/note-share-dialog.tsx
Normal 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'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'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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }))}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 |
@@ -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.`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, '&')
|
breaks: true,
|
||||||
html = html.replace(/</g, '<')
|
}) as string
|
||||||
html = html.replace(/>/g, '>')
|
|
||||||
|
|
||||||
// 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 ()
|
|
||||||
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(/^>\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')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 d’insérer ici — ouvrez une note avec l’assistant 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 l’export du diagramme en image.",
|
"insertedInNote": "Diagramme inséré dans la note",
|
||||||
"insertUploadError": "Erreur lors du téléchargement de l’image.",
|
"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...",
|
||||||
|
|||||||
146
memento-note/package-lock.json
generated
146
memento-note/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user