Compare commits

7 Commits

Author SHA1 Message Date
Antigravity
bbca93c4be feat: add reminder and move-to-notebook actions to editorial note menu
- Add Bell/reminder item to EditorialNoteMenu (notes-editorial-view.tsx)
- Add FolderOpen submenu for moving notes between notebooks
- Import ReminderDialog, useNotebooks, DropdownMenuSub components
- Fix settings/general/page.tsx to pass only required props to GeneralSettingsClient

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:18:44 +00:00
Antigravity
368b43cb8e feat: improve AI Chat UX, add notebook summary, and fix shared/reminders routing
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m50s
2026-05-09 14:40:36 +00:00
Antigravity
66e957fd59 fix: add missing error handling for sendMessage promise rejections
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:15:38 +00:00
Antigravity
b0c2556a12 style: restore blue accents for AI dialog components and standardize gold header 2026-05-09 13:23:04 +00:00
Antigravity
60a3fe5453 UI Stabilization: Global color theme updates (#75B2D6), AI Assistant styling refactor, and navigation fixes 2026-05-09 12:58:16 +00:00
Antigravity
1446463f04 design: apply Architectural Minimalist style to all Settings pages
- settings/layout: serif h1 title + uppercase tracking subtitle, matching Agents page
- SettingsNav: uppercase tracking-wider tabs with foreground underline on active
- All settings pages (general, ai, appearance, profile, mcp, about, data):
  remove duplicate h1 (now in layout header), replace with uppercase section label
- notes.ts: decouple history guards from global userAISettings
- note-document-info-panel: add 'Save version' button with loading feedback
2026-05-09 07:39:35 +00:00
Antigravity
97b08e5d0b feat: icon-only toolbar, versioning fixes, history modal, PanelRight repositioning
- Toolbar: remove text labels from all icon buttons (AI, Save, Preview, Convert)
  all buttons now icon-only with title tooltip for accessibility
- Toolbar: reposition PanelRight (info panel toggle) to far right after three-dot menu
- Versioning: decouple getNoteHistory/restoreNoteVersion from global userAISettings.noteHistory
  now checks note.historyEnabled directly — unblocks manual per-note history
- Versioning: add 'Sauvegarder cette version' button in Versions tab of info panel
  calls commitNoteHistory with visual feedback (spinner → success state)
- note-document-info-panel: import commitNoteHistory, add isSavingVersion state
- notes.ts: fix double guard that silently blocked all history operations
2026-05-09 07:28:03 +00:00
119 changed files with 7002 additions and 4683 deletions

10
.claude/settings.json Normal file
View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(curl -s http://localhost:3000)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/)",
"Bash(kill 3309513)"
]
}
}

View File

@@ -24,7 +24,14 @@
"Bash(do python3 -c \"import json; json.load\\(open\\(''$f''\\)\\)\")",
"Bash(done)",
"Bash(npx prisma generate)",
"Bash(git add:*)"
"Bash(git add:*)",
"Bash(npm list *)",
"Bash(git commit -m ' *)",
"Bash(git push *)",
"mcp__zai-mcp-server__analyze_image",
"Bash(npx prisma *)",
"Bash(xargs -I{} ls {})",
"Bash(node_modules/.bin/tsc --noEmit)"
]
}
}

View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
architectural-grid (5)/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/b7b577c6-4d9f-44ac-8fe1-85bc3c6d6e66
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "Architectural Grid",
"description": "A minimalist notebook for architectural research and conceptual sketches.",
"requestFramePermissions": [],
"majorCapabilities": []
}

View File

@@ -0,0 +1,34 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Playfair Display", serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--color-paper: #F2F0E9;
--color-ink: #1C1C1C;
--color-muted-ink: rgba(28, 28, 28, 0.6);
--color-border: rgba(28, 28, 28, 0.1);
}
@layer base {
body {
@apply bg-[#E5E2D9] text-ink font-sans antialiased;
}
}
.paper-texture {
background-color: var(--color-paper);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
}
/* Custom Scrollbar - Architectural Minimalist */
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.08);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.2);
}
.ai-glass {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
.active-nav-item {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});

View File

@@ -22,26 +22,36 @@ NEXTAUTH_URL="http://localhost:3000"
# -----------------------------------------------------------------------------
# AI Providers
# -----------------------------------------------------------------------------
# Main provider: "openai" | "ollama" | "deepseek" | "openrouter" | "custom-openai"
# Main provider: "openai" | "anthropic" | "anthropic_custom" | "ollama" | "deepseek" | "openrouter" | "custom-openai"
# AI_PROVIDER="openai"
# Per-feature provider overrides (optional, falls back to AI_PROVIDER)
# AI_PROVIDER_CHAT="openai"
# AI_PROVIDER_TAGS="openai"
# AI_PROVIDER_TAGS="anthropic"
# AI_PROVIDER_EMBEDDING="openai"
# Model names (optional, uses provider defaults)
# AI_MODEL_CHAT="gpt-4o-mini"
# AI_MODEL_TAGS="gpt-4o-mini"
# AI_MODEL_TAGS="claude-sonnet-4-20250514"
# AI_MODEL_EMBEDDING="text-embedding-3-small"
# OpenAI
# OPENAI_API_KEY="sk-..."
# Anthropic (official Messages API — tags/chat only; use another provider for embeddings)
# ANTHROPIC_API_KEY="sk-ant-api03-..."
# Anthropic-compatible Messages API (custom host — ex. MiniMax M2.7, pas OpenAI)
# Same key as sur https://platform.minimax.io — base URL sans slash final.
# ANTHROPIC_CUSTOM_API_KEY="<MINIMAX_API_KEY>"
# ANTHROPIC_CUSTOM_BASE_URL="https://api.minimax.io/anthropic"
# China: https://api.minimaxi.com/anthropic — Model ID admin: MiniMax-M2.7
# Embeddings MiniMax: utiliser CUSTOM_* avec https://api.minimax.io/v1
# Ollama (local)
# OLLAMA_BASE_URL="http://localhost:11434"
# Custom OpenAI-compatible endpoint
# Custom OpenAI-compatible endpoint (incl. MiniMax OpenAI API /v1)
# CUSTOM_OPENAI_API_KEY="..."
# CUSTOM_OPENAI_BASE_URL="https://your-provider.com/v1"

View File

@@ -103,9 +103,9 @@ export default function AITestPage() {
{/* 3. Chat Test - Horizontal Layout */}
<div className="bg-card rounded-[4rem] border border-border/60 shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-700 group flex flex-col xl:flex-row">
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-violet-500/[0.05] to-transparent relative overflow-hidden">
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-zinc-500/[0.05] to-transparent relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 opacity-[0.03] group-hover:opacity-[0.08] transition-all duration-700 group-hover:scale-125 group-hover:-rotate-6">
<MessageSquare className="h-80 w-80 text-violet-500" />
<MessageSquare className="h-80 w-80 text-zinc-500" />
</div>
<div className="relative space-y-8">
<div className="w-20 h-20 rounded-[1.5rem] bg-background flex items-center justify-center text-4xl shadow-2xl border border-border/50 group-hover:scale-110 transition-transform duration-500">
@@ -116,12 +116,12 @@ export default function AITestPage() {
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.chatTestDescription')}</p>
</div>
<div className="flex flex-wrap gap-3">
<span className="px-4 py-2 bg-violet-500/10 rounded-xl text-violet-600 text-[10px] font-black uppercase tracking-widest">Conversational</span>
<span className="px-4 py-2 bg-violet-500/10 rounded-xl text-violet-600 text-[10px] font-black uppercase tracking-widest">Streaming</span>
<span className="px-4 py-2 bg-zinc-500/10 rounded-xl text-zinc-600 text-[10px] font-black uppercase tracking-widest">Conversational</span>
<span className="px-4 py-2 bg-zinc-500/10 rounded-xl text-zinc-600 text-[10px] font-black uppercase tracking-widest">Streaming</span>
</div>
</div>
</div>
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-violet-500/[0.01]">
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-zinc-500/[0.01]">
<div className="max-w-4xl">
<AI_TESTER type="chat" />
</div>

View File

@@ -1,29 +1,21 @@
import { AdminHeader } from '@/components/admin-header'
import { AdminNav } from '@/components/admin-nav'
import { AdminSidebar } from '@/components/admin-sidebar'
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
// to avoid React Error #310 caused by Next.js 16.x route-group transition bug.
// Navigation admin ↔ app en <a> (rechargement complet) pour éviter React Error #310
// sur les transitions entre route groups (Next.js 16 / React #33580).
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="bg-background flex flex-col min-h-screen">
<AdminHeader />
{/* Horizontal Tab Navigation */}
<div className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
<AdminNav />
</div>
{/* Page Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
<div className="flex h-screen overflow-hidden bg-[#E5E2D9] dark:bg-background">
<AdminSidebar />
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
<div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-6 sm:px-6 sm:py-8 lg:px-10">
{children}
</div>
</div>
</main>
</div>
)
}

View File

@@ -12,12 +12,33 @@ import { useState, useEffect, useCallback } from 'react'
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
type AIProvider =
| 'ollama'
| 'openai'
| 'anthropic'
| 'anthropic_custom'
| 'custom'
| 'deepseek'
| 'openrouter'
| 'mistral'
| 'zai'
| 'lmstudio'
/** Providers that cannot be used for embeddings in Memento (no embedding API wired). */
const PROVIDERS_WITHOUT_EMBEDDINGS: AIProvider[] = ['anthropic', 'anthropic_custom']
// Provider config metadata
const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: string; hasApiKey: boolean; hasBaseUrl: boolean; isLocal: boolean }> = {
ollama: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true },
openai: { apiKeyLabel: 'OPENAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
anthropic: { apiKeyLabel: 'ANTHROPIC_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
anthropic_custom: {
apiKeyLabel: 'ANTHROPIC_CUSTOM_API_KEY',
baseUrlLabel: 'admin.ai.baseUrl',
hasApiKey: true,
hasBaseUrl: true,
isLocal: false,
},
deepseek: { apiKeyLabel: 'DEEPSEEK_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
openrouter:{ apiKeyLabel: 'OPENROUTER_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
mistral: { apiKeyLabel: 'MISTRAL_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
@@ -30,6 +51,8 @@ const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: str
const API_KEY_CONFIG: Record<AIProvider, string> = {
ollama: '',
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
anthropic_custom: 'ANTHROPIC_CUSTOM_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
mistral: 'MISTRAL_API_KEY',
@@ -41,6 +64,8 @@ const API_KEY_CONFIG: Record<AIProvider, string> = {
const BASE_URL_CONFIG: Record<AIProvider, string> = {
ollama: 'OLLAMA_BASE_URL',
openai: '',
anthropic: '',
anthropic_custom: 'ANTHROPIC_CUSTOM_BASE_URL',
deepseek: '',
openrouter: '',
mistral: '',
@@ -52,6 +77,8 @@ const BASE_URL_CONFIG: Record<AIProvider, string> = {
const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
ollama: 'http://localhost:11434',
openai: '',
anthropic: '',
anthropic_custom: '',
deepseek: 'https://api.deepseek.com/v1',
openrouter: 'https://openrouter.ai/api/v1',
mistral: 'https://api.mistral.ai/v1',
@@ -63,6 +90,24 @@ const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
// Suggested models per provider (shown as hints in Combobox - user can always type a custom name)
const SUGGESTED_MODELS: Record<string, string[]> = {
openai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o4-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: [
'claude-sonnet-4-20250514',
'claude-sonnet-4-5',
'claude-opus-4-20250514',
'claude-opus-4-5',
'claude-haiku-4-5',
'claude-3-haiku-20240307',
],
anthropic_custom: [
'MiniMax-M2.7',
'MiniMax-M2.7-highspeed',
'MiniMax-M2.5',
'MiniMax-M2.5-highspeed',
'MiniMax-M2.1',
'MiniMax-M2.1-highspeed',
'MiniMax-M2',
'claude-sonnet-4-20250514',
],
openrouter: ['openai/gpt-4o-mini', 'openai/gpt-4.1-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-flash-preview', 'google/gemma-4-26b-a4b-it', 'meta-llama/llama-4-maverick', 'deepseek/deepseek-chat-v3-0324'],
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', 'codestral-latest', 'mistral-embed'],
@@ -100,7 +145,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
// AI Provider state - separated for tags, embeddings, and chat
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>(() => {
const v = (config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama'
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
})
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
// Selected Models State
@@ -170,7 +218,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
await fetchModels('tags', 'ollama', config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (tagsProvider === 'lmstudio') {
await fetchModels('tags', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[tagsProvider]?.hasApiKey) {
} else if (PROVIDER_META[tagsProvider]?.hasApiKey && tagsProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[tagsProvider]
const key = config[API_KEY_CONFIG[tagsProvider]] || ''
if (url && key) await fetchModels('tags', tagsProvider, url, key)
@@ -180,7 +228,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
await fetchModels('embeddings', 'ollama', config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (embeddingsProvider === 'lmstudio') {
await fetchModels('embeddings', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey) {
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey && embeddingsProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[embeddingsProvider]
const key = config[API_KEY_CONFIG[embeddingsProvider]] || ''
if (url && key) await fetchModels('embeddings', embeddingsProvider, url, key)
@@ -190,7 +238,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
await fetchModels('chat', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (chatProvider === 'lmstudio') {
await fetchModels('chat', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[chatProvider]?.hasApiKey) {
} else if (PROVIDER_META[chatProvider]?.hasApiKey && chatProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[chatProvider]
const key = config[API_KEY_CONFIG[chatProvider]] || ''
if (url && key) await fetchModels('chat', chatProvider, url, key)
@@ -459,13 +507,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
? (config.LMSTUDIO_BASE_URL || DEFAULT_BASE_URLS.lmstudio)
: (config[BASE_URL_CONFIG[provider]] || DEFAULT_BASE_URLS[provider] || '')
}
placeholder={DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')}
placeholder={
provider === 'anthropic_custom'
? 'https://api.minimax.io/anthropic'
: DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')
}
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
if (provider === 'anthropic_custom') {
toast.info(t('admin.ai.anthropicCustomNoModelList'))
return
}
const urlInput = document.getElementById(`BASE_URL_${provider}_${purpose}`) as HTMLInputElement
const keyInput = meta.hasApiKey
? document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
@@ -474,7 +530,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const key = keyInput?.value || (meta.hasApiKey ? config[API_KEY_CONFIG[provider]] : undefined)
if (url) fetchModels(purpose, provider, url, key)
}}
disabled={loading}
disabled={loading || provider === 'anthropic_custom'}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
@@ -500,7 +556,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const key = keyInput?.value || config[API_KEY_CONFIG[provider]] || ''
if (url && key) fetchModels(purpose, provider, url, key)
}}
disabled={loading}
disabled={loading || provider === 'anthropic'}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
@@ -525,9 +581,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
? t('admin.ai.fetchingModels')
: dynamicModels[purpose].length > 0
? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length })
: provider === 'ollama' || provider === 'lmstudio'
? t('admin.ai.selectOllamaModel')
: t('admin.ai.enterUrlToLoad')}
: provider === 'anthropic'
? t('admin.ai.anthropicModelHint')
: provider === 'anthropic_custom'
? t('admin.ai.anthropicCustomModelHint')
: provider === 'ollama' || provider === 'lmstudio'
? t('admin.ai.selectOllamaModel')
: t('admin.ai.enterUrlToLoad')}
</p>
</div>
</div>
@@ -538,6 +598,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const providerOptions = [
{ value: 'ollama', label: t('admin.ai.providerOllamaOption') },
{ value: 'openai', label: t('admin.ai.providerOpenAIOption') },
{ value: 'anthropic', label: t('admin.ai.providerAnthropicOption') },
{ value: 'anthropic_custom', label: t('admin.ai.providerAnthropicCustomOption') },
{ value: 'deepseek', label: t('admin.ai.providerDeepSeekOption') },
{ value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') },
{ value: 'mistral', label: t('admin.ai.providerMistralOption') },
@@ -546,6 +608,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
{ value: 'custom', label: t('admin.ai.providerCustomOption') },
]
const embeddingsProviderOptions = providerOptions.filter(
(opt) => !PROVIDERS_WITHOUT_EMBEDDINGS.includes(opt.value as AIProvider)
)
return (
<div className="columns-1 lg:columns-2 gap-6">
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
@@ -657,7 +723,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{providerOptions.map(opt => (
{embeddingsProviderOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
@@ -670,7 +736,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
{/* Chat Provider */}
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'chat' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
<span className="text-zinc-600">💬</span> {t('admin.ai.chatProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>

View File

@@ -31,12 +31,12 @@ export default async function MainLayout({
return (
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-[#E5E2D9]">
<div className="flex h-screen overflow-hidden bg-memento-desk">
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
<Sidebar user={session?.user} />
</Suspense>
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper">
{children}
</main>

View File

@@ -25,6 +25,7 @@ export default async function HomePage() {
notesViewMode,
noteHistory: settings?.noteHistory === true,
noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
aiAssistantEnabled: settings?.paragraphRefactor !== false,
}}
/>
)

View File

@@ -1,9 +0,0 @@
import { getNotesWithReminders } from '@/app/actions/notes'
import { RemindersPage } from '@/components/reminders-page'
export const dynamic = 'force-dynamic'
export default async function RemindersRoute() {
const notes = await getNotesWithReminders()
return <RemindersPage notes={notes} />
}

View File

@@ -11,10 +11,9 @@ export default function AboutSettingsPage() {
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('about.title')}</h1>
<p className="text-muted-foreground mt-1">{t('about.description')}</p>
</div>
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
{t('about.description')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* App info */}

View File

@@ -6,9 +6,8 @@ export function AISettingsHeader() {
const { t } = useLanguage()
return (
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('aiSettings.title')}</h1>
<p className="text-muted-foreground mt-1">{t('aiSettings.description')}</p>
</div>
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
{t('aiSettings.description')}
</p>
)
}

View File

@@ -63,12 +63,18 @@ export function AppearanceSettingsClient({
}
const handleFontFamilyChange = async (value: string) => {
const font = value === 'system' ? 'system' : 'inter'
const font = value === 'system' ? 'system'
: value === 'playfair' ? 'playfair'
: value === 'jetbrains' ? 'jetbrains'
: 'inter'
setFontFamily(font)
localStorage.setItem('font-family', font)
const root = document.documentElement
font === 'system' ? root.classList.add('font-system') : root.classList.remove('font-system')
await updateAISettings({ fontFamily: font })
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
if (font === 'system') root.classList.add('font-system')
if (font === 'playfair') root.classList.add('font-playfair')
if (font === 'jetbrains') root.classList.add('font-jetbrains')
await updateAISettings({ fontFamily: font as 'inter' | 'playfair' | 'jetbrains' | 'system' })
toast.success(t('settings.settingsSaved') || 'Saved')
}
@@ -157,10 +163,10 @@ export function AppearanceSettingsClient({
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('appearance.title')}</h1>
<p className="text-muted-foreground mt-1">{t('appearance.description')}</p>
</div>
{/* Section label — architectural style */}
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
{t('appearance.description') || "Personnalisez l'interface"}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<SelectCard
@@ -192,7 +198,9 @@ export function AppearanceSettingsClient({
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
value={fontFamily}
options={[
{ value: 'inter', label: 'Inter' },
{ value: 'inter', label: 'Inter (défaut)' },
{ value: 'playfair', label: 'Playfair Display' },
{ value: 'jetbrains', label: 'JetBrains Mono' },
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
]}
onChange={handleFontFamilyChange}

View File

@@ -118,17 +118,16 @@ export default function DataSettingsPage() {
}
return (
<div className="max-w-4xl mx-auto space-y-8 p-6">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight text-foreground">{t('dataManagement.title')}</h1>
<p className="text-muted-foreground">{t('dataManagement.toolsDescription')}</p>
</div>
<div className="space-y-8">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
{t('dataManagement.toolsDescription')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Export card */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
<div className="space-y-4">
<div className="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-600 shrink-0">
<div className="w-12 h-12 rounded-full bg-zinc-500/10 flex items-center justify-center text-zinc-600 shrink-0">
<Download className="h-6 w-6" />
</div>
<div>

View File

@@ -12,6 +12,7 @@ interface GeneralSettingsClientProps {
preferredLanguage: string
emailNotifications: boolean
desktopNotifications: boolean
autoSave: boolean
}
}
@@ -21,15 +22,18 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
const [autoSave, setAutoSave] = useState(initialSettings.autoSave ?? true)
const handleLanguageChange = async (value: string) => {
setLanguage(value)
await updateAISettings({ preferredLanguage: value as any })
if (value === 'auto') {
localStorage.removeItem('user-language')
document.cookie = 'user-language=;path=/;max-age=0'
toast.success(t('settings.languageAuto') || 'Language set to Auto')
} else {
localStorage.setItem('user-language', value)
document.cookie = `user-language=${value};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`
setContextLanguage(value as any)
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
}
@@ -47,14 +51,18 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
await updateAISettings({ desktopNotifications: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleAutoSaveChange = async (enabled: boolean) => {
setAutoSave(enabled)
await updateAISettings({ autoSave: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
return (
<div className="space-y-8">
{/* Page title */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('generalSettings.title')}</h1>
<p className="text-muted-foreground mt-1">{t('generalSettings.description')}</p>
</div>
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
{t('generalSettings.description')}
</p>
{/* 2-column card grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -142,6 +150,22 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div className="border-t border-border pt-5 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{t('settings.autoSave') || 'Auto-Save'}</p>
<p className="text-xs text-muted-foreground">{t('settings.autoSaveDesc') || 'Sauvegarder automatiquement les modifications'}</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoSave}
onClick={() => handleAutoSaveChange(!autoSave)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${autoSave ? 'bg-primary' : 'bg-muted-foreground/30'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSave ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
</div>
</div>

View File

@@ -9,7 +9,16 @@ export default async function GeneralSettingsPage() {
redirect('/api/auth/signin')
}
const settings = await getAISettings()
const {
preferredLanguage,
emailNotifications,
desktopNotifications,
autoSave,
} = await getAISettings()
return <GeneralSettingsClient initialSettings={settings} />
return (
<GeneralSettingsClient
initialSettings={{ preferredLanguage, emailNotifications, desktopNotifications, autoSave }}
/>
)
}

View File

@@ -8,18 +8,30 @@ export default function SettingsLayout({
children: React.ReactNode
}) {
return (
<div className="flex flex-col h-full">
{/* Horizontal Tab Navigation */}
<header className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
<SettingsNav />
<div className="flex flex-col h-full bg-[#F2F0E9]">
{/* Architectural header — matches Agents page */}
<header className="flex flex-col px-12 pt-10 pb-0 border-b border-border/40 shrink-0">
<div className="flex items-end justify-between mb-6">
<div>
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
Paramètres
</h1>
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
Configuration &amp; Préférences
</p>
</div>
</div>
{/* Tab nav flush to the border-bottom of header */}
<SettingsNav className="-mb-px" />
</header>
{/* Page Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
<div className="max-w-5xl mx-auto px-12 py-10 space-y-8">
{children}
</div>
</div>
</div>
)
}

View File

@@ -14,10 +14,9 @@ export default async function McpSettingsPage() {
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">Paramètres MCP</h1>
<p className="text-muted-foreground mt-1">Gérez vos clés API et serveurs MCP connectés.</p>
</div>
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
Gérez vos clés API et serveurs MCP connectés.
</p>
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
</div>
)

View File

@@ -13,10 +13,9 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('profile.title')}</h1>
<p className="text-muted-foreground mt-1">{t('profile.description')}</p>
</div>
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
{t('profile.description')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Profile info card */}

View File

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

View File

@@ -0,0 +1,100 @@
'use server'
import DOMPurify from 'isomorphic-dompurify'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { getAISettings } from '@/app/actions/ai-settings'
import { revalidatePath } from 'next/cache'
function extractSvgSnippet(raw: string): string | null {
const trimmed = raw.trim()
const fenced = trimmed.match(/```(?:svg)?\s*([\s\S]*?)```/i)
const candidate = (fenced ? fenced[1] : trimmed).trim()
const start = candidate.indexOf('<svg')
const end = candidate.lastIndexOf('</svg>')
if (start === -1 || end === -1 || end <= start) return null
return candidate.slice(start, end + 6)
}
function sanitizeSvgMarkup(svg: string): string {
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['viewBox', 'xmlns', 'preserveAspectRatio'],
})
}
/**
* Génère une miniature SVG abstraite pour le flux éditorial (via modèle chat configuré).
* Respecte les préférences utilisateur (assistant IA activé) et nettoie le SVG.
*/
export async function generateNoteIllustrationSvg(noteId: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await auth()
if (!session?.user?.id) return { ok: false, error: 'Non autorisé' }
try {
const settings = await getAISettings(session.user.id)
if (settings.paragraphRefactor === false) {
return { ok: false, error: 'Assistant IA désactivé dans vos paramètres.' }
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, title: true, content: true },
})
if (!note) return { ok: false, error: 'Note introuvable' }
const plainTitle = (note.title || '').slice(0, 200)
const plainBody = note.content
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 1200)
if (!plainBody && !plainTitle) {
return { ok: false, error: 'Ajoutez du contenu avant de générer une illustration.' }
}
const config = await getSystemConfig()
const provider = getAIProvider(config)
const prompt = `Tu es un designer minimaliste. Produis UN SEUL document SVG valide pour une vignette de carte note.
Contraintes strictes:
- viewBox="0 0 224 168" (rapport 4:3), pas de width/height fixes en px sur la racine ou width="100%" height="100%"
- Style architectural / papier, 24 formes géométriques ou lignes, palette sobre (noir/gris/une couleur douce), pas de texte lisible
- AUCUN script, AUCUNE balise foreignObject, AUCUN lien externe, AUCUN attribut on*
- Réponds UNIQUEMENT avec le fragment SVG (commence par <svg ...> et finit par </svg>), sans markdown ni commentaire.
Thème à suggérer visuellement (abstrait, pas littéral):
Titre: ${plainTitle || '(sans titre)'}
Extrait: ${plainBody.slice(0, 400)}`
const raw = await provider.generateText(prompt)
const extracted = extractSvgSnippet(raw)
if (!extracted) {
return { ok: false, error: 'Le modèle na pas renvoyé un SVG valide. Réessayez.' }
}
const safe = sanitizeSvgMarkup(extracted)
if (!safe.includes('<svg')) {
return { ok: false, error: 'SVG rejeté après sécurisation.' }
}
await prisma.note.update({
where: { id: noteId, userId: session.user.id },
data: {
illustrationSvg: safe,
lastAiAnalysis: new Date(),
},
})
revalidatePath('/')
return { ok: true }
} catch (e) {
console.error('[note-illustration]', e)
const msg = e instanceof Error ? e.message : 'Erreur inconnue'
return { ok: false, error: msg.includes('required') ? 'Configurez un fournisseur IA (admin ou paramètres système).' : msg }
}
}

View File

@@ -214,7 +214,7 @@ async function syncNoteLabels(noteId: string, labelNames: string[], notebookId:
if (Array.isArray(parsed)) {
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
}
} catch {}
} catch { }
}
}
// Delete labels not in use
@@ -372,16 +372,14 @@ export async function getNoteHistory(noteId: string, limit = 30) {
const session = await auth()
if (!session?.user?.id) return []
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
if (!enabled) return []
const clampedLimit = Math.min(Math.max(limit, 1), 100)
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
select: { id: true, historyEnabled: true },
})
if (!note) return []
// History not found or not enabled on this note
if (!note || !note.historyEnabled) return []
const entries = await prisma.noteHistory.findMany({
where: { noteId: note.id, userId: session.user.id },
@@ -396,13 +394,10 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
if (!enabled) throw new Error('History is disabled')
const [note, historyEntry] = await Promise.all([
prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, notebookId: true },
select: { id: true, notebookId: true, historyEnabled: true },
}),
prisma.noteHistory.findFirst({
where: {
@@ -413,9 +408,8 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
}),
])
if (!note || !historyEntry) {
throw new Error('History entry not found')
}
if (!note || !note.historyEnabled) throw new Error('History is disabled for this note')
if (!historyEntry) throw new Error('History entry not found')
const userId = session.user.id
@@ -686,91 +680,91 @@ export async function createNote(data: {
const notebookId = data.notebookId
const hasUserLabels = data.labels && data.labels.length > 0
// Use setImmediate-like pattern to not block the response
;(async () => {
try {
// Background task 1: Generate embedding
const bgConfig = await getSystemConfig()
const provider = getAIProvider(bgConfig)
const embedding = await provider.getEmbeddings(content)
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
}
} catch (e) {
console.error('[BG] Embedding generation failed:', e)
}
// Background task 2: Auto-labeling (only if no user labels and has notebook)
if (!hasUserLabels && notebookId) {
// Use setImmediate-like pattern to not block the response
; (async () => {
try {
const userAISettings = await getAISettings(userId)
const autoLabelingEnabled = userAISettings.autoLabeling !== false
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
// Background task 1: Generate embedding
const bgConfig = await getSystemConfig()
const provider = getAIProvider(bgConfig)
const embedding = await provider.getEmbeddings(content)
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
}
} catch (e) {
console.error('[BG] Embedding generation failed:', e)
}
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
// Background task 2: Auto-labeling (only if no user labels and has notebook)
if (!hasUserLabels && notebookId) {
try {
const userAISettings = await getAISettings(userId)
const autoLabelingEnabled = userAISettings.autoLabeling !== false
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
if (autoLabelingEnabled) {
// Detect user's language from their existing notes for localized prompts
let userLang = 'en'
try {
const langResult = await prisma.note.groupBy({
by: ['language'],
where: { userId, language: { not: null } },
_count: true,
orderBy: { _count: { language: 'desc' } },
take: 1,
})
if (langResult.length > 0 && langResult[0].language) {
userLang = langResult[0].language
}
} catch {}
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
const suggestions = await contextualAutoTagService.suggestLabels(
content,
notebookId,
userId,
userLang
)
if (autoLabelingEnabled) {
// Detect user's language from their existing notes for localized prompts
let userLang = 'en'
try {
const langResult = await prisma.note.groupBy({
by: ['language'],
where: { userId, language: { not: null } },
_count: true,
orderBy: { _count: { language: 'desc' } },
take: 1,
})
if (langResult.length > 0 && langResult[0].language) {
userLang = langResult[0].language
}
} catch { }
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
const suggestions = await contextualAutoTagService.suggestLabels(
content,
notebookId,
userId,
userLang
)
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label)
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
if (appliedLabels.length > 0) {
// Merge with existing labels
const existing = await prisma.note.findUnique({
where: { id: noteId },
select: { labels: true },
})
let existingNames: string[] = []
if (existing?.labels) {
try {
const parsed = existing.labels as unknown
existingNames = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
: []
} catch { existingNames = [] }
}
const merged = [...new Set([...existingNames, ...appliedLabels])]
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
if (!data.skipRevalidation) {
revalidatePath('/')
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label)
if (appliedLabels.length > 0) {
// Merge with existing labels
const existing = await prisma.note.findUnique({
where: { id: noteId },
select: { labels: true },
})
let existingNames: string[] = []
if (existing?.labels) {
try {
const parsed = existing.labels as unknown
existingNames = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
: []
} catch { existingNames = [] }
}
const merged = [...new Set([...existingNames, ...appliedLabels])]
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
if (!data.skipRevalidation) {
revalidatePath('/')
}
}
}
} catch (error) {
console.error('[BG] Auto-labeling failed:', error)
}
} catch (error) {
console.error('[BG] Auto-labeling failed:', error)
} else {
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
}
} else {
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
}
})()
})()
return parseNote(note)
} catch (error) {
@@ -825,21 +819,21 @@ export async function updateNote(id: string, data: {
if (data.content !== undefined) {
const noteId = id
const content = data.content
;(async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
; (async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
}
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
}
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
}
})()
})()
}
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
@@ -861,10 +855,18 @@ export async function updateNote(id: string, data: {
updateData.contentUpdatedAt = new Date()
}
const note = await prisma.note.update({
where: { id, userId: session.user.id },
data: updateData
})
console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id)
let note
try {
note = await prisma.note.update({
where: { id, userId: session.user.id },
data: updateData
})
console.log('[updateNote] Succeeded, note id:', note?.id)
} catch (dbError: any) {
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
throw dbError
}
// Sync labels (JSON + labelRelations + Label rows)
const notebookMoved =
@@ -908,9 +910,15 @@ export async function updateNote(id: string, data: {
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
const isStructuralChange = structuralFields.some(field => field in data)
if (isStructuralChange && !options?.skipRevalidation) {
revalidatePath('/')
console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
if (!options?.skipRevalidation) {
// Always revalidate note individual page on content changes so UI reflects saved data
revalidatePath(`/note/${id}`)
revalidatePath('/')
}
if (isStructuralChange) {
if (data.isArchived !== undefined) {
revalidatePath('/archive')
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ import Script from "next/script";
import { getThemeScript } from "@/lib/theme-script";
import { normalizeThemeId } from "@/lib/apply-document-theme";
import { Inter, Manrope, Playfair_Display } from "next/font/google";
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
@@ -30,6 +30,12 @@ const playfair = Playfair_Display({
weight: ["400", "500", "600", "700"],
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "Memento - Your Digital Notepad",
description: "A beautiful note-taking app built with Next.js 16",
@@ -69,6 +75,10 @@ const directionScript = `
(function(){
try {
var lang = localStorage.getItem('user-language');
if (!lang) {
var c = document.cookie.split(';').map(function(s){return s.trim()}).find(function(s){return s.startsWith('user-language=')});
if (c) lang = c.split('=')[1];
}
if (lang === 'fa' || lang === 'ar') {
document.documentElement.dir = 'rtl';
document.documentElement.lang = lang;
@@ -99,7 +109,7 @@ export default async function RootLayout({
data-theme={htmlTheme.dataTheme}
>
<head />
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable}`}>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable}`}>
<Script
id="theme-early"
strategy="beforeInteractive"

View File

@@ -1,136 +0,0 @@
'use client'
import { signOut, useSession } from 'next-auth/react'
import { Shield, Search, Settings, LogOut, User, StickyNote, FlaskConical, Bot } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { NotificationPanel } from './notification-panel'
import { useLanguage } from '@/lib/i18n'
/**
* Admin header — visuellement identique au Header principal.
* Utilise exclusivement des <a> (rechargement complet) au lieu de <Link>
* pour éviter React Error #310 (bug React #33580 / Next.js #63388).
*/
export function AdminHeader() {
const { data: session } = useSession()
const { t } = useLanguage()
const user = session?.user
const initial = user?.name
? user.name.charAt(0).toUpperCase()
: user?.email?.[0]?.toUpperCase() ?? '?'
return (
<header className="flex-none flex items-center justify-between whitespace-nowrap border-b border-solid border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e2128] px-6 py-3 z-20">
{/* ── Logo + Search ── */}
<div className="flex items-center gap-8">
<a href="/" className="flex items-center gap-3 text-slate-900 dark:text-white group no-underline">
<div className="size-8 bg-primary rounded-lg flex items-center justify-center text-primary-foreground shadow-sm group-hover:shadow-md transition-all">
<StickyNote className="w-5 h-5" />
</div>
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
</a>
{/* Badge Admin */}
<span className="hidden sm:flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-semibold">
<Shield className="h-3 w-3" />
Admin
</span>
{/* Search (décoratif en mode admin) — même taille que l'entête principale */}
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
<div className="flex w-full flex-1 items-stretch rounded-full h-full bg-slate-100 dark:bg-slate-800 border border-transparent">
<div className="text-slate-400 dark:text-slate-400 flex items-center justify-center pl-4">
<Search className="w-4 h-4" />
</div>
<input
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
placeholder={t('search.placeholder') }
type="text"
disabled
aria-label={t('search.disabledAdmin')}
/>
</div>
</label>
</div>
{/* ── Droite : nav + notifs + settings + avatar ── */}
<div className="flex flex-1 justify-end gap-2 items-center">
{/* Nav pills — toutes en <a> pour éviter la RSC race condition */}
<div className="hidden md:flex items-center gap-1 bg-slate-100 dark:bg-slate-800/60 rounded-full px-1.5 py-1">
<a
href="/agents"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<Bot className="h-3.5 w-3.5" />
<span>{t('nav.agents') || 'Agents'}</span>
</a>
<a
href="/lab"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<FlaskConical className="h-3.5 w-3.5" />
<span>{t('nav.lab') || 'The Lab'}</span>
</a>
</div>
{/* Notifications */}
<NotificationPanel />
{/* Settings */}
<a
href="/settings"
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
aria-label={t('settings.title')}
>
<Settings className="w-5 h-5" />
</a>
{/* Avatar + menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-full size-10 ring-2 ring-white dark:ring-slate-700 cursor-pointer shadow-sm hover:shadow-md transition-shadow bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
style={user?.image ? { backgroundImage: `url(${(user as any).image})` } : undefined}
>
{!user?.image && (
<span className="text-sm font-semibold">{initial}</span>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
{user?.name && <p className="font-medium">{user.name}</p>}
{user?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{user.email}</p>}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<a href="/settings/profile">
<User className="mr-2 h-4 w-4" />
<span>{t('settings.profile') }</span>
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/' })}
className="cursor-pointer text-red-600 focus:text-red-600"
>
<LogOut className="mr-2 h-4 w-4" />
<span>{t('auth.signOut') }</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}

View File

@@ -1,71 +0,0 @@
'use client'
import { usePathname } from 'next/navigation'
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export interface AdminNavProps {
className?: string
}
export interface NavItem {
titleKey: string
href: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{
titleKey: 'admin.sidebar.dashboard',
href: '/admin',
icon: <LayoutDashboard className="h-4 w-4" />,
},
{
titleKey: 'admin.sidebar.users',
href: '/admin/users',
icon: <Users className="h-4 w-4" />,
},
{
titleKey: 'admin.sidebar.aiManagement',
href: '/admin/ai',
icon: <Brain className="h-4 w-4" />,
},
{
titleKey: 'admin.sidebar.settings',
href: '/admin/settings',
icon: <Settings className="h-4 w-4" />,
},
]
export function AdminNav({ className }: AdminNavProps) {
const pathname = usePathname()
const { t } = useLanguage()
return (
<nav className={cn('flex items-center gap-1', className)}>
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href + '/'))
return (
// <a> instead of <Link>: avoids Next.js RSC navigation transitions
// that trigger React Error #310 (React bug #33580) in production.
// Full-page reloads are acceptable for admin navigation.
<a
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
)}
>
{item.icon}
<span>{t(item.titleKey)}</span>
</a>
)
})}
</nav>
)
}

View File

@@ -1,6 +1,8 @@
'use client'
import { LanguageProvider } from '@/lib/i18n/LanguageProvider'
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
import { QueryProvider } from '@/components/query-provider'
import type { Translations } from '@/lib/i18n/load-translations'
import type { ReactNode } from 'react'
@@ -16,11 +18,15 @@ export function AdminProvidersWrapper({
initialTranslations,
}: AdminProvidersWrapperProps) {
return (
<LanguageProvider
initialLanguage={initialLanguage as any}
initialTranslations={initialTranslations}
>
{children}
</LanguageProvider>
<QueryProvider>
<NoteRefreshProvider>
<LanguageProvider
initialLanguage={initialLanguage as any}
initialTranslations={initialTranslations}
>
{children}
</LanguageProvider>
</NoteRefreshProvider>
</QueryProvider>
)
}

View File

@@ -0,0 +1,219 @@
'use client'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import {
LayoutDashboard,
Users,
Brain,
Settings,
StickyNote,
Shield,
ArrowLeft,
User,
LogOut,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { NotificationPanel } from '@/components/notification-panel'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
const ADMIN_NAV_ITEMS = [
{
titleKey: 'admin.sidebar.dashboard',
href: '/admin',
icon: LayoutDashboard,
},
{
titleKey: 'admin.sidebar.users',
href: '/admin/users',
icon: Users,
},
{
titleKey: 'admin.sidebar.aiManagement',
href: '/admin/ai',
icon: Brain,
},
{
titleKey: 'admin.sidebar.settings',
href: '/admin/settings',
icon: Settings,
},
] as const
function navItemIsActive(pathname: string | null, href: string): boolean {
if (!pathname) return false
if (href === '/admin') return pathname === '/admin'
if (href === '/admin/ai') return pathname === '/admin/ai' || pathname.startsWith('/admin/ai')
return pathname === href || pathname.startsWith(`${href}/`)
}
/**
* Barre latérale administration — même vocabulaire visuel que {@link Sidebar}
* (fond bureau, panneau vitré, navigation arrondie). Liens en &lt;a&gt; pour
* éviter les transitions RSC qui déclenchent React #310 entre groupes de routes.
*/
export function AdminSidebar({ className }: { className?: string }) {
const pathname = usePathname()
const { t } = useLanguage()
const { data: session } = useSession()
const user = session?.user
const initial = user?.name
? user.name.charAt(0).toUpperCase()
: user?.email?.[0]?.toUpperCase() ?? '?'
return (
<aside
className={cn(
'flex h-full min-h-0 w-64 shrink-0 flex-col sm:w-72 lg:w-80',
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-border/30 dark:bg-sidebar/90',
className
)}
>
{/* Marque + retour app */}
<div className="flex flex-col gap-4 p-6 pb-4">
<div className="flex items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
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') || 'Account menu'}
>
<div className="flex size-10 items-center justify-center overflow-hidden rounded-full border border-border bg-muted shadow-sm">
<Avatar className="size-10 ring-1 ring-border/60">
<AvatarImage src={(user as { image?: string } | undefined)?.image} alt="" />
<AvatarFallback className="bg-primary/10 font-memento-serif text-lg font-semibold text-primary">
{initial}
</AvatarFallback>
</Avatar>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
<DropdownMenuItem asChild className="cursor-pointer">
<a href="/settings/profile" className="flex items-center gap-2">
<User className="h-4 w-4" />
{t('settings.profile')}
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<a href="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
{t('nav.settings')}
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
{t('auth.signOut')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<a
href="/"
className={cn(
'flex min-w-0 flex-1 items-center gap-2 rounded-xl px-2 py-1.5 transition-colors',
'hover:bg-white/40 dark:hover:bg-white/10'
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<StickyNote className="size-4" />
</div>
<div className="min-w-0 text-left">
<p className="truncate font-memento-serif text-[13px] font-semibold tracking-tight text-foreground">
MEMENTO
</p>
<p className="truncate text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{t('admin.adminConsole')}
</p>
</div>
</a>
</div>
<a
href="/"
className={cn(
'flex items-center gap-3 rounded-xl px-4 py-2.5 text-[13px] font-medium transition-all',
'text-muted-foreground hover:bg-white/40 hover:text-foreground dark:hover:bg-white/10'
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-full border border-border bg-white/60 dark:bg-white/10">
<ArrowLeft className="size-4" />
</div>
<span>{t('admin.backToApp')}</span>
</a>
<div className="flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary dark:bg-primary/20">
<Shield className="size-3.5 shrink-0" />
<span>{t('admin.title')}</span>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto px-4 pb-4 custom-scrollbar">
<p className="mb-3 px-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t('admin.navSection')}
</p>
<nav className="space-y-1">
{ADMIN_NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = navItemIsActive(pathname, item.href)
return (
<a
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-xl px-4 py-3 transition-all duration-300',
active
? 'memento-active-nav text-foreground'
: 'text-muted-foreground hover:bg-white/40 hover:text-foreground dark:hover:bg-white/10'
)}
>
<div
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-full border transition-colors',
active
? 'border-foreground bg-foreground text-background'
: 'border-border bg-white/60 dark:bg-white/10'
)}
>
<Icon className="size-4" />
</div>
<span className="text-[13px] font-medium">{t(item.titleKey)}</span>
</a>
)
})}
</nav>
</div>
{/* Pied */}
<div className="space-y-1 border-t border-border p-5 pt-4">
<div className="flex items-center gap-3 px-4 py-2">
<NotificationPanel />
<span className="text-[13px] font-medium text-muted-foreground">
{t('notification.notifications')}
</span>
</div>
<a
href="/settings"
className="flex items-center gap-3 rounded-lg px-4 py-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10"
>
<Settings className="size-4" />
<span>{t('nav.settings')}</span>
</a>
</div>
</aside>
)
}

View File

@@ -83,19 +83,24 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
}
}
await sendMessage(
{ text },
{
body: {
tone: selectedTone,
chatScope,
notebookId: chatScope !== 'all' ? chatScope : undefined,
webSearch: webSearch && webSearchAvailable,
conversationId: convId,
language,
try {
await sendMessage(
{ text },
{
body: {
tone: selectedTone,
chatScope,
notebookId: chatScope !== 'all' ? chatScope : undefined,
webSearch: webSearch && webSearchAvailable,
conversationId: convId,
language,
}
}
}
)
)
} catch (error) {
console.error('Chat send error:', error)
toast.error(t('chat.assistantError') || 'Failed to send message')
}
}
const fetchHistory = async () => {
@@ -147,18 +152,18 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
return (
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105"
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105 bg-muted text-foreground hover:bg-muted/80 border border-border"
size="icon"
title={t('ai.openAssistant')}
>
<Sparkles className="h-5 w-5" />
<Sparkles className="h-5 w-5 text-memento-accent" />
</Button>
)
}
return (
<aside className={cn(
"fixed bottom-20 right-6 border border-border/40 bg-card flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
"fixed bottom-20 right-6 border border-border/40 bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
)}>
{/* Header */}
@@ -202,7 +207,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('chat')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'chat' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
activeTab === 'chat' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Bot className="h-4 w-4" /> {t('ai.chatTab')}
@@ -211,7 +216,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('insights')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'insights' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
activeTab === 'insights' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
@@ -220,7 +225,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={() => setActiveTab('history')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'history' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
activeTab === 'history' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<History className="h-4 w-4" /> {t('ai.historyTab')}
@@ -234,10 +239,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{/* AI Welcome Message */}
{messages.length === 0 && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<div className="bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<p className="text-sm text-foreground leading-relaxed">
{t('ai.welcomeMsg')}
</p>
@@ -255,16 +260,16 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
msg.role === 'user'
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
: 'bg-primary/10 text-primary border-primary/20',
? 'bg-muted border-border text-muted-foreground'
: 'bg-memento-blue/10 text-memento-blue border-memento-blue/20',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
</div>
<div className={cn(
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
? 'bg-memento-blue text-white rounded-tr-sm'
: 'bg-background border border-border/50 rounded-tl-sm text-foreground',
)}>
{msg.role === 'assistant'
? <MarkdownContent content={text} />
@@ -276,10 +281,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<div className="bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</div>
@@ -290,7 +295,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{activeTab === 'insights' && (
<div className="h-full">
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> {t('ai.summaryLast5')}</h3>
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-memento-accent" /> {t('ai.summaryLast5')}</h3>
{insightsLoading ? (
<div className="flex flex-col items-center justify-center py-10 opacity-60">
<Loader2 className="h-8 w-8 animate-spin mb-4 text-muted-foreground" />
@@ -318,7 +323,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
history.map(conv => (
<button
key={conv.id}
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-primary/30 transition-all flex flex-col gap-1"
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-memento-blue/30 transition-all flex flex-col gap-1"
onClick={() => {
setConversationId(conv.id)
setMessages(conv.messages.map((m: any) => ({
@@ -345,12 +350,12 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</div>
{/* Input Area & Tone Controls (Only in Chat tab) */}
<div className={cn("p-4 border-t border-border/40 bg-muted/10 shrink-0", activeTab !== 'chat' && "hidden")}>
<div className={cn("p-4 border-t border-border/40 bg-background shrink-0", activeTab !== 'chat' && "hidden")}>
{/* Context Scope */}
<div className="mb-3">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
<Select value={chatScope} onValueChange={setChatScope}>
<SelectTrigger className="h-8 text-xs bg-card border-border/60">
<SelectTrigger className="h-8 text-xs bg-background border-border/60">
<SelectValue placeholder={t('ai.selectNotebook')} />
</SelectTrigger>
<SelectContent>
@@ -387,8 +392,8 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
className={cn(
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
isSelected
? "border-primary bg-primary/10 text-primary shadow-sm"
: "border-border/60 bg-card text-muted-foreground hover:bg-muted hover:border-border"
? "border-memento-blue bg-memento-blue/10 text-memento-blue shadow-sm"
: "border-border/60 bg-background text-muted-foreground hover:bg-muted hover:border-border"
)}
>
<Icon className="h-3 w-3" />
@@ -400,7 +405,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</div>
{/* Text Input */}
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-all shadow-sm">
<div className="relative bg-background border border-border/60 rounded-xl p-1 focus-within:border-memento-blue focus-within:ring-1 focus-within:ring-memento-blue/20 transition-all shadow-sm">
<textarea
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
placeholder={t('ai.chatPlaceholder')}
@@ -435,7 +440,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
) : (
<Button
size="icon"
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
className="h-8 w-8 rounded-lg bg-memento-blue text-white shadow-sm hover:shadow-md transition-all"
onClick={handleSend}
disabled={!input.trim()}
>

View File

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

View File

@@ -137,17 +137,22 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
}
}
await sendMessage(
{ text: content },
{
body: {
conversationId: convId,
notebookId: notebookId || selectedNotebook || undefined,
language,
webSearch: webSearchEnabled,
},
}
)
try {
await sendMessage(
{ text: content },
{
body: {
conversationId: convId,
notebookId: notebookId || selectedNotebook || undefined,
language,
webSearch: webSearchEnabled,
},
}
)
} catch (error) {
console.error('Chat send error:', error)
toast.error(t('chat.assistantError') || 'Failed to send message')
}
}
const handleNewChat = () => {
@@ -183,7 +188,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
}, [displayMessages])
return (
<div className="flex-1 flex overflow-hidden bg-white dark:bg-[#1a1c22]">
<div className="flex-1 flex overflow-hidden bg-background">
<ChatSidebar
conversations={conversations}
currentId={currentId}
@@ -197,7 +202,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
</div>
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-white dark:from-[#1a1c22] via-white/90 dark:via-[#1a1c22]/90 to-transparent pt-6 pb-4">
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
<div className="w-full max-w-4xl px-4">
<ChatInput
onSend={handleSendMessage}

View File

@@ -64,7 +64,7 @@ export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNoteboo
return (
<div className="w-full relative">
<div className="relative flex flex-col bg-slate-50 dark:bg-[#202228] rounded-[24px] border border-slate-200/60 dark:border-white/10 shadow-sm focus-within:shadow-md focus-within:border-slate-300 dark:focus-within:border-white/20 transition-all duration-300 overflow-hidden">
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
{/* Input Area */}
<Textarea
@@ -84,7 +84,7 @@ export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNoteboo
value={selectedNotebook || 'global'}
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
>
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-slate-50 dark:hover:bg-[#252830] transition-colors">
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder={t('chat.allNotebooks')} />
</SelectTrigger>
@@ -113,9 +113,9 @@ export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNoteboo
onClick={onToggleWebSearch}
className={cn(
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
webSearchEnabled
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
: "bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-[#252830]"
webSearchEnabled
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
)}
>
<Globe className="h-3.5 w-3.5" />

View File

@@ -52,7 +52,7 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
)}
>
{msg.role === 'user' ? (
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-[#f4f4f5] dark:bg-[#2a2d36] text-slate-800 dark:text-slate-100 rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-slate-200/50 dark:border-white/5">
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>

View File

@@ -46,7 +46,7 @@ export function ChatSidebar({
}
return (
<div className="w-64 border-r flex flex-col h-full bg-white dark:bg-[#1e2128]">
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
<div className="p-4 border-bottom">
<Button
onClick={onNew}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +1,10 @@
'use client'
import { useState } from 'react'
import { Plus, X, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { motion, AnimatePresence } from 'motion/react'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
const NOTEBOOK_ICONS = [
{ icon: Folder, name: 'folder' },
{ icon: Briefcase, name: 'briefcase' },
{ icon: FileText, name: 'document' },
{ icon: Zap, name: 'lightning' },
{ icon: BarChart3, name: 'chart' },
{ icon: Globe, name: 'globe' },
{ icon: Sparkles, name: 'sparkle' },
{ icon: Book, name: 'book' },
{ icon: Heart, name: 'heart' },
{ icon: Crown, name: 'crown' },
{ icon: Music, name: 'music' },
{ icon: Building2, name: 'building' },
]
const NOTEBOOK_COLORS = [
{ name: 'Slate', value: '#64748B', bg: 'bg-slate-500' },
{ name: 'Purple', value: '#8B5CF6', bg: 'bg-purple-500' },
{ name: 'Red', value: '#EF4444', bg: 'bg-red-500' },
{ name: 'Orange', value: '#F59E0B', bg: 'bg-orange-500' },
{ name: 'Green', value: '#10B981', bg: 'bg-green-500' },
{ name: 'Teal', value: '#14B8A6', bg: 'bg-teal-500' },
{ name: 'Gray', value: '#6B7280', bg: 'bg-gray-500' },
]
interface CreateNotebookDialogProps {
open?: boolean
onOpenChange?: (open: boolean) => void
@@ -49,166 +14,93 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
const { t } = useLanguage()
const { createNotebookOptimistic } = useNotebooks()
const [name, setName] = useState('')
const [selectedIcon, setSelectedIcon] = useState('folder')
const [selectedColor, setSelectedColor] = useState('#3B82F6')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setIsSubmitting(true)
try {
await createNotebookOptimistic({
name: name.trim(),
icon: selectedIcon,
color: selectedColor,
})
// Close dialog — context already updated sidebar state
await createNotebookOptimistic({ name: name.trim(), icon: 'folder', color: '#64748B' })
setName('')
onOpenChange?.(false)
} catch (error) {
console.error('Failed to create notebook:', error)
} catch (err) {
console.error('Failed to create notebook:', err)
} finally {
setIsSubmitting(false)
}
}
const handleReset = () => {
const handleClose = () => {
setName('')
setSelectedIcon('folder')
setSelectedColor('#3B82F6')
onOpenChange?.(false)
}
const SelectedIconComponent = NOTEBOOK_ICONS.find(i => i.name === selectedIcon)?.icon || Folder
return (
<Dialog open={open} onOpenChange={(val) => {
onOpenChange?.(val)
if (!val) handleReset()
}}>
<DialogContent className="sm:max-w-[500px] p-0">
<button
onClick={() => onOpenChange?.(false)}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors z-10"
>
<X className="h-5 w-5" />
</button>
<DialogHeader className="px-8 pt-8 pb-4">
<DialogTitle className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{t('notebook.createNew')}
</DialogTitle>
<DialogDescription className="text-sm text-gray-500 dark:text-gray-400">
{t('notebook.createDescription')}
</DialogDescription>
</DialogHeader>
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={handleClose}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
/>
<form onSubmit={handleSubmit} className="px-8 pb-8">
<div className="space-y-6">
{/* Notebook Name */}
<div>
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2 block">
{t('notebook.name')}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('notebook.namePlaceholder')}
className="w-full"
autoFocus
/>
</div>
{/* Card */}
<motion.div
initial={{ opacity: 0, scale: 0.92, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 20 }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="relative w-full max-w-md bg-[#F2F0E9] dark:bg-zinc-900 border border-black/10 dark:border-white/10 shadow-2xl rounded-2xl p-8"
>
<h3 className="text-2xl font-memento-serif font-medium text-foreground mb-2">
{t('notebook.createNew') || 'Nouveau carnet'}
</h3>
<p className="text-sm text-muted-foreground mb-6 font-light">
{t('notebook.createDescription') || 'Donnez un nom à votre nouveau carnet.'}
</p>
{/* Icon Selection */}
<div>
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 block">
{t('notebook.selectIcon')}
</label>
<div className="grid grid-cols-6 gap-3">
{NOTEBOOK_ICONS.map((item) => {
const IconComponent = item.icon
const isSelected = selectedIcon === item.name
return (
<button
key={item.name}
type="button"
onClick={() => setSelectedIcon(item.name)}
className={cn(
"h-14 w-full rounded-xl border-2 flex items-center justify-center transition-all duration-200",
isSelected
? 'border-indigo-600 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600'
: 'border-gray-200 dark:border-gray-700 text-gray-400 hover:border-gray-300 dark:hover:border-gray-600'
)}
>
<IconComponent className="h-5 w-5" />
</button>
)
})}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-foreground mb-2">
{t('notebook.name') || 'Nom du carnet'}
</label>
<input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('notebook.namePlaceholder') || 'Ex. : Projets, Recherche…'}
className="w-full bg-white dark:bg-zinc-800 border border-black/12 dark:border-white/15 rounded-lg px-4 py-3 outline-none focus:border-foreground/40 dark:focus:border-white/40 transition-colors font-memento-serif italic text-lg text-foreground placeholder:text-muted-foreground/40"
/>
</div>
</div>
{/* Color Selection */}
<div>
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 block">
{t('notebook.selectColor')}
</label>
<div className="flex items-center gap-3">
{NOTEBOOK_COLORS.map((color) => {
const isSelected = selectedColor === color.value
return (
<button
key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)}
className={cn(
"h-10 w-10 rounded-full border-2 transition-all duration-200",
isSelected
? 'border-white scale-110 shadow-lg'
: 'border-gray-200 dark:border-gray-700 hover:scale-105'
)}
style={{ backgroundColor: color.value }}
title={color.name}
/>
)
})}
</div>
</div>
{/* Preview */}
{name.trim() && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white shadow-md"
style={{ backgroundColor: selectedColor }}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={handleClose}
className="flex-1 py-3 border border-black/12 dark:border-white/12 rounded-xl text-sm font-medium text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<SelectedIconComponent className="h-5 w-5" />
</div>
<span className="font-semibold text-gray-900 dark:text-white">{name.trim()}</span>
{t('notebook.cancel') || 'Annuler'}
</button>
<button
type="submit"
disabled={!name.trim() || isSubmitting}
className="flex-1 py-3 bg-foreground text-background rounded-xl text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSubmitting
? (t('notebook.creating') || 'Création…')
: (t('notebook.create') || 'Créer')}
</button>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange?.(false)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
{t('notebook.cancel')}
</Button>
<Button
type="submit"
disabled={!name.trim() || isSubmitting}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6"
>
{isSubmitting ? t('notebook.creating') : t('notebook.create')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
)
}

View File

@@ -9,7 +9,11 @@ import { useEffect } from 'react'
export function DirectionInitializer() {
useEffect(() => {
try {
const lang = localStorage.getItem('user-language')
let lang = localStorage.getItem('user-language')
if (!lang) {
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('user-language='))
if (c) lang = c.split('=')[1]
}
if (lang === 'fa' || lang === 'ar') {
document.documentElement.dir = 'rtl'
document.documentElement.lang = lang

View File

@@ -11,7 +11,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles, FileText } from 'lucide-react'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
import { useReminderCheck } from '@/hooks/use-reminder-check'
@@ -38,12 +38,17 @@ const AutoLabelSuggestionDialog = dynamic(
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
{ ssr: false }
)
const NotebookSummaryDialog = dynamic(
() => import('@/components/notebook-summary-dialog').then(m => ({ default: m.NotebookSummaryDialog })),
{ ssr: false }
)
type InitialSettings = {
showRecentNotes: boolean
notesViewMode: 'masonry' | 'tabs' | 'list'
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
aiAssistantEnabled: boolean
}
interface HomeClientProps {
@@ -71,6 +76,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [isCreating, startCreating] = useTransition()
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
const [showInlineSearch, setShowInlineSearch] = useState(false)
const [inlineSearchQuery, setInlineSearchQuery] = useState('')
const inlineSearchRef = useRef<HTMLInputElement>(null)
const notesRef = useRef(notes)
notesRef.current = notes
const { refreshKey, triggerRefresh } = useNoteRefresh()
@@ -80,6 +88,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
useEffect(() => {
if (shouldSuggestLabels && suggestNotebookId) {
@@ -147,10 +156,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
}, [searchParams, labels, router])
const handleOpenNote = (noteId: string) => {
const note = notes.find(n => n.id === noteId)
if (note) setEditingNote({ note, readOnly: false })
}
// Always fetch fresh from server — avoids stale state after a save regardless of
// whether the notes list has re-fetched yet.
const handleOpenNoteFresh = useCallback(async (noteId: string, readOnly = false) => {
const note = await getNoteById(noteId)
if (note) setEditingNote({ note, readOnly })
}, [])
const handleAddNote = () => {
startCreating(async () => {
@@ -205,13 +216,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
let cancelled = false
const run = async () => {
const existing = notesRef.current.find(n => n.id === openNoteId)
const note = existing ?? (await getNoteById(openNoteId))
// Always fetch fresh data from DB to avoid showing stale content after a save.
// notesRef.current can be stale if the notes list hasn't re-fetched yet when the
// user closes and re-opens the note quickly after saving.
const note = await getNoteById(openNoteId)
if (cancelled || !note) return
setEditingNote(prev => {
if (prev?.note.id === note.id && prev.readOnly === false) return prev
return { note, readOnly: false }
})
setEditingNote({ note, readOnly: false })
}
run()
return () => {
@@ -258,8 +268,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
? await searchNotes(search, semanticMode, notebook || undefined)
: await getAllNotes(false, notebook || undefined)
if (!notebook && !search) {
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
const sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
if (sharedOnly) {
allNotes = allNotes.filter((note: any) => note._isShared)
} else if (remindersOnly) {
allNotes = allNotes.filter((note: any) => note.reminder !== null)
} else if (!notebook && !search) {
allNotes = allNotes.filter((note: any) => !note.notebookId && !note._isShared)
}
if (labelFilter.length > 0) {
@@ -291,10 +308,16 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
return () => { cancelled.value = true }
} else {
let filtered = initialNotes
const sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
if (notebook) {
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
} else if (sharedOnly) {
filtered = initialNotes.filter((n: any) => n._isShared)
} else if (remindersOnly) {
filtered = initialNotes.filter((n: any) => n.reminder !== null)
} else {
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
filtered = initialNotes.filter((n: any) => !n.notebookId && !n._isShared)
}
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
@@ -302,7 +325,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
})
setPinnedNotes(filtered.filter(n => n.isPinned))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey])
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
@@ -347,6 +370,16 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
refreshNotes(searchParams.get('notebook') || null)
}, [refreshNotes, router, searchParams])
// Called by NoteEditor when a save succeeds — update local state immediately
// so the user sees fresh data if they reopen the note before getAllNotes() completes
const handleNoteSaved = useCallback((savedNote: Note) => {
setNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n))
setPinnedNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n))
setEditingNote(prev => prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev)
// Refresh sidebar note titles so the new title appears immediately
refreshNotes(savedNote.notebookId || null)
}, [refreshNotes])
return (
<div
className={cn(
@@ -359,67 +392,135 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleEditorClose}
onNoteSaved={handleNoteSaved}
fullPage
/>
) : (
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper flex flex-col">
<div className={cn(
'px-12 pt-12 pb-8 flex flex-col gap-6',
isEditorialMode ? 'sticky top-0 bg-background/90 backdrop-blur-md z-30 border-b border-foreground/5' : ''
isEditorialMode ? 'sticky top-0 bg-memento-paper/90 backdrop-blur-md z-30' : ''
)}>
<div className="flex justify-between items-start">
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
{currentNotebook ? currentNotebook.name : t('notes.title')}
{currentNotebook
? currentNotebook.name
: searchParams.get('shared') === '1'
? (t('sidebar.sharedWithMe') || 'Partagées avec moi')
: searchParams.get('reminders') === '1'
? (t('sidebar.reminders') || 'Rappels')
: t('notes.title')}
</h1>
</div>
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
<button
onClick={handleAddNote}
disabled={isCreating}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Plus size={16} />
<span>{t('notes.newNote') || 'Add Note'}</span>
</button>
{/* Sort order */}
<div className="relative">
<div className="flex items-center gap-6">
<button
onClick={() => setShowSortMenu(s => !s)}
className="flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground font-medium transition-opacity"
title={t('sidebar.sortOrder') || 'Sort order'}
onClick={handleAddNote}
disabled={isCreating}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<ArrowUpDown size={14} />
<span className="hidden sm:inline text-[11px] uppercase tracking-wider font-bold">
{sortOrder === 'newest' ? 'Plus récentes' : sortOrder === 'oldest' ? 'Plus anciennes' : 'A → Z'}
</span>
<Plus size={16} />
<span>{t('notes.newNote') || 'Add Note'}</span>
</button>
{/* Inline search — toggles an input within the toolbar */}
{showInlineSearch ? (
<div className="flex items-center gap-2">
<Search size={14} className="text-muted-foreground shrink-0" />
<input
ref={inlineSearchRef}
autoFocus
type="text"
value={inlineSearchQuery}
onChange={e => {
const q = e.target.value
setInlineSearchQuery(q)
const params = new URLSearchParams(searchParams.toString())
if (q.trim()) {
params.set('search', q)
} else {
params.delete('search')
}
router.push(`/?${params.toString()}`)
}}
onBlur={() => {
if (!inlineSearchQuery) {
setShowInlineSearch(false)
}
}}
onKeyDown={e => {
if (e.key === 'Escape') {
setShowInlineSearch(false)
setInlineSearchQuery('')
const params = new URLSearchParams(searchParams.toString())
params.delete('search')
router.push(`/?${params.toString()}`)
}
}}
placeholder={t('search.placeholder') || 'Rechercher...'}
className="w-48 bg-transparent border-b border-foreground/20 focus:border-foreground outline-none text-[13px] text-foreground placeholder:text-muted-foreground/50 py-0.5 transition-colors"
/>
{inlineSearchQuery && (
<button
onClick={() => {
setShowInlineSearch(false)
setInlineSearchQuery('')
const params = new URLSearchParams(searchParams.toString())
params.delete('search')
router.push(`/?${params.toString()}`)
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<span className="text-[11px]">×</span>
</button>
)}
</div>
) : (
<button
onClick={() => {
setShowInlineSearch(true)
setTimeout(() => inlineSearchRef.current?.focus(), 50)
}}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Search size={16} />
<span>{t('notes.search') || 'Search'}</span>
</button>
)}
{!searchParams.get('notebook') && searchParams.get('shared') !== '1' && (
<button
onClick={() => setBatchOrganizationOpen(true)}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Sparkles size={16} />
<span>{t('notes.reorganize') || 'Réorganiser les notes'}</span>
</button>
)}
</div>
<div className="flex items-center gap-6">
{searchParams.get('notebook') && (
<button
onClick={() => setSummaryDialogOpen(true)}
disabled={!initialSettings.aiAssistantEnabled}
className={cn(
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
initialSettings.aiAssistantEnabled ? "text-foreground hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
)}
title={initialSettings.aiAssistantEnabled ? t('notebook.summary') : "Activez l'Assistant IA dans les paramètres pour résumer"}
>
<FileText size={16} />
<span>{t('notebook.summary') || 'Summarize'}</span>
</button>
)}
<button
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : 'newest')}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<ArrowUpDown size={16} />
<span>{sortLabels[sortOrder]}</span>
</button>
<AnimatePresence>
{showSortMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -4 }}
className="absolute right-0 top-full mt-2 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
>
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
<button
key={order}
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
className={cn(
'w-full text-left px-4 py-2 text-[12px] transition-colors',
sortOrder === order
? 'font-bold text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{sortLabels[order]}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
@@ -442,13 +543,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
) : (
<div className="max-w-3xl space-y-16">
{sortedPinnedNotes.length > 0 && (
<div className="mb-8">
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-6 px-2">
<div className="mb-6">
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
{t('notes.pinned')}
</h2>
<NotesEditorialView
notes={sortedPinnedNotes}
onOpen={(note: Note, readOnly?: boolean) => setEditingNote({ note, readOnly: readOnly ?? false })}
onOpen={(note: Note, readOnly?: boolean) => handleOpenNoteFresh(note.id, readOnly ?? false)}
notebookName={currentNotebook?.name}
onOpenHistory={handleOpenHistory}
/>
@@ -458,7 +559,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
<NotesEditorialView
notes={sortedNotes.filter((note) => !note.isPinned)}
onOpen={(note, readOnly) => setEditingNote({ note, readOnly })}
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
notebookName={currentNotebook?.name}
onOpenHistory={handleOpenHistory}
/>
@@ -490,7 +591,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
</div>
)}
<MemoryEchoNotification onOpenNote={handleOpenNote} />
<MemoryEchoNotification onOpenNote={(noteId) => handleOpenNoteFresh(noteId)} />
{notebookSuggestion && (
<NotebookSuggestionToast
@@ -528,6 +629,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onEnableHistory={async () => { if (historyNote) await handleEnableHistory(historyNote.id) }}
onRestored={handleHistoryRestored}
/>
{searchParams.get('notebook') && (
<NotebookSummaryDialog
open={summaryDialogOpen}
onOpenChange={setSummaryDialogOpen}
notebookId={searchParams.get('notebook')}
notebookName={currentNotebook?.name}
/>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -120,7 +120,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
{/* Section 1: What is MCP */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500 shrink-0">
<div className="w-10 h-10 rounded-full bg-zinc-500/10 flex items-center justify-center text-zinc-500 shrink-0">
<Info className="h-5 w-5" />
</div>
<div>
@@ -135,7 +135,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
href="https://modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline mt-4"
className="inline-flex items-center gap-1 text-sm text-zinc-600 hover:underline mt-4"
>
{t('mcpSettings.whatIsMcp.learnMore')}
<ExternalLink className="h-3 w-3" />
@@ -156,7 +156,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
<div className="p-6">
<div className="space-y-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('mcpSettings.serverStatus.mode')}</span>
<span className="text-zinc-600">{t('mcpSettings.serverStatus.mode')}</span>
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
</div>
{serverStatus.mode === 'sse' && serverStatus.url && (

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,12 @@ interface NoteEditorProps {
readOnly?: boolean
onClose: () => void
fullPage?: boolean
onNoteSaved?: (savedNote: Note) => void
}
export function NoteEditor({ note, readOnly, onClose, fullPage = false }: NoteEditorProps) {
export function NoteEditor({ note, readOnly, onClose, fullPage = false, onNoteSaved }: NoteEditorProps) {
return (
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage}>
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage} onNoteSaved={onNoteSaved}>
{fullPage ? (
<NoteEditorFullPage onClose={onClose} />
) : (

View File

@@ -25,10 +25,11 @@ interface NoteEditorProviderProps {
note: Note
readOnly?: boolean
fullPage?: boolean
onNoteSaved?: (savedNote: Note) => void
children: ReactNode
}
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, children }: NoteEditorProviderProps) {
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, onNoteSaved, children }: NoteEditorProviderProps) {
const { data: session } = useSession()
const { t } = useLanguage()
const queryClient = useQueryClient()
@@ -133,6 +134,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
// Wire autoTitleSuggestions into state so NoteTitleBlock can display them
useEffect(() => {
if (autoTitleSuggestions.length > 0) {
setTitleSuggestions(autoTitleSuggestions)
}
}, [autoTitleSuggestions])
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
@@ -169,7 +177,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
for (const file of Array.from(files)) {
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
setImages(prev => prev.includes(url) ? prev : [...prev, url])
} catch (error) {
console.error('Upload error:', error)
toast.error(t('notes.uploadFailed', { filename: file.name }))
@@ -190,7 +198,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
if (!file) continue
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
setImages(prev => prev.includes(url) ? prev : [...prev, url])
} catch {
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
}
@@ -293,7 +301,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
const data = await response.json()
setTitleSuggestions(data.suggestions || [])
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
// Auto-apply first title for dialog mode (fullPage shows suggestions UI instead)
if (!fullPage && data.suggestions?.[0]?.title) {
setTitle(data.suggestions[0].title)
setDismissedTitleSuggestions(true)
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
} else if (data.suggestions?.length) {
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
}
} catch (error: any) {
console.error('Error generating titles:', error)
toast.error(error.message || t('ai.titleGenerationFailed'))
@@ -521,9 +536,11 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
}
const handleSave = async () => {
console.log('[SAVE] handleSave called, note.id:', note.id)
setIsSaving(true)
try {
await updateNote(note.id, {
console.log('[SAVE] Calling updateNote...')
const result = await updateNote(note.id, {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
@@ -536,20 +553,25 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
type: noteType,
size,
})
console.log('[SAVE] updateNote succeeded, result title:', result?.title, 'result content len:', result?.content?.length)
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
// Keep local note ref in sync with saved data so useEffect detects changes correctly
prevNoteRef.current = { ...prevNoteRef.current, ...result }
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
// Notify parent with the freshly-saved note so it can update its local state immediately
onNoteSaved?.(result)
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
// Note: onClose is handled by the composition component
toast.success(t('notes.saved') || 'Note sauvegardée !')
} catch (error) {
console.error('Failed to save note:', error)
console.error('[SAVE] updateNote failed:', error)
toast.error(t('notes.saveFailed') || 'Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
}
@@ -633,9 +655,11 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
// Save in place (fullPage) — without closing
const handleSaveInPlace = async () => {
console.log('[SAVE] handleSaveInPlace called, note.id:', note.id, 'content length:', content.length, 'title:', title.substring(0, 50))
setIsSaving(true)
try {
await updateNote(note.id, {
console.log('[SAVE] Calling updateNote with note.id:', note.id, '| content len:', content.length, '| title:', title.substring(0, 30))
const updatePayload = {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
@@ -647,11 +671,20 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
}
console.log('[SAVE] payload.content:', JSON.stringify(updatePayload.content)?.substring(0, 100))
const result = await updateNote(note.id, updatePayload)
console.log('[SAVE] updateNote succeeded, result.id:', result?.id, '| result.content len:', result?.content?.length, '| result.title:', result?.title)
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
// Sync local note reference with saved data so prop/state stay aligned after save
prevNoteRef.current = { ...prevNoteRef.current, ...result }
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
// Notify parent with the freshly-saved note so it can update its local state immediately
onNoteSaved?.(result)
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
@@ -659,7 +692,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
setIsDirty(false)
toast.success('Note sauvegardée !')
} catch (error) {
console.error('Failed to save note:', error)
console.error('[SAVE] updateNote failed:', error)
toast.error('Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
@@ -725,8 +758,11 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
])
// Build actions object
const actions: NoteEditorActions = useMemo(() => ({
// Build actions object — NOT memoized to avoid stale closures.
// handleSave / handleSaveInPlace close over content, title, labels, etc.
// which change on every keystroke. Memoizing with [] would freeze those
// values at the first render, causing the wrong content to be saved.
const actions: NoteEditorActions = {
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions,
setContent: (c) => { setContent(c); setIsDirty(true) },
@@ -775,9 +811,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
setInfoOpen,
setIsProcessingAI,
setIsGeneratingTitles,
setIsAnalyzingSuggestions: (a) => { /* handled by useAutoTagging */ },
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot,
}), [])
}
const value: NoteEditorContextValue = useMemo(() => ({
note,

View File

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

View File

@@ -1,5 +1,6 @@
'use client'
import { useState, useRef } from 'react'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
@@ -17,11 +18,12 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, Info, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2
} from 'lucide-react'
import { NoteShareDialog } from './note-share-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
import { useLanguage } from '@/lib/i18n'
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
import { cn } from '@/lib/utils'
@@ -36,10 +38,60 @@ interface NoteEditorToolbarProps {
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const { refreshNotes } = useRefresh()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
// Snapshot for undo — stored in a ref so the toast callback isn't a stale closure
const undoSnapshotRef = useRef<{ content: string; noteType: string } | null>(null)
const handleConvertToRichtext = async () => {
if (isConverting || !state.content.trim()) return
setIsConverting(true)
// Capture snapshot BEFORE converting
const snapshot = { content: state.content, noteType: state.noteType }
undoSnapshotRef.current = snapshot
try {
let html: string
if (state.noteType === 'markdown') {
// Proper markdown → HTML via marked (no AI needed)
const { marked } = await import('marked')
html = await marked(state.content, { async: false }) as string
} else {
// Plain text → wrap paragraphs in <p> tags
html = state.content
.split(/\n{2,}/)
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
.join('')
}
actions.setContent(html)
actions.setNoteType('richtext')
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
duration: 8000,
action: {
label: t('notes.undo') || '↩ Undo',
onClick: () => {
const snap = undoSnapshotRef.current
if (!snap) return
actions.setContent(snap.content)
actions.setNoteType(snap.noteType as any)
undoSnapshotRef.current = null
toast.info(t('ai.undoApplied') || 'Conversion undone')
},
},
})
} catch {
toast.error(t('notes.transformFailed') || 'Conversion failed')
} finally {
setIsConverting(false)
}
}
if (mode === 'fullPage') {
return (
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
@@ -70,71 +122,86 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
compact
/>
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
{/* Preview toggle — icon only */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
<button
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
'p-1.5 rounded-full border transition-all duration-300',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Eye size={16} />
<span>{state.showMarkdownPreview ? 'Éditer' : 'Aperçu'}</span>
</button>
)}
{/* AI — rounded-full, exact prototype style */}
{/* Convert to Rich Text — icon only */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
<button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
onClick={handleConvertToRichtext}
disabled={isConverting}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
isConverting && 'opacity-50 cursor-not-allowed'
)}
>
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
</button>
)}
{/* AI — icon only */}
<button
title="AI Assistant"
aria-label="Ouvrir l'assistant IA"
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
'p-1.5 rounded-full border transition-all duration-300',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
<span>AI Assistant</span>
</button>
{/* Info — rounded-full */}
<button
aria-label="Informations du document"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Info size={16} />
<span>Document Info</span>
</button>
{/* Save button */}
{/* Save — icon only */}
{!readOnly && (
<button
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
aria-label={state.isDirty ? 'Enregistrer la note' : 'Aucune modification à enregistrer'}
onClick={actions.handleSaveInPlace}
disabled={state.isSaving || !state.isDirty}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
'p-1.5 rounded-full border transition-all duration-300',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
<span>{state.isSaving ? 'Saving…' : 'Save'}</span>
</button>
)}
{/* Share button */}
{!readOnly && (
<button
title="Partager la note"
aria-label="Partager la note"
onClick={() => setShareOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Share2 size={16} />
</button>
)}
{/* Three-dot options menu */}
{!readOnly && (
<DropdownMenu>
@@ -148,7 +215,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
onClick={async () => {
try {
await deleteNote(note.id)
triggerRefresh()
refreshNotes(note.notebookId)
toast.success('Note supprimée.')
onClose()
} catch { toast.error('Impossible de supprimer.') }
@@ -161,6 +228,29 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Share Dialog portal */}
{shareOpen && (
<NoteShareDialog
noteId={note.id}
noteTitle={state.title}
onClose={() => setShareOpen(false)}
/>
)}
{/* Info panel toggle — rightmost, icon only */}
<button
aria-label="Informations du document"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<PanelRight size={16} />
</button>
</div>
</div>
)
@@ -277,7 +367,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
try {
await leaveSharedNote(note.id)
toast.success(t('notes.leftShare') || 'Share removed')
triggerRefresh()
refreshNotes(note.notebookId)
onClose()
} catch {
toast.error(t('general.error'))

View File

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

View File

@@ -12,9 +12,18 @@ export function NoteTitleBlock() {
const { t } = useLanguage()
if (fullPage) {
// Adaptive font size: short = big editorial, long = smaller but still premium
const titleLen = (state.title || '').length
const titleSizeClass =
titleLen === 0 ? 'text-5xl md:text-6xl' :
titleLen < 40 ? 'text-5xl md:text-6xl' :
titleLen < 70 ? 'text-4xl md:text-5xl' :
titleLen < 100 ? 'text-3xl md:text-4xl' :
'text-2xl md:text-3xl'
return (
<div className="space-y-4">
{/* Title — auto-resizing textarea to prevent overflow */}
{/* Title — auto-resizing textarea, adaptive size */}
<div className="group relative">
<textarea
dir="auto"
@@ -29,12 +38,22 @@ export function NoteTitleBlock() {
}}
disabled={readOnly}
className={cn(
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight',
'w-full font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground',
'leading-[1.15] tracking-tight',
'placeholder:text-foreground/20 resize-none overflow-hidden',
titleSizeClass,
!readOnly && 'pr-12'
)}
style={{ height: 'auto' }}
ref={(el) => {
// Force correct initial height on mount
if (el) {
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
}}
/>
{/* AI title generation — always visible on hover */}
{/* AI title generation — visible on hover */}
{!readOnly && (
<button
type="button"
@@ -64,7 +83,9 @@ export function NoteTitleBlock() {
} else {
toast.error('Erreur lors de la génération du titre.')
}
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
} catch (e) {
toast.error('Erreur réseau.')
} finally { actions.setIsProcessingAI(false) }
}}
disabled={state.isProcessingAI}
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
@@ -87,6 +108,7 @@ export function NoteTitleBlock() {
)
}
// Dialog mode title block
return (
<div className="relative">

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,7 @@ export function NoteTypeSelector({ value, onChange, compact = false, className }
<Button
variant="ghost"
size={compact ? 'icon' : 'sm'}
aria-label={t('notes.noteType') || 'Changer le type de note'}
className={cn(
'gap-1.5 shrink-0',
compact ? 'h-8 w-8' : 'h-8 px-2 text-xs font-medium',
@@ -69,6 +70,7 @@ export function NoteTypeSelector({ value, onChange, compact = false, className }
<DropdownMenuItem
key={type}
onClick={() => onChange(type)}
aria-label={t(TYPE_I18N_KEYS[type])}
className={cn('gap-2 cursor-pointer', isActive && 'bg-accent')}
>
<div className="flex items-center gap-2 flex-1">

View File

@@ -6,7 +6,7 @@ import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/li
import { useLanguage } from '@/lib/i18n'
import { useRefresh } from '@/lib/use-refresh'
import { motion, AnimatePresence } from 'motion/react'
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2 } from 'lucide-react'
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen, StickyNote } from 'lucide-react'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
@@ -16,8 +16,13 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@/components/ui/dropdown-menu'
import { deleteNote, toggleArchive, togglePin } from '@/app/actions/notes'
import { deleteNote, toggleArchive, togglePin, updateNote } from '@/app/actions/notes'
import { ReminderDialog } from '@/components/reminder-dialog'
import { useNotebooks } from '@/context/notebooks-context'
import { toast } from 'sonner'
type NotesEditorialViewProps = {
@@ -43,7 +48,10 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
}) {
const { t } = useLanguage()
const { refreshNotes } = useRefresh()
const { notebooks } = useNotebooks()
const [, startTransition] = useTransition()
const [showReminder, setShowReminder] = useState(false)
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
@@ -83,6 +91,18 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
})
}
const handleMoveToNotebook = (notebookId: string | null) => {
startTransition(async () => {
try {
await updateNote(note.id, { notebookId })
refreshNotes(note?.notebookId)
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
} catch {
toast.error(t('general.error'))
}
})
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
@@ -109,6 +129,52 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
{t('notes.history') || 'Historique'}
</DropdownMenuItem>
)}
{/* Rappel */}
<DropdownMenuItem onClick={e => { e.stopPropagation(); setShowReminder(true) }}>
<Bell className="h-4 w-4 mr-2" />
{note.reminder ? (t('reminder.changeReminder') || 'Modifier le rappel') : (t('reminder.setReminder') || 'Définir un rappel')}
</DropdownMenuItem>
<ReminderDialog
open={showReminder}
onOpenChange={setShowReminder}
currentReminder={note.reminder ? new Date(note.reminder) : null}
onSave={(date) => {
startTransition(async () => {
await updateNote(note.id, { reminder: date })
refreshNotes(note?.notebookId)
setShowReminder(false)
})
}}
onRemove={() => {
startTransition(async () => {
await updateNote(note.id, { reminder: null })
refreshNotes(note?.notebookId)
setShowReminder(false)
})
}}
/>
{/* Déplacer vers notebook */}
<DropdownMenuSub open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
<DropdownMenuSubTrigger onClick={e => e.stopPropagation()}>
<FolderOpen className="h-4 w-4 mr-2" />
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers notebook'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent alignOffset={0} className="w-48">
<DropdownMenuItem onClick={e => { e.stopPropagation(); handleMoveToNotebook(null) }}>
<StickyNote className="h-4 w-4 mr-2" />
{t('notebookSuggestion.generalNotes') || 'Notes générales'}
</DropdownMenuItem>
{notebooks.map((nb: any) => (
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
<FolderOpen className="h-4 w-4 mr-2" />
{nb.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400 focus:text-red-600">
<Trash2 className="h-4 w-4 mr-2" />
@@ -264,7 +330,7 @@ export function NotesEditorialView({
}, [session?.user?.id])
return (
<div className="mx-auto w-full max-w-3xl space-y-16">
<div className="mx-auto w-full max-w-3xl space-y-8">
<AnimatePresence>
{notes.map((note: Note, index: number) => {
const title = getNoteDisplayTitle(note, t('notes.untitled') || 'Untitled')
@@ -277,14 +343,19 @@ export function NotesEditorialView({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 * index, duration: 0.6 }}
className="space-y-4 group cursor-pointer relative border-b border-border/20 pb-16"
className="space-y-4 group cursor-pointer relative pb-8"
onClick={() => onOpen(note)}
>
{/* Date / breadcrumb + actions menu */}
<div className="flex items-center justify-between">
<div className="note-date-badge">
{notebookName ? `${notebookName}${dateStr}` : dateStr}
</div>
{/* Date / breadcrumb */}
<div className="note-date-badge">
{notebookName ? `${notebookName}${dateStr}` : dateStr}
</div>
{/* Actions menu — absolutely positioned at top-right */}
<div
className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={e => e.stopPropagation()}
>
<EditorialNoteMenu note={note} onOpen={onOpen} onOpenHistory={onOpenHistory} />
</div>

View File

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

View File

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

View File

@@ -34,16 +34,16 @@ export function SettingsNav({ className }: SettingsNavProps) {
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
return (
<nav className={cn('flex items-center gap-1', className)}>
<nav className={cn('flex items-center gap-6 border-b border-border/40', className)}>
{sections.map((section) => (
<Link
key={section.id}
href={section.href}
className={cn(
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
isActive(section.href)
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
? 'border-[#D4A373] text-[#1C1C1C]'
: 'border-transparent text-[#1C1C1C]/40 hover:text-[#1C1C1C]'
)}
>
{section.icon}

View File

@@ -20,10 +20,12 @@ import {
User,
LogOut,
Shield,
GripVertical,
Users,
Bell,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useNotebooksQuery } from '@/lib/query-hooks'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getAllNotes } from '@/app/actions/notes'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useNotebooks } from '@/context/notebooks-context'
@@ -40,6 +42,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { signOut } from 'next-auth/react'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
type NavigationView = 'notebooks' | 'agents'
type SortOrder = 'newest' | 'oldest' | 'alpha'
@@ -59,7 +62,7 @@ function NoteLink({
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={cn(
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg',
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg text-left',
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
)}
>
@@ -67,7 +70,7 @@ function NoteLink({
'w-1.5 h-1.5 rounded-full shrink-0',
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
)} />
<span className="truncate">{title}</span>
<span className="break-words line-clamp-2 leading-tight">{title}</span>
</motion.button>
)
}
@@ -79,51 +82,66 @@ function SidebarCarnetItem({
activeNoteId,
onCarnetClick,
onNoteClick,
isDragging,
dragHandleProps,
}: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean
/** Notes for this carnet — always passed (like architectural-grid ref); visibility toggled by isActive */
notes: { id: string; title: string }[]
activeNoteId: string | null
onCarnetClick: () => void
onNoteClick: (noteId: string, carnetId: string) => void
isDragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
}) {
const { t } = useLanguage()
return (
<div className="space-y-1">
<motion.button
whileHover={{ x: 4 }}
onClick={onCarnetClick}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
)}
>
<motion.div
animate={{ rotate: isActive ? 90 : 0 }}
className="text-muted-foreground"
<div className={cn('space-y-1 transition-opacity', isDragging && 'opacity-40')}>
<div className="relative group/carnet">
{/* Drag handle — visible on hover */}
<div
{...dragHandleProps}
className="absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover/carnet:opacity-100 transition-opacity z-10"
title="Déplacer"
>
<ChevronRight size={14} />
</motion.div>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
isActive
? 'bg-foreground text-background border-foreground'
: 'bg-white/60 text-foreground border-border'
)}>
{carnet.initial}
<GripVertical size={12} />
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
'text-[13px] font-medium transition-colors truncate',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
<motion.button
whileHover={{ x: 4 }}
onClick={onCarnetClick}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
)}
>
<motion.div
animate={{ rotate: isActive ? 90 : 0 }}
className="text-muted-foreground"
>
<ChevronRight size={14} />
</motion.div>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
isActive
? 'bg-foreground text-background border-foreground'
: 'bg-white/60 text-foreground border-border'
)}>
{carnet.initial}
</div>
</div>
</motion.button>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
'text-[13px] font-medium transition-colors truncate',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
</div>
</div>
</motion.button>
</div>
<AnimatePresence>
{isActive && (
@@ -143,7 +161,7 @@ function SidebarCarnetItem({
/>
))}
{notes.length === 0 && (
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">No notes yet</p>
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">{t('common.noResults')}</p>
)}
</motion.div>
)}
@@ -157,17 +175,24 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const { notebooks } = useNotebooks()
const { notebooks, updateNotebookOrderOptimistic } = useNotebooks()
const { refreshKey } = useNoteRefresh()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
// ── Drag state ──
const [draggedId, setDraggedId] = useState<string | null>(null)
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
const dragOverId = useRef<string | null>(null)
// Prevents the sync effect from overwriting a just-saved drag order
const isSavingRef = useRef(false)
const currentNotebookId = searchParams.get('notebook')
const currentNoteId = searchParams.get('openNote')
// Determine if inbox is active (no notebook filter, on home page)
const isInboxActive =
pathname === '/' &&
!searchParams.get('notebook') &&
@@ -175,7 +200,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
!searchParams.get('archived') &&
!searchParams.get('trashed')
// Sync toggle with route (fixes staying on "Agents" tab after navigating home)
useEffect(() => {
setActiveView(
pathname.startsWith('/agents') || pathname.startsWith('/lab') ? 'agents' : 'notebooks'
@@ -185,12 +209,23 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const displayName = user?.name || user?.email || ''
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
// Sorted list for the sort dropdown (not used directly when dragging)
const sortedNotebooks = useMemo(() => {
const arr = [...notebooks]
if (sortOrder === 'alpha') return arr.sort((a, b) => a.name.localeCompare(b.name))
if (sortOrder === 'newest') return arr.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
if (sortOrder === 'oldest') return arr.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return arr
}, [notebooks, sortOrder])
// Sync orderedNotebooks from server ONLY when not in the middle of a drag save
useEffect(() => {
if (isSavingRef.current) return
setOrderedNotebooks(sortedNotebooks)
}, [sortedNotebooks])
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
/** Load note titles for every notebook (like ref: filter per carnet).
* Refetch when notebooks list changes (added/removed/reordered).
* Note: individual note changes (create/edit/delete) don't need to trigger this
* because React Query cache handles invalidation separately. */
useEffect(() => {
if (!notebookIdsKey) return
let cancelled = false
@@ -200,7 +235,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const notes = await getAllNotes(false, nb.id)
const mapped = notes.map((n: Note) => ({
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled') || 'Untitled'),
title: getNoteDisplayTitle(n, t('notes.untitled')),
}))
return [nb.id, mapped] as const
})
@@ -209,16 +244,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
setNotebookNotes(Object.fromEntries(mappedEntries))
}
load()
return () => {
cancelled = true
}
}, [notebookIdsKey, notebooks, t])
return () => { cancelled = true }
// refreshKey: reload note titles whenever any note is saved/created/deleted
}, [notebookIdsKey, refreshKey, t])
// BUG FIX: clicking a carnet always forces list (editorial) view
const handleCarnetClick = (notebookId: string) => {
const params = new URLSearchParams()
params.set('notebook', notebookId)
// forceList resets to editorial view in home-client
params.set('forceList', '1')
router.push(`/?${params.toString()}`)
}
@@ -235,18 +267,68 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
router.push(`/?${params.toString()}`)
}
// Sort notebooks
const sortedNotebooks = [...notebooks].sort((a: Notebook, b: Notebook) => {
if (sortOrder === 'alpha') return a.name.localeCompare(b.name)
if (sortOrder === 'newest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
if (sortOrder === 'oldest') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
return 0
})
// ── Drag handlers ──
const handleDragStart = (e: React.DragEvent, notebookId: string) => {
setDraggedId(notebookId)
e.dataTransfer.effectAllowed = 'move'
}
const handleDragOver = (e: React.DragEvent, notebookId: string) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragOverId.current === notebookId) return
dragOverId.current = notebookId
if (!draggedId || draggedId === notebookId) return
setOrderedNotebooks(prev => {
const fromIdx = prev.findIndex(n => n.id === draggedId)
const toIdx = prev.findIndex(n => n.id === notebookId)
if (fromIdx === -1 || toIdx === -1) return prev
const next = [...prev]
const [item] = next.splice(fromIdx, 1)
next.splice(toIdx, 0, item)
return next
})
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
if (!draggedId) return
const savedOrder = [...orderedNotebooks]
setDraggedId(null)
dragOverId.current = null
// Block the sync effect so the server reload doesn't overwrite local order
isSavingRef.current = true
try {
await updateNotebookOrderOptimistic(savedOrder.map(n => n.id))
// Keep local order — server will return them in the right order next load
setOrderedNotebooks(savedOrder)
} catch {
// On failure, revert to original server order
isSavingRef.current = false
setOrderedNotebooks(sortedNotebooks)
} finally {
// Allow sync again after 2 s (time for the server reload to settle)
setTimeout(() => {
isSavingRef.current = false
}, 2000)
}
}
const handleDragEnd = () => {
if (draggedId) {
// Drag cancelled without drop — restore
setDraggedId(null)
dragOverId.current = null
isSavingRef.current = false
setOrderedNotebooks(sortedNotebooks)
}
}
const sortLabels: Record<SortOrder, string> = {
newest: t('sidebar.sortNewest') || 'Newest first',
oldest: t('sidebar.sortOldest') || 'Oldest first',
alpha: t('sidebar.sortAlpha') || 'A → Z',
newest: t('sidebar.sortNewest'),
oldest: t('sidebar.sortOldest'),
alpha: t('sidebar.sortAlpha'),
}
return (
@@ -254,7 +336,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<aside
className={cn(
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-border/30 dark:bg-sidebar/90',
'border-e border-border/40 bg-memento-sidebar backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
className
)}
>
@@ -265,13 +347,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<button
type="button"
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')}
>
<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-secondary border border-black/10 flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm">
{user?.image ? (
<Avatar className="size-10 ring-1 ring-border/60">
<AvatarImage src={user.image} alt="" />
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initial}</AvatarFallback>
<AvatarFallback className="bg-secondary text-sm font-semibold text-[#1C1C1C]/60">{initial}</AvatarFallback>
</Avatar>
) : (
<span>{initial}</span>
@@ -311,22 +393,25 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</DropdownMenuContent>
</DropdownMenu>
{/* Notebooks / Agents toggle */}
<div className="sidebar-view-toggle">
<button
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
className={cn('sidebar-view-toggle-btn', activeView === 'notebooks' && 'active')}
title={t('nav.notebooks') || 'Notebooks'}
>
<BookOpen size={14} />
</button>
<button
onClick={() => { setActiveView('agents'); router.push('/agents') }}
className={cn('sidebar-view-toggle-btn', activeView === 'agents' && 'active')}
title={t('nav.agents') || 'Agents'}
>
<Bot size={14} />
</button>
{/* Notification bell + Notebooks / Agents toggle */}
<div className="flex items-center gap-2">
<NotificationPanel />
<div className="flex bg-white/50 p-1 rounded-full border border-border transition-all">
<button
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
className={cn('p-1.5 rounded-full transition-all', activeView === 'notebooks' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}
title={t('nav.notebooks')}
>
<BookOpen size={14} />
</button>
<button
onClick={() => { setActiveView('agents'); router.push('/agents') }}
className={cn('p-1.5 rounded-full transition-all', activeView === 'agents' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}
title={t('nav.agents')}
>
<Bot size={14} />
</button>
</div>
</div>
</div>
@@ -345,13 +430,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{/* Section header with sort button */}
<div className="flex items-center justify-between px-4 mb-3">
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
{t('nav.notebooks') || 'Notebooks'}
{t('nav.notebooks')}
</p>
<div className="relative">
<button
onClick={() => setShowSortMenu(s => !s)}
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
title={t('sidebar.sortOrder') || 'Sort order'}
title={t('sidebar.sortOrder')}
>
<ArrowUpDown size={12} />
</button>
@@ -400,32 +485,105 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
'text-[13px] font-medium truncate',
isInboxActive ? 'text-foreground' : 'text-muted-foreground'
)}>
{t('sidebar.inbox') || 'Inbox'}
{t('sidebar.inbox')}
</span>
</button>
<button
onClick={() => {
const params = new URLSearchParams()
params.set('shared', '1')
params.set('forceList', '1')
router.push(`/?${params.toString()}`)
}}
className={cn('sidebar-inbox-item', searchParams.get('shared') === '1' && pathname === '/' && 'active')}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
searchParams.get('shared') === '1' && pathname === '/'
? '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',
searchParams.get('shared') === '1' && pathname === '/' ? 'text-foreground' : 'text-muted-foreground'
)}>
{t('sidebar.sharedWithMe')}
</span>
</button>
<button
onClick={() => {
const params = new URLSearchParams()
params.set('reminders', '1')
params.set('forceList', '1')
router.push(`/?${params.toString()}`)
}}
className={cn('sidebar-inbox-item', searchParams.get('reminders') === '1' && pathname === '/' && 'active')}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
searchParams.get('reminders') === '1' && pathname === '/'
? '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',
searchParams.get('reminders') === '1' && pathname === '/' ? 'text-foreground' : 'text-muted-foreground'
)}>
{t('sidebar.reminders')}
</span>
</button>
{/* Divider */}
<div className="mx-4 my-3 h-px bg-border/40" />
{/* Notebooks list */}
<div className="space-y-1">
{sortedNotebooks.map((notebook: Notebook) => {
{/* Notebooks list — draggable */}
<div
className="space-y-1"
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
{orderedNotebooks.map((notebook: Notebook) => {
const isActive = currentNotebookId === notebook.id
const notes = notebookNotes[notebook.id] || []
const isDragging = draggedId === notebook.id
return (
<SidebarCarnetItem
<motion.div
key={notebook.id}
carnet={{
id: notebook.id,
name: notebook.name,
initial: notebook.name.charAt(0).toUpperCase(),
layout
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
mass: 0.8
}}
isActive={isActive}
notes={notes}
activeNoteId={currentNoteId}
onCarnetClick={() => handleCarnetClick(notebook.id)}
onNoteClick={handleNoteClick}
/>
>
<div
draggable
onDragStart={(e) => handleDragStart(e, notebook.id)}
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragEnd={handleDragEnd}
>
<SidebarCarnetItem
carnet={{
id: notebook.id,
name: notebook.name,
initial: notebook.name.charAt(0).toUpperCase(),
}}
isActive={isActive}
notes={notes}
activeNoteId={currentNoteId}
onCarnetClick={() => handleCarnetClick(notebook.id)}
onNoteClick={handleNoteClick}
isDragging={isDragging}
/>
</div>
</motion.div>
)
})}
@@ -434,7 +592,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
className="w-full mt-4 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/40"
>
<Plus size={16} />
<span>{t('notebooks.create') || 'New Carnet'}</span>
<span>{t('notebook.create')}</span>
</button>
</div>
</motion.div>
@@ -447,13 +605,12 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
transition={{ duration: 0.2 }}
>
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
{t('agents.intelligenceOS') || 'Intelligence OS'}
{t('agents.intelligenceOS')}
</p>
<div className="space-y-1">
{[
{ id: 'agents', href: '/agents', label: t('agents.myAgents') || 'Mes Agents', icon: Bot },
{ id: 'lab', href: '/lab', label: t('nav.lab') || 'Le Lab AI', icon: FlaskConical },
{ id: 'chat', href: '/chat', label: t('nav.chat') || 'Conversations', icon: MessageSquare },
{ id: 'agents', href: '/agents', label: t('agents.myAgents'), icon: Bot },
{ id: 'lab', href: '/lab', label: t('nav.lab'), icon: FlaskConical },
].map(item => {
const isActive = pathname.startsWith(item.href)
return (
@@ -477,17 +634,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</Link>
)
})}
{/* General Chat button (opens floating panel) */}
<button
onClick={() => window.dispatchEvent(new Event('toggle-ai-chat'))}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group text-muted-foreground hover:bg-white/40 hover:text-foreground"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0 bg-white/60 border-border group-hover:border-foreground/20">
<Sparkles size={16} />
</div>
<span className="text-[13px] font-medium">{t('ai.openAssistant') || 'Assistant IA'}</span>
</button>
</div>
</motion.div>
)}
@@ -496,34 +642,26 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{/* ── Footer ── */}
<div className="pt-4 p-5 border-t border-border space-y-1">
{/* Notifications */}
<Link
href="/notifications"
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"
>
<NotificationPanel />
<span>{t('notification.notifications') || 'Notifications'}</span>
</Link>
<Link
href="/archive"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
>
<Archive size={16} />
<span>{t('sidebar.archive') || 'Archives'}</span>
<span>{t('sidebar.archive')}</span>
</Link>
<Link
href="/trash"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
>
<Trash2 size={16} />
<span>{t('sidebar.trash') || 'Corbeille'}</span>
<span>{t('sidebar.trash')}</span>
</Link>
<Link
href="/settings"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
>
<Settings size={16} />
<span>{t('nav.settings') || 'Paramètres'}</span>
<span>{t('nav.settings')}</span>
</Link>
</div>
</aside>

View File

@@ -47,11 +47,10 @@ export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializ
const localFontFamily = localStorage.getItem('font-family')
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
const root = document.documentElement
if (effectiveFontFamily === 'system') {
root.classList.add('font-system')
} else {
root.classList.remove('font-system')
}
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
if (effectiveFontFamily === 'system') root.classList.add('font-system')
if (effectiveFontFamily === 'playfair') root.classList.add('font-playfair')
if (effectiveFontFamily === 'jetbrains') root.classList.add('font-jetbrains')
if (!localFontFamily && fontFamily) {
localStorage.setItem('font-family', fontFamily)
}

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -1,9 +1,20 @@
import { OpenAIProvider } from './providers/openai';
import { OllamaProvider } from './providers/ollama';
import { CustomOpenAIProvider } from './providers/custom-openai';
import { AnthropicProvider } from './providers/anthropic';
import { AIProvider } from './types';
type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio';
type ProviderType =
| 'ollama'
| 'openai'
| 'custom'
| 'deepseek'
| 'openrouter'
| 'mistral'
| 'zai'
| 'lmstudio'
| 'anthropic'
| 'anthropic_custom';
// --- Provider defaults ---
const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string; embeddingModel: string }> = {
@@ -115,6 +126,36 @@ function createLMStudioProvider(config: Record<string, string>, modelName: strin
return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName);
}
function createAnthropicProvider(config: Record<string, string>, modelName: string): AnthropicProvider {
const apiKey = config?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
if (!apiKey) {
throw new Error('ANTHROPIC_API_KEY is required when using Anthropic provider');
}
return new AnthropicProvider(apiKey, modelName || 'claude-sonnet-4-20250514');
}
/**
* Passerelles compatibles **Anthropic Messages API** (ex. MiniMax), pas OpenAI.
* Le SDK envoie les requêtes vers `{baseURL}/messages` avec len-tête `x-api-key`.
*/
function createAnthropicCustomProvider(config: Record<string, string>, modelName: string): AnthropicProvider {
const apiKey = config?.ANTHROPIC_CUSTOM_API_KEY || process.env.ANTHROPIC_CUSTOM_API_KEY || '';
const baseUrl = config?.ANTHROPIC_CUSTOM_BASE_URL || process.env.ANTHROPIC_CUSTOM_BASE_URL || '';
if (!apiKey) {
throw new Error('ANTHROPIC_CUSTOM_API_KEY is required when using Anthropic Custom provider');
}
if (!baseUrl) {
throw new Error('ANTHROPIC_CUSTOM_BASE_URL is required when using Anthropic Custom provider');
}
const resolvedModel =
modelName && modelName.trim() !== '' ? modelName.trim() : 'MiniMax-M2.7';
return new AnthropicProvider(apiKey, resolvedModel, baseUrl.trim());
}
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
switch (providerType) {
case 'ollama':
@@ -133,6 +174,10 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
return createZAIProvider(config, modelName, embeddingModelName);
case 'lmstudio':
return createLMStudioProvider(config, modelName, embeddingModelName);
case 'anthropic':
return createAnthropicProvider(config, modelName);
case 'anthropic_custom':
return createAnthropicCustomProvider(config, modelName);
default:
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
}
@@ -148,6 +193,9 @@ function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string;
case 'zai': return { apiKeyConfigKey: 'ZAI_API_KEY', baseUrlConfigKey: '' };
case 'lmstudio': return { apiKeyConfigKey: 'LMSTUDIO_API_KEY', baseUrlConfigKey: 'LMSTUDIO_BASE_URL' };
case 'openai': return { apiKeyConfigKey: 'OPENAI_API_KEY', baseUrlConfigKey: '' };
case 'anthropic': return { apiKeyConfigKey: 'ANTHROPIC_API_KEY', baseUrlConfigKey: '' };
case 'anthropic_custom':
return { apiKeyConfigKey: 'ANTHROPIC_CUSTOM_API_KEY', baseUrlConfigKey: 'ANTHROPIC_CUSTOM_BASE_URL' };
case 'custom': return { apiKeyConfigKey: 'CUSTOM_OPENAI_API_KEY', baseUrlConfigKey: 'CUSTOM_OPENAI_BASE_URL' };
default: return { apiKeyConfigKey: '', baseUrlConfigKey: 'OLLAMA_BASE_URL' };
}
@@ -167,7 +215,7 @@ export function getTagsProvider(config?: Record<string, string>): AIProvider {
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
);
}
@@ -198,6 +246,12 @@ export function getEmbeddingsProvider(config?: Record<string, string>): AIProvid
}
const provider = providerType.toLowerCase() as ProviderType;
if (provider === 'anthropic' || provider === 'anthropic_custom') {
throw new Error(
'AI_PROVIDER_EMBEDDING cannot use "anthropic" or "anthropic_custom": these gateways use the Anthropic Messages API only (no embeddings in Memento). Use ollama, openai, or "custom" with MiniMax OpenAI URL https://api.minimax.io/v1 for embeddings.'
);
}
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
@@ -225,7 +279,7 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
);
}

View File

@@ -0,0 +1,122 @@
import { createAnthropic } from '@ai-sdk/anthropic';
import { generateObject, generateText as aiGenerateText, stepCountIs } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
export class AnthropicProvider implements AIProvider {
private model: any;
/**
* @param baseURL Optional Messages API root (no trailing slash). The SDK calls `{baseURL}/messages`.
* MiniMax: `https://api.minimax.io/anthropic` (China: `https://api.minimaxi.com/anthropic`).
*/
constructor(apiKey: string, modelName: string = 'claude-sonnet-4-20250514', baseURL?: string) {
const trimmedBase = baseURL?.trim().replace(/\/+$/, '');
const anthropicClient = createAnthropic(trimmedBase ? { apiKey, baseURL: trimmedBase } : { apiKey });
this.model = anthropicClient.chat(modelName);
}
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
return object.tags;
} catch (e) {
console.error('Error generating tags (Anthropic):', e);
return [];
}
}
async getEmbeddings(_text: string): Promise<number[]> {
throw new Error(
'Anthropic does not expose embedding models in Memento. Choose another provider for embeddings (e.g. Ollama or OpenAI).'
);
}
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt,
});
return object.titles;
} catch (e) {
console.error('Error generating titles (Anthropic):', e);
return [];
}
}
async generateText(prompt: string): Promise<string> {
try {
const { text } = await aiGenerateText({
model: this.model,
prompt,
});
return text.trim();
} catch (e) {
console.error('Error generating text (Anthropic):', e);
throw e;
}
}
async chat(messages: any[], systemPrompt?: string): Promise<any> {
try {
const { text } = await aiGenerateText({
model: this.model,
system: systemPrompt,
messages,
});
return { text: text.trim() };
} catch (e) {
console.error('Error in chat (Anthropic):', e);
throw e;
}
}
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options;
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
};
if (systemPrompt) opts.system = systemPrompt;
if (messages) opts.messages = messages;
else if (prompt) opts.prompt = prompt;
const result = await aiGenerateText(opts as any);
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
})) || [],
};
}
getModel() {
return this.model;
}
}

View File

@@ -83,21 +83,23 @@ export class CustomOpenAIProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
// Use generateText instead of generateObject — DeepSeek doesn't support
// response_format: json_schema via the OpenAI compat layer
const { text } = await aiGenerateText({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
})
return object.titles;
// Parse the JSON array from the text response — strip markdown code fences if present
const parsed = JSON.parse(text.replace(/^```json\n?/,'').replace(/\n?```$/,'').trim())
const titles = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || [])
return titles.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.5),
}))
} catch (e) {
console.error('Error generating titles (Custom OpenAI):', e);
return [];
console.error('Error generating titles (Custom OpenAI):', e)
return []
}
}

Some files were not shown because too many files have changed in this diff Show More