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
10
.claude/settings.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
9
architectural-grid (5)/.env.example
Normal 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
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
20
architectural-grid (5)/README.md
Normal 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`
|
||||
13
architectural-grid (5)/index.html
Normal 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>
|
||||
|
||||
6
architectural-grid (5)/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Architectural Grid",
|
||||
"description": "A minimalist notebook for architectural research and conceptual sketches.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": []
|
||||
}
|
||||
34
architectural-grid (5)/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1352
architectural-grid (5)/src/App.tsx
Normal file
58
architectural-grid (5)/src/index.css
Normal 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);
|
||||
}
|
||||
10
architectural-grid (5)/src/main.tsx
Normal 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>,
|
||||
);
|
||||
26
architectural-grid (5)/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
24
architectural-grid (5)/vite.config.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -23,7 +23,7 @@ export type UserAISettingsData = {
|
||||
autoLabeling?: boolean
|
||||
noteHistory?: boolean
|
||||
noteHistoryMode?: 'manual' | 'auto'
|
||||
fontFamily?: 'inter' | 'system'
|
||||
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
|
||||
}
|
||||
|
||||
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
||||
@@ -191,7 +191,7 @@ 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',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting AI settings:', error)
|
||||
|
||||
100
memento-note/app/actions/note-illustration.ts
Normal 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, 2–4 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 n’a 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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Font family overrides — toggled on <html> by ThemeInitializer */
|
||||
.font-system {
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.font-playfair {
|
||||
--font-sans: var(--font-memento-serif), ui-serif, Georgia, 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
.font-jetbrains {
|
||||
--font-sans: var(--font-jetbrains-mono), 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for better aesthetics - Architectural Minimalist */
|
||||
::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
@@ -93,6 +106,9 @@
|
||||
@utility win11-shadow-hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
@utility editor-body {
|
||||
font-size: var(--editor-body-size, 16px);
|
||||
}
|
||||
|
||||
/* Architectural Grid — texture & navigation (réf. architectural-grid1) */
|
||||
.memento-paper-texture {
|
||||
@@ -269,7 +285,7 @@ html.dark .memento-active-nav {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-inter);
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
|
||||
--font-mono: var(--font-jetbrains-mono), 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -423,7 +439,7 @@ html.dark {
|
||||
--secondary: #2d2d2d;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #2d2d2d;
|
||||
--muted-foreground: #9e9e9e;
|
||||
--muted-foreground: #a8a8a8;
|
||||
--accent: #383838;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ff6b6b;
|
||||
@@ -435,7 +451,7 @@ html.dark {
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: rgba(32, 32, 32, 0.75);
|
||||
--sidebar: #252525;
|
||||
--sidebar-foreground: #ffffff;
|
||||
--sidebar-primary: #d6d3d1;
|
||||
--sidebar-primary-foreground: #1c1917;
|
||||
@@ -991,6 +1007,11 @@ html.font-system * {
|
||||
font-size: var(--user-font-size, 16px);
|
||||
}
|
||||
|
||||
/* Editor body size — used for textarea and ProseMirror content */
|
||||
:root {
|
||||
--editor-body-size: var(--user-font-size, 16px);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-foreground;
|
||||
background-color: var(--memento-desk);
|
||||
@@ -1778,3 +1799,25 @@ html.font-system * {
|
||||
font-size: 1.125rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ─── OVERRIDE FINAL: Force editorial body text in fullPage using CSS var ──────── */
|
||||
/* Sets font-size on the container so TipTap inherits --editor-body-size */
|
||||
.fullpage-editor {
|
||||
font-size: var(--editor-body-size, 16px) !important;
|
||||
line-height: 1.85 !important;
|
||||
}
|
||||
/* Also target TipTap's actual editable div directly */
|
||||
.fullpage-editor .tiptap,
|
||||
.fullpage-editor .tiptap p,
|
||||
.fullpage-editor .ProseMirror,
|
||||
.fullpage-editor .ProseMirror > p {
|
||||
font-size: var(--editor-body-size, 16px) !important;
|
||||
line-height: 1.85 !important;
|
||||
}
|
||||
/* Keep headings at their correct relative sizes */
|
||||
.fullpage-editor .tiptap h1,
|
||||
.fullpage-editor .ProseMirror h1 { font-size: 2.25rem !important; line-height: 1.25 !important; }
|
||||
.fullpage-editor .tiptap h2,
|
||||
.fullpage-editor .ProseMirror h2 { font-size: 1.75rem !important; line-height: 1.3 !important; }
|
||||
.fullpage-editor .tiptap h3,
|
||||
.fullpage-editor .ProseMirror h3 { font-size: 1.375rem !important; line-height: 1.4 !important; }
|
||||
|
||||
@@ -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",
|
||||
@@ -99,7 +105,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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
219
memento-note/components/admin-sidebar.tsx
Normal 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 <a> 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, Share2 } from 'lucide-react'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
@@ -71,6 +71,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()
|
||||
@@ -147,10 +150,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 +210,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 () => {
|
||||
@@ -347,6 +351,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,13 +373,14 @@ 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={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-background/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">
|
||||
@@ -374,53 +389,85 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
<button className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity">
|
||||
<Share2 size={16} />
|
||||
<span>{t('notes.share') || 'Share'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -442,13 +489,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 +505,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 +537,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
||||
<MemoryEchoNotification onOpenNote={(noteId) => handleOpenNoteFresh(noteId)} />
|
||||
|
||||
{notebookSuggestion && (
|
||||
<NotebookSuggestionToast
|
||||
|
||||
@@ -182,7 +182,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
}
|
||||
}}
|
||||
validateEmbeddable={false}
|
||||
renderTopRightUI={() => null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 } from '@/app/actions/notes'
|
||||
|
||||
type Tab = 'info' | 'versions'
|
||||
|
||||
@@ -47,6 +47,8 @@ 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 locale = getLocale(language)
|
||||
|
||||
const notebook = useMemo(
|
||||
@@ -62,7 +64,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,8 +201,42 @@ 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-3">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Versions sauvegardées</p>
|
||||
|
||||
{/* Save version button */}
|
||||
<button
|
||||
disabled={isSavingVersion}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 p-3 rounded-xl border transition-colors text-sm font-medium',
|
||||
versionSaved
|
||||
? 'border-emerald-500/40 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400'
|
||||
: 'border-foreground/20 bg-foreground text-background hover:opacity-80',
|
||||
isSavingVersion && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={async () => {
|
||||
setIsSavingVersion(true)
|
||||
try {
|
||||
await commitNoteHistory(note.id)
|
||||
setVersionSaved(true)
|
||||
setTimeout(() => setVersionSaved(false), 3000)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsSavingVersion(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSavingVersion ? (
|
||||
<><span className="h-3.5 w-3.5 rounded-full border-2 border-current border-t-transparent animate-spin" />Sauvegarde…</>
|
||||
) : versionSaved ? (
|
||||
<><span className="text-base">✓</span> Version sauvegardée !</>
|
||||
) : (
|
||||
<><span className="text-base">⎘</span> Sauvegarder cette version</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* View history */}
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl border border-border hover:bg-muted transition-colors text-left"
|
||||
onClick={() => setShowHistory(true)}
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,11 @@ 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
|
||||
} from 'lucide-react'
|
||||
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 +37,59 @@ 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 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,68 +120,70 @@ 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>
|
||||
)}
|
||||
|
||||
@@ -148,7 +200,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 +213,20 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* 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 +343,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'))
|
||||
|
||||
@@ -29,8 +29,8 @@ 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',
|
||||
'placeholder:text-foreground/20 resize-none overflow-hidden',
|
||||
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight break-words',
|
||||
'placeholder:text-foreground/20 resize-none overflow-hidden break-words',
|
||||
!readOnly && 'pr-12'
|
||||
)}
|
||||
/>
|
||||
@@ -39,21 +39,26 @@ export function NoteTitleBlock() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
console.log('[TITLE] Sparkles button clicked')
|
||||
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||
console.log('[TITLE] Content length:', plain.length, 'Word count:', wordCount)
|
||||
if (wordCount < 10) {
|
||||
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
|
||||
return
|
||||
}
|
||||
actions.setIsProcessingAI(true)
|
||||
try {
|
||||
console.log('[TITLE] Calling /api/ai/title-suggestions...')
|
||||
const res = await fetch('/api/ai/title-suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: plain }),
|
||||
})
|
||||
console.log('[TITLE] API response:', res.status)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
console.log('[TITLE] Suggestions:', data.suggestions)
|
||||
const s = data.suggestions?.[0]?.title ?? ''
|
||||
if (s) {
|
||||
actions.setTitle(s)
|
||||
@@ -62,9 +67,14 @@ export function NoteTitleBlock() {
|
||||
toast.error('Impossible de générer un titre.')
|
||||
}
|
||||
} else {
|
||||
const err = await res.text()
|
||||
console.error('[TITLE] API error:', err)
|
||||
toast.error('Erreur lors de la génération du titre.')
|
||||
}
|
||||
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
|
||||
} catch (e) {
|
||||
console.error('[TITLE] Fetch failed:', 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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -264,7 +264,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 +277,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>
|
||||
|
||||
|
||||
@@ -142,21 +142,16 @@ export function NotificationPanel() {
|
||||
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 bg-rose-500 text-white text-[9px] font-bold border border-white shadow-sm">
|
||||
{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">
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
User,
|
||||
LogOut,
|
||||
Shield,
|
||||
GripVertical,
|
||||
} 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 +40,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'
|
||||
@@ -79,51 +80,65 @@ 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>
|
||||
}) {
|
||||
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 && (
|
||||
@@ -157,17 +172,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 +197,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 +206,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
|
||||
@@ -209,16 +241,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,13 +264,63 @@ 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',
|
||||
@@ -254,7 +333,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-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -311,22 +390,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') || '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') || 'Agents'}
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -407,25 +489,38 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{/* 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
|
||||
<div
|
||||
key={notebook.id}
|
||||
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}
|
||||
/>
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -496,14 +591,6 @@ 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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 310 KiB |
@@ -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 l’en-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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
122
memento-note/lib/ai/providers/anthropic.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,4 +67,14 @@ export interface AIProvider {
|
||||
generateWithTools(options: ToolUseOptions): Promise<ToolCallResult>;
|
||||
}
|
||||
|
||||
export type AIProviderType = 'openai' | 'ollama' | 'custom' | 'deepseek' | 'openrouter';
|
||||
export type AIProviderType =
|
||||
| 'openai'
|
||||
| 'ollama'
|
||||
| 'custom'
|
||||
| 'deepseek'
|
||||
| 'openrouter'
|
||||
| 'mistral'
|
||||
| 'zai'
|
||||
| 'lmstudio'
|
||||
| 'anthropic'
|
||||
| 'anthropic_custom';
|
||||
|
||||
@@ -13,6 +13,11 @@ const ENV_FALLBACKS: Record<string, string> = {
|
||||
OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL || '',
|
||||
// OpenAI
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
|
||||
// Anthropic (official Messages API)
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '',
|
||||
// Anthropic via OpenAI-compatible proxy / gateway
|
||||
ANTHROPIC_CUSTOM_API_KEY: process.env.ANTHROPIC_CUSTOM_API_KEY || '',
|
||||
ANTHROPIC_CUSTOM_BASE_URL: process.env.ANTHROPIC_CUSTOM_BASE_URL || '',
|
||||
// Custom OpenAI
|
||||
CUSTOM_OPENAI_API_KEY: process.env.CUSTOM_OPENAI_API_KEY || '',
|
||||
CUSTOM_OPENAI_BASE_URL: process.env.CUSTOM_OPENAI_BASE_URL || '',
|
||||
|
||||
@@ -567,6 +567,9 @@ export interface Translations {
|
||||
}
|
||||
admin: {
|
||||
title: string
|
||||
adminConsole: string
|
||||
navSection: string
|
||||
backToApp: string
|
||||
userManagement: string
|
||||
aiTesting: string
|
||||
settings: string
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface Note {
|
||||
checkItems: CheckItem[] | null;
|
||||
labels: string[] | null;
|
||||
images: string[] | null;
|
||||
/** SVG sanitisé pour vignette flux éditorial (ex. généré par IA) */
|
||||
illustrationSvg?: string | null;
|
||||
links: LinkMetadata[] | null;
|
||||
reminder: Date | null;
|
||||
isReminderDone: boolean;
|
||||
|
||||
@@ -339,6 +339,8 @@
|
||||
"transforming": "Transforming...",
|
||||
"transformSuccess": "Text transformed to Markdown successfully!",
|
||||
"transformError": "Error during transformation",
|
||||
"convertToRichtext": "Convert to Rich Text",
|
||||
"convertingToRichtext": "Converting...",
|
||||
"assistant": "AI Assistant",
|
||||
"generating": "Generating...",
|
||||
"generateTitles": "Generate titles",
|
||||
@@ -920,6 +922,9 @@
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Dashboard",
|
||||
"adminConsole": "Admin console",
|
||||
"navSection": "Navigation",
|
||||
"backToApp": "Back to Memento",
|
||||
"userManagement": "User Management",
|
||||
"chat": "AI Chat",
|
||||
"lab": "The Lab",
|
||||
@@ -962,6 +967,11 @@
|
||||
"providerEmbeddingRequired": "AI_PROVIDER_EMBEDDING is required",
|
||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||
"providerAnthropicOption": "🧠 Anthropic (Claude API)",
|
||||
"providerAnthropicCustomOption": "🧩 Anthropic custom (Messages API — MiniMax, etc.)",
|
||||
"anthropicModelHint": "Pick a Claude model ID from the suggestions or enter one manually (no remote model list for the official API).",
|
||||
"anthropicCustomModelHint": "Anthropic-compatible Messages API (e.g. MiniMax): base URL https://api.minimax.io/anthropic (China: https://api.minimaxi.com/anthropic), model MiniMax-M2.7. Embeddings: use provider « Custom » + OpenAI URL https://api.minimax.io/v1.",
|
||||
"anthropicCustomNoModelList": "This gateway does not expose an OpenAI-style /models list — pick the model from the suggestions or type it (e.g. MiniMax-M2.7).",
|
||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||
|
||||
@@ -339,6 +339,8 @@
|
||||
"transforming": "Transformation...",
|
||||
"transformSuccess": "Texte transformé en Markdown avec succès !",
|
||||
"transformError": "Erreur lors de la transformation",
|
||||
"convertToRichtext": "Convertir en texte enrichi",
|
||||
"convertingToRichtext": "Conversion...",
|
||||
"assistant": "IA Note",
|
||||
"generating": "Génération...",
|
||||
"generateTitles": "Générer des titres",
|
||||
@@ -991,6 +993,9 @@
|
||||
},
|
||||
"admin": {
|
||||
"title": "Tableau de bord Admin",
|
||||
"adminConsole": "Console d’administration",
|
||||
"navSection": "Navigation",
|
||||
"backToApp": "Retour à Memento",
|
||||
"userManagement": "Gestion des utilisateurs",
|
||||
"chat": "Chat IA",
|
||||
"lab": "Le Lab",
|
||||
@@ -1033,6 +1038,11 @@
|
||||
"providerEmbeddingRequired": "AI_PROVIDER_EMBEDDING est requis",
|
||||
"providerOllamaOption": "🦙 Ollama (Local & Gratuit)",
|
||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||
"providerAnthropicOption": "🧠 Anthropic (API Claude)",
|
||||
"providerAnthropicCustomOption": "🧩 Anthropic personnalisé (API Messages — MiniMax, etc.)",
|
||||
"anthropicModelHint": "Choisissez un identifiant Claude parmi les suggestions ou saisissez-en un (pas de liste distante pour l’API officielle).",
|
||||
"anthropicCustomModelHint": "API Messages compatible Anthropic (ex. MiniMax) : URL de base https://api.minimax.io/anthropic (Chine : https://api.minimaxi.com/anthropic), modèle MiniMax-M2.7. Pour les embeddings, utilisez le fournisseur « Personnalisé » avec l’URL OpenAI https://api.minimax.io/v1.",
|
||||
"anthropicCustomNoModelList": "Cette passerelle n’expose pas de liste /models style OpenAI — choisissez le modèle dans les suggestions ou saisissez-le (ex. MiniMax-M2.7).",
|
||||
"providerCustomOption": "🔧 Custom Compatible OpenAI",
|
||||
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||
|
||||
73
memento-note/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "memento",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.76",
|
||||
"@ai-sdk/openai": "^3.0.7",
|
||||
"@ai-sdk/react": "^3.0.170",
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tiptap/extension-color": "^3.22.5",
|
||||
"@tiptap/extension-highlight": "^3.22.5",
|
||||
"@tiptap/extension-image": "^3.22.5",
|
||||
@@ -105,6 +107,51 @@
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/anthropic": {
|
||||
"version": "3.0.76",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.76.tgz",
|
||||
"integrity": "sha512-kOuvT9e6PygFvgYpkr4v9gjvmcMPfJp79jaXjeRl9Gpoj2OXdtc3ero7o1ic+tiSBw5IMubxXFO68BCA/axGJA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@ai-sdk/provider-utils": "4.0.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
|
||||
"integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "4.0.27",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
|
||||
"integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"eventsource-parser": "^3.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/gateway": {
|
||||
"version": "3.0.104",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.104.tgz",
|
||||
@@ -6249,6 +6296,32 @@
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
|
||||
"integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.100.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz",
|
||||
"integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.100.9"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "illustrationSvg" TEXT;
|
||||
@@ -294,7 +294,7 @@ async function chooseEnvironment() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI provider menus
|
||||
// ---------------------------------------------------------------------------
|
||||
const AI_PROVIDERS = ['openai', 'ollama', 'deepseek', 'openrouter', 'custom-openai'];
|
||||
const AI_PROVIDERS = ['openai', 'anthropic', 'anthropic_custom', 'ollama', 'deepseek', 'openrouter', 'custom-openai'];
|
||||
|
||||
function showAiProviderMenu(label) {
|
||||
console.log();
|
||||
|
||||