Compare commits
21 Commits
db899b0da2
...
feature/ar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbca93c4be | ||
|
|
368b43cb8e | ||
|
|
66e957fd59 | ||
|
|
b0c2556a12 | ||
|
|
60a3fe5453 | ||
|
|
1446463f04 | ||
|
|
97b08e5d0b | ||
|
|
574c8b3166 | ||
|
|
9b8df398dc | ||
|
|
65568c0f07 | ||
|
|
def683982c | ||
|
|
91b1201112 | ||
|
|
a58610003d | ||
|
|
29e65038b7 | ||
|
|
38c637cfac | ||
|
|
3b036e84b8 | ||
|
|
ea62d68cdd | ||
|
|
77d6458946 | ||
|
|
8d4e4d5d56 | ||
|
|
24b5d6bdac | ||
|
|
01390ebb5b |
10
.claude/settings.json
Normal file
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(do python3 -c \"import json; json.load\\(open\\(''$f''\\)\\)\")",
|
||||||
"Bash(done)",
|
"Bash(done)",
|
||||||
"Bash(npx prisma generate)",
|
"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
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
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
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
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
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
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
1352
architectural-grid (5)/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
architectural-grid (5)/src/index.css
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
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
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
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
|
# AI Providers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Main provider: "openai" | "ollama" | "deepseek" | "openrouter" | "custom-openai"
|
# Main provider: "openai" | "anthropic" | "anthropic_custom" | "ollama" | "deepseek" | "openrouter" | "custom-openai"
|
||||||
# AI_PROVIDER="openai"
|
# AI_PROVIDER="openai"
|
||||||
|
|
||||||
# Per-feature provider overrides (optional, falls back to AI_PROVIDER)
|
# Per-feature provider overrides (optional, falls back to AI_PROVIDER)
|
||||||
# AI_PROVIDER_CHAT="openai"
|
# AI_PROVIDER_CHAT="openai"
|
||||||
# AI_PROVIDER_TAGS="openai"
|
# AI_PROVIDER_TAGS="anthropic"
|
||||||
# AI_PROVIDER_EMBEDDING="openai"
|
# AI_PROVIDER_EMBEDDING="openai"
|
||||||
|
|
||||||
# Model names (optional, uses provider defaults)
|
# Model names (optional, uses provider defaults)
|
||||||
# AI_MODEL_CHAT="gpt-4o-mini"
|
# 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"
|
# AI_MODEL_EMBEDDING="text-embedding-3-small"
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
# OPENAI_API_KEY="sk-..."
|
# 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 (local)
|
||||||
# OLLAMA_BASE_URL="http://localhost:11434"
|
# 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_API_KEY="..."
|
||||||
# CUSTOM_OPENAI_BASE_URL="https://your-provider.com/v1"
|
# CUSTOM_OPENAI_BASE_URL="https://your-provider.com/v1"
|
||||||
|
|
||||||
|
|||||||
@@ -103,9 +103,9 @@ export default function AITestPage() {
|
|||||||
|
|
||||||
{/* 3. Chat Test - Horizontal Layout */}
|
{/* 3. Chat Test - Horizontal Layout */}
|
||||||
<div className="bg-card rounded-[4rem] border border-border/60 shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-700 group flex flex-col xl:flex-row">
|
<div className="bg-card rounded-[4rem] border border-border/60 shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-700 group flex flex-col xl:flex-row">
|
||||||
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-violet-500/[0.05] to-transparent relative overflow-hidden">
|
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-zinc-500/[0.05] to-transparent relative overflow-hidden">
|
||||||
<div className="absolute -right-10 -bottom-10 opacity-[0.03] group-hover:opacity-[0.08] transition-all duration-700 group-hover:scale-125 group-hover:-rotate-6">
|
<div className="absolute -right-10 -bottom-10 opacity-[0.03] group-hover:opacity-[0.08] transition-all duration-700 group-hover:scale-125 group-hover:-rotate-6">
|
||||||
<MessageSquare className="h-80 w-80 text-violet-500" />
|
<MessageSquare className="h-80 w-80 text-zinc-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative space-y-8">
|
<div className="relative space-y-8">
|
||||||
<div className="w-20 h-20 rounded-[1.5rem] bg-background flex items-center justify-center text-4xl shadow-2xl border border-border/50 group-hover:scale-110 transition-transform duration-500">
|
<div className="w-20 h-20 rounded-[1.5rem] bg-background flex items-center justify-center text-4xl shadow-2xl border border-border/50 group-hover:scale-110 transition-transform duration-500">
|
||||||
@@ -116,12 +116,12 @@ export default function AITestPage() {
|
|||||||
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.chatTestDescription')}</p>
|
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.chatTestDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<span className="px-4 py-2 bg-violet-500/10 rounded-xl text-violet-600 text-[10px] font-black uppercase tracking-widest">Conversational</span>
|
<span className="px-4 py-2 bg-zinc-500/10 rounded-xl text-zinc-600 text-[10px] font-black uppercase tracking-widest">Conversational</span>
|
||||||
<span className="px-4 py-2 bg-violet-500/10 rounded-xl text-violet-600 text-[10px] font-black uppercase tracking-widest">Streaming</span>
|
<span className="px-4 py-2 bg-zinc-500/10 rounded-xl text-zinc-600 text-[10px] font-black uppercase tracking-widest">Streaming</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-violet-500/[0.01]">
|
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-zinc-500/[0.01]">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<AI_TESTER type="chat" />
|
<AI_TESTER type="chat" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
import { AdminHeader } from '@/components/admin-header'
|
import { AdminSidebar } from '@/components/admin-sidebar'
|
||||||
import { AdminNav } from '@/components/admin-nav'
|
|
||||||
|
|
||||||
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
||||||
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
|
// Navigation admin ↔ app en <a> (rechargement complet) pour éviter React Error #310
|
||||||
// to avoid React Error #310 caused by Next.js 16.x route-group transition bug.
|
// sur les transitions entre route groups (Next.js 16 / React #33580).
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex flex-col min-h-screen">
|
<div className="flex h-screen overflow-hidden bg-[#E5E2D9] dark:bg-background">
|
||||||
<AdminHeader />
|
<AdminSidebar />
|
||||||
|
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
|
||||||
{/* Horizontal Tab Navigation */}
|
<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">
|
||||||
<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">
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,33 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
|
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
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
|
// Provider config metadata
|
||||||
const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: string; hasApiKey: boolean; hasBaseUrl: boolean; isLocal: boolean }> = {
|
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 },
|
ollama: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true },
|
||||||
openai: { apiKeyLabel: 'OPENAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
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 },
|
deepseek: { apiKeyLabel: 'DEEPSEEK_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||||
openrouter:{ apiKeyLabel: 'OPENROUTER_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 },
|
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> = {
|
const API_KEY_CONFIG: Record<AIProvider, string> = {
|
||||||
ollama: '',
|
ollama: '',
|
||||||
openai: 'OPENAI_API_KEY',
|
openai: 'OPENAI_API_KEY',
|
||||||
|
anthropic: 'ANTHROPIC_API_KEY',
|
||||||
|
anthropic_custom: 'ANTHROPIC_CUSTOM_API_KEY',
|
||||||
deepseek: 'DEEPSEEK_API_KEY',
|
deepseek: 'DEEPSEEK_API_KEY',
|
||||||
openrouter: 'OPENROUTER_API_KEY',
|
openrouter: 'OPENROUTER_API_KEY',
|
||||||
mistral: 'MISTRAL_API_KEY',
|
mistral: 'MISTRAL_API_KEY',
|
||||||
@@ -41,6 +64,8 @@ const API_KEY_CONFIG: Record<AIProvider, string> = {
|
|||||||
const BASE_URL_CONFIG: Record<AIProvider, string> = {
|
const BASE_URL_CONFIG: Record<AIProvider, string> = {
|
||||||
ollama: 'OLLAMA_BASE_URL',
|
ollama: 'OLLAMA_BASE_URL',
|
||||||
openai: '',
|
openai: '',
|
||||||
|
anthropic: '',
|
||||||
|
anthropic_custom: 'ANTHROPIC_CUSTOM_BASE_URL',
|
||||||
deepseek: '',
|
deepseek: '',
|
||||||
openrouter: '',
|
openrouter: '',
|
||||||
mistral: '',
|
mistral: '',
|
||||||
@@ -52,6 +77,8 @@ const BASE_URL_CONFIG: Record<AIProvider, string> = {
|
|||||||
const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
||||||
ollama: 'http://localhost:11434',
|
ollama: 'http://localhost:11434',
|
||||||
openai: '',
|
openai: '',
|
||||||
|
anthropic: '',
|
||||||
|
anthropic_custom: '',
|
||||||
deepseek: 'https://api.deepseek.com/v1',
|
deepseek: 'https://api.deepseek.com/v1',
|
||||||
openrouter: 'https://openrouter.ai/api/v1',
|
openrouter: 'https://openrouter.ai/api/v1',
|
||||||
mistral: 'https://api.mistral.ai/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)
|
// Suggested models per provider (shown as hints in Combobox - user can always type a custom name)
|
||||||
const SUGGESTED_MODELS: Record<string, string[]> = {
|
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'],
|
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'],
|
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'],
|
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
||||||
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', 'codestral-latest', 'mistral-embed'],
|
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
|
// AI Provider state - separated for tags, embeddings, and chat
|
||||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
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')
|
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||||
|
|
||||||
// Selected Models State
|
// 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')
|
await fetchModels('tags', 'ollama', config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||||
} else if (tagsProvider === 'lmstudio') {
|
} else if (tagsProvider === 'lmstudio') {
|
||||||
await fetchModels('tags', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
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 url = DEFAULT_BASE_URLS[tagsProvider]
|
||||||
const key = config[API_KEY_CONFIG[tagsProvider]] || ''
|
const key = config[API_KEY_CONFIG[tagsProvider]] || ''
|
||||||
if (url && key) await fetchModels('tags', tagsProvider, url, key)
|
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')
|
await fetchModels('embeddings', 'ollama', config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||||
} else if (embeddingsProvider === 'lmstudio') {
|
} else if (embeddingsProvider === 'lmstudio') {
|
||||||
await fetchModels('embeddings', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
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 url = DEFAULT_BASE_URLS[embeddingsProvider]
|
||||||
const key = config[API_KEY_CONFIG[embeddingsProvider]] || ''
|
const key = config[API_KEY_CONFIG[embeddingsProvider]] || ''
|
||||||
if (url && key) await fetchModels('embeddings', embeddingsProvider, url, key)
|
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')
|
await fetchModels('chat', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||||
} else if (chatProvider === 'lmstudio') {
|
} else if (chatProvider === 'lmstudio') {
|
||||||
await fetchModels('chat', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
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 url = DEFAULT_BASE_URLS[chatProvider]
|
||||||
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
||||||
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
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.LMSTUDIO_BASE_URL || DEFAULT_BASE_URLS.lmstudio)
|
||||||
: (config[BASE_URL_CONFIG[provider]] || DEFAULT_BASE_URLS[provider] || '')
|
: (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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (provider === 'anthropic_custom') {
|
||||||
|
toast.info(t('admin.ai.anthropicCustomNoModelList'))
|
||||||
|
return
|
||||||
|
}
|
||||||
const urlInput = document.getElementById(`BASE_URL_${provider}_${purpose}`) as HTMLInputElement
|
const urlInput = document.getElementById(`BASE_URL_${provider}_${purpose}`) as HTMLInputElement
|
||||||
const keyInput = meta.hasApiKey
|
const keyInput = meta.hasApiKey
|
||||||
? document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
|
? 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)
|
const key = keyInput?.value || (meta.hasApiKey ? config[API_KEY_CONFIG[provider]] : undefined)
|
||||||
if (url) fetchModels(purpose, provider, url, key)
|
if (url) fetchModels(purpose, provider, url, key)
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading || provider === 'anthropic_custom'}
|
||||||
title={t('admin.ai.refreshModels')}
|
title={t('admin.ai.refreshModels')}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
<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]] || ''
|
const key = keyInput?.value || config[API_KEY_CONFIG[provider]] || ''
|
||||||
if (url && key) fetchModels(purpose, provider, url, key)
|
if (url && key) fetchModels(purpose, provider, url, key)
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading || provider === 'anthropic'}
|
||||||
title={t('admin.ai.refreshModels')}
|
title={t('admin.ai.refreshModels')}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
@@ -525,6 +581,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
? t('admin.ai.fetchingModels')
|
? t('admin.ai.fetchingModels')
|
||||||
: dynamicModels[purpose].length > 0
|
: dynamicModels[purpose].length > 0
|
||||||
? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length })
|
? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length })
|
||||||
|
: provider === 'anthropic'
|
||||||
|
? t('admin.ai.anthropicModelHint')
|
||||||
|
: provider === 'anthropic_custom'
|
||||||
|
? t('admin.ai.anthropicCustomModelHint')
|
||||||
: provider === 'ollama' || provider === 'lmstudio'
|
: provider === 'ollama' || provider === 'lmstudio'
|
||||||
? t('admin.ai.selectOllamaModel')
|
? t('admin.ai.selectOllamaModel')
|
||||||
: t('admin.ai.enterUrlToLoad')}
|
: t('admin.ai.enterUrlToLoad')}
|
||||||
@@ -538,6 +598,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
const providerOptions = [
|
const providerOptions = [
|
||||||
{ value: 'ollama', label: t('admin.ai.providerOllamaOption') },
|
{ value: 'ollama', label: t('admin.ai.providerOllamaOption') },
|
||||||
{ value: 'openai', label: t('admin.ai.providerOpenAIOption') },
|
{ 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: 'deepseek', label: t('admin.ai.providerDeepSeekOption') },
|
||||||
{ value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') },
|
{ value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') },
|
||||||
{ value: 'mistral', label: t('admin.ai.providerMistralOption') },
|
{ 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') },
|
{ value: 'custom', label: t('admin.ai.providerCustomOption') },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const embeddingsProviderOptions = providerOptions.filter(
|
||||||
|
(opt) => !PROVIDERS_WITHOUT_EMBEDDINGS.includes(opt.value as AIProvider)
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="columns-1 lg:columns-2 gap-6">
|
<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">
|
<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"
|
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>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -670,7 +736,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
{/* Chat Provider */}
|
{/* Chat Provider */}
|
||||||
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'chat' ? 'block' : 'hidden'}`}>
|
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
|
<span className="text-zinc-600">💬</span> {t('admin.ai.chatProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ export default async function MainLayout({
|
|||||||
return (
|
return (
|
||||||
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||||
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
||||||
<div className="flex h-screen overflow-hidden bg-[#E5E2D9]">
|
<div className="flex h-screen overflow-hidden bg-memento-desk">
|
||||||
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
|
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
|
||||||
<Sidebar user={session?.user} />
|
<Sidebar user={session?.user} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
|
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default async function HomePage() {
|
|||||||
notesViewMode,
|
notesViewMode,
|
||||||
noteHistory: settings?.noteHistory === true,
|
noteHistory: settings?.noteHistory === true,
|
||||||
noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||||
|
aiAssistantEnabled: settings?.paragraphRefactor !== false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { getNotesWithReminders } from '@/app/actions/notes'
|
|
||||||
import { RemindersPage } from '@/components/reminders-page'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export default async function RemindersRoute() {
|
|
||||||
const notes = await getNotesWithReminders()
|
|
||||||
return <RemindersPage notes={notes} />
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,9 @@ export default function AboutSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('about.title')}</h1>
|
{t('about.description')}
|
||||||
<p className="text-muted-foreground mt-1">{t('about.description')}</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* App info */}
|
{/* App info */}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ export function AISettingsHeader() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('aiSettings.title')}</h1>
|
{t('aiSettings.description')}
|
||||||
<p className="text-muted-foreground mt-1">{t('aiSettings.description')}</p>
|
</p>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,12 +63,18 @@ export function AppearanceSettingsClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleFontFamilyChange = async (value: string) => {
|
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)
|
setFontFamily(font)
|
||||||
localStorage.setItem('font-family', font)
|
localStorage.setItem('font-family', font)
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
font === 'system' ? root.classList.add('font-system') : root.classList.remove('font-system')
|
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
|
||||||
await updateAISettings({ fontFamily: font })
|
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')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,10 +163,10 @@ export function AppearanceSettingsClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
{/* Section label — architectural style */}
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('appearance.title')}</h1>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<p className="text-muted-foreground mt-1">{t('appearance.description')}</p>
|
{t('appearance.description') || "Personnalisez l'interface"}
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<SelectCard
|
<SelectCard
|
||||||
@@ -192,7 +198,9 @@ export function AppearanceSettingsClient({
|
|||||||
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
|
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
|
||||||
value={fontFamily}
|
value={fontFamily}
|
||||||
options={[
|
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' },
|
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
||||||
]}
|
]}
|
||||||
onChange={handleFontFamilyChange}
|
onChange={handleFontFamilyChange}
|
||||||
|
|||||||
@@ -118,17 +118,16 @@ export default function DataSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8 p-6">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1">
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">{t('dataManagement.title')}</h1>
|
{t('dataManagement.toolsDescription')}
|
||||||
<p className="text-muted-foreground">{t('dataManagement.toolsDescription')}</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Export card */}
|
{/* Export card */}
|
||||||
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-600 shrink-0">
|
<div className="w-12 h-12 rounded-full bg-zinc-500/10 flex items-center justify-center text-zinc-600 shrink-0">
|
||||||
<Download className="h-6 w-6" />
|
<Download className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface GeneralSettingsClientProps {
|
|||||||
preferredLanguage: string
|
preferredLanguage: string
|
||||||
emailNotifications: boolean
|
emailNotifications: boolean
|
||||||
desktopNotifications: boolean
|
desktopNotifications: boolean
|
||||||
|
autoSave: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,15 +22,18 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||||
|
const [autoSave, setAutoSave] = useState(initialSettings.autoSave ?? true)
|
||||||
|
|
||||||
const handleLanguageChange = async (value: string) => {
|
const handleLanguageChange = async (value: string) => {
|
||||||
setLanguage(value)
|
setLanguage(value)
|
||||||
await updateAISettings({ preferredLanguage: value as any })
|
await updateAISettings({ preferredLanguage: value as any })
|
||||||
if (value === 'auto') {
|
if (value === 'auto') {
|
||||||
localStorage.removeItem('user-language')
|
localStorage.removeItem('user-language')
|
||||||
|
document.cookie = 'user-language=;path=/;max-age=0'
|
||||||
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('user-language', value)
|
localStorage.setItem('user-language', value)
|
||||||
|
document.cookie = `user-language=${value};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`
|
||||||
setContextLanguage(value as any)
|
setContextLanguage(value as any)
|
||||||
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
||||||
}
|
}
|
||||||
@@ -48,13 +52,17 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAutoSaveChange = async (enabled: boolean) => {
|
||||||
|
setAutoSave(enabled)
|
||||||
|
await updateAISettings({ autoSave: enabled })
|
||||||
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page title */}
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<div>
|
{t('generalSettings.description')}
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('generalSettings.title')}</h1>
|
</p>
|
||||||
<p className="text-muted-foreground mt-1">{t('generalSettings.description')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2-column card grid */}
|
{/* 2-column card grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -142,6 +150,22 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border pt-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">{t('settings.autoSave') || 'Auto-Save'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('settings.autoSaveDesc') || 'Sauvegarder automatiquement les modifications'}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={autoSave}
|
||||||
|
onClick={() => handleAutoSaveChange(!autoSave)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${autoSave ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSave ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ export default async function GeneralSettingsPage() {
|
|||||||
redirect('/api/auth/signin')
|
redirect('/api/auth/signin')
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getAISettings()
|
const {
|
||||||
|
preferredLanguage,
|
||||||
|
emailNotifications,
|
||||||
|
desktopNotifications,
|
||||||
|
autoSave,
|
||||||
|
} = await getAISettings()
|
||||||
|
|
||||||
return <GeneralSettingsClient initialSettings={settings} />
|
return (
|
||||||
|
<GeneralSettingsClient
|
||||||
|
initialSettings={{ preferredLanguage, emailNotifications, desktopNotifications, autoSave }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,30 @@ export default function SettingsLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full bg-[#F2F0E9]">
|
||||||
{/* Horizontal Tab Navigation */}
|
{/* Architectural header — matches Agents page */}
|
||||||
<header className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
|
<header className="flex flex-col px-12 pt-10 pb-0 border-b border-border/40 shrink-0">
|
||||||
<SettingsNav />
|
<div className="flex items-end justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
|
||||||
|
Paramètres
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
|
||||||
|
Configuration & Préférences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Tab nav flush to the border-bottom of header */}
|
||||||
|
<SettingsNav className="-mb-px" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
|
<div className="max-w-5xl mx-auto px-12 py-10 space-y-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ export default async function McpSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Paramètres MCP</h1>
|
Gérez vos clés API et serveurs MCP connectés.
|
||||||
<p className="text-muted-foreground mt-1">Gérez vos clés API et serveurs MCP connectés.</p>
|
</p>
|
||||||
</div>
|
|
||||||
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('profile.title')}</h1>
|
{t('profile.description')}
|
||||||
<p className="text-muted-foreground mt-1">{t('profile.description')}</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Profile info card */}
|
{/* Profile info card */}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export type UserAISettingsData = {
|
|||||||
autoLabeling?: boolean
|
autoLabeling?: boolean
|
||||||
noteHistory?: boolean
|
noteHistory?: boolean
|
||||||
noteHistoryMode?: 'manual' | 'auto'
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
fontFamily?: 'inter' | 'system'
|
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
|
||||||
|
autoSave?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
||||||
@@ -47,6 +48,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
|
|||||||
'noteHistory',
|
'noteHistory',
|
||||||
'noteHistoryMode',
|
'noteHistoryMode',
|
||||||
'fontFamily',
|
'fontFamily',
|
||||||
|
'autoSave',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
||||||
@@ -158,6 +160,7 @@ const getCachedAISettings = unstable_cache(
|
|||||||
noteHistory: false,
|
noteHistory: false,
|
||||||
noteHistoryMode: 'manual' as const,
|
noteHistoryMode: 'manual' as const,
|
||||||
fontFamily: 'inter' as const,
|
fontFamily: 'inter' as const,
|
||||||
|
autoSave: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +194,8 @@ const getCachedAISettings = unstable_cache(
|
|||||||
autoLabeling: settings.autoLabeling ?? true,
|
autoLabeling: settings.autoLabeling ?? true,
|
||||||
noteHistory: settings.noteHistory ?? false,
|
noteHistory: settings.noteHistory ?? false,
|
||||||
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||||
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'system',
|
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
|
||||||
|
autoSave: settings.autoSave ?? true,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting AI settings:', error)
|
console.error('Error getting AI settings:', error)
|
||||||
@@ -217,6 +221,7 @@ const getCachedAISettings = unstable_cache(
|
|||||||
noteHistory: false,
|
noteHistory: false,
|
||||||
noteHistoryMode: 'manual' as const,
|
noteHistoryMode: 'manual' as const,
|
||||||
fontFamily: 'inter' as const,
|
fontFamily: 'inter' as const,
|
||||||
|
autoSave: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
100
memento-note/app/actions/note-illustration.ts
Normal file
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const NOTE_LIST_SELECT = {
|
|||||||
checkItems: true,
|
checkItems: true,
|
||||||
labels: true,
|
labels: true,
|
||||||
images: true,
|
images: true,
|
||||||
|
illustrationSvg: true,
|
||||||
links: true,
|
links: true,
|
||||||
reminder: true,
|
reminder: true,
|
||||||
isReminderDone: true,
|
isReminderDone: true,
|
||||||
@@ -213,7 +214,7 @@ async function syncNoteLabels(noteId: string, labelNames: string[], notebookId:
|
|||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
|
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Delete labels not in use
|
// Delete labels not in use
|
||||||
@@ -371,16 +372,14 @@ export async function getNoteHistory(noteId: string, limit = 30) {
|
|||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return []
|
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 clampedLimit = Math.min(Math.max(limit, 1), 100)
|
||||||
|
|
||||||
const note = await prisma.note.findFirst({
|
const note = await prisma.note.findFirst({
|
||||||
where: { id: noteId, userId: session.user.id },
|
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({
|
const entries = await prisma.noteHistory.findMany({
|
||||||
where: { noteId: note.id, userId: session.user.id },
|
where: { noteId: note.id, userId: session.user.id },
|
||||||
@@ -395,13 +394,10 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
|
|||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
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([
|
const [note, historyEntry] = await Promise.all([
|
||||||
prisma.note.findFirst({
|
prisma.note.findFirst({
|
||||||
where: { id: noteId, userId: session.user.id },
|
where: { id: noteId, userId: session.user.id },
|
||||||
select: { id: true, notebookId: true },
|
select: { id: true, notebookId: true, historyEnabled: true },
|
||||||
}),
|
}),
|
||||||
prisma.noteHistory.findFirst({
|
prisma.noteHistory.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -412,9 +408,8 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!note || !historyEntry) {
|
if (!note || !note.historyEnabled) throw new Error('History is disabled for this note')
|
||||||
throw new Error('History entry not found')
|
if (!historyEntry) throw new Error('History entry not found')
|
||||||
}
|
|
||||||
|
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
@@ -686,7 +681,7 @@ export async function createNote(data: {
|
|||||||
const hasUserLabels = data.labels && data.labels.length > 0
|
const hasUserLabels = data.labels && data.labels.length > 0
|
||||||
|
|
||||||
// Use setImmediate-like pattern to not block the response
|
// Use setImmediate-like pattern to not block the response
|
||||||
;(async () => {
|
; (async () => {
|
||||||
try {
|
try {
|
||||||
// Background task 1: Generate embedding
|
// Background task 1: Generate embedding
|
||||||
const bgConfig = await getSystemConfig()
|
const bgConfig = await getSystemConfig()
|
||||||
@@ -726,7 +721,7 @@ export async function createNote(data: {
|
|||||||
if (langResult.length > 0 && langResult[0].language) {
|
if (langResult.length > 0 && langResult[0].language) {
|
||||||
userLang = langResult[0].language
|
userLang = langResult[0].language
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||||
content,
|
content,
|
||||||
@@ -789,6 +784,7 @@ export async function updateNote(id: string, data: {
|
|||||||
checkItems?: CheckItem[] | null
|
checkItems?: CheckItem[] | null
|
||||||
labels?: string[] | null
|
labels?: string[] | null
|
||||||
images?: string[] | null
|
images?: string[] | null
|
||||||
|
illustrationSvg?: string | null
|
||||||
links?: any[] | null
|
links?: any[] | null
|
||||||
reminder?: Date | null
|
reminder?: Date | null
|
||||||
isMarkdown?: boolean
|
isMarkdown?: boolean
|
||||||
@@ -823,7 +819,7 @@ export async function updateNote(id: string, data: {
|
|||||||
if (data.content !== undefined) {
|
if (data.content !== undefined) {
|
||||||
const noteId = id
|
const noteId = id
|
||||||
const content = data.content
|
const content = data.content
|
||||||
;(async () => {
|
; (async () => {
|
||||||
try {
|
try {
|
||||||
const provider = getAIProvider(await getSystemConfig());
|
const provider = getAIProvider(await getSystemConfig());
|
||||||
const embedding = await provider.getEmbeddings(content);
|
const embedding = await provider.getEmbeddings(content);
|
||||||
@@ -844,6 +840,7 @@ export async function updateNote(id: string, data: {
|
|||||||
// labels handled by syncNoteLabels below
|
// labels handled by syncNoteLabels below
|
||||||
delete updateData.labels
|
delete updateData.labels
|
||||||
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
||||||
|
if ('illustrationSvg' in data) updateData.illustrationSvg = data.illustrationSvg
|
||||||
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
||||||
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
||||||
// Explicitly handle size to ensure it propagates
|
// Explicitly handle size to ensure it propagates
|
||||||
@@ -852,16 +849,24 @@ export async function updateNote(id: string, data: {
|
|||||||
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
||||||
// (size, color, isPinned, isArchived are properties, not content)
|
// (size, color, isPinned, isArchived are properties, not content)
|
||||||
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
|
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
|
||||||
const contentFields = ['title', 'content', 'checkItems', 'images', 'links']
|
const contentFields = ['title', 'content', 'checkItems', 'images', 'links', 'illustrationSvg']
|
||||||
const isContentChange = contentFields.some(field => field in data)
|
const isContentChange = contentFields.some(field => field in data)
|
||||||
if (isContentChange && !options?.skipContentTimestamp) {
|
if (isContentChange && !options?.skipContentTimestamp) {
|
||||||
updateData.contentUpdatedAt = new Date()
|
updateData.contentUpdatedAt = new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await prisma.note.update({
|
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 },
|
where: { id, userId: session.user.id },
|
||||||
data: updateData
|
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)
|
// Sync labels (JSON + labelRelations + Label rows)
|
||||||
const notebookMoved =
|
const notebookMoved =
|
||||||
@@ -905,9 +910,15 @@ export async function updateNote(id: string, data: {
|
|||||||
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
||||||
const isStructuralChange = structuralFields.some(field => field in data)
|
const isStructuralChange = structuralFields.some(field => field in data)
|
||||||
|
|
||||||
if (isStructuralChange && !options?.skipRevalidation) {
|
console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
|
||||||
revalidatePath('/')
|
|
||||||
|
if (!options?.skipRevalidation) {
|
||||||
|
// Always revalidate note individual page on content changes so UI reflects saved data
|
||||||
revalidatePath(`/note/${id}`)
|
revalidatePath(`/note/${id}`)
|
||||||
|
revalidatePath('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStructuralChange) {
|
||||||
if (data.isArchived !== undefined) {
|
if (data.isArchived !== undefined) {
|
||||||
revalidatePath('/archive')
|
revalidatePath('/archive')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ export async function POST(req: NextRequest) {
|
|||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const { noteId, type, theme, style } = body as {
|
const { noteId, type, theme, style, language } = body as {
|
||||||
noteId: string
|
noteId: string
|
||||||
type: GenerateType
|
type: GenerateType
|
||||||
theme?: string
|
theme?: string
|
||||||
style?: string
|
style?: string
|
||||||
|
language?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
||||||
@@ -50,15 +51,26 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaults = TYPE_DEFAULTS[type]
|
const defaults = TYPE_DEFAULTS[type]
|
||||||
|
const isEn = language === 'English'
|
||||||
|
|
||||||
|
let role = defaults.role
|
||||||
|
if (isEn) {
|
||||||
|
if (type === 'slide-generator') {
|
||||||
|
role = 'Create a professional and visual PowerPoint presentation from the provided note content.'
|
||||||
|
} else {
|
||||||
|
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const agentName = type === 'slide-generator'
|
const agentName = type === 'slide-generator'
|
||||||
? `Slides — ${(note.title || 'Note').substring(0, 40)}`
|
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}`
|
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
|
|
||||||
const agent = await prisma.agent.create({
|
const agent = await prisma.agent.create({
|
||||||
data: {
|
data: {
|
||||||
name: agentName,
|
name: agentName,
|
||||||
type,
|
type,
|
||||||
role: defaults.role,
|
role,
|
||||||
tools: JSON.stringify(defaults.tools),
|
tools: JSON.stringify(defaults.tools),
|
||||||
maxSteps: defaults.maxSteps,
|
maxSteps: defaults.maxSteps,
|
||||||
frequency: 'one-shot',
|
frequency: 'one-shot',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { existingContent, resourceText, mode, language } = await request.json()
|
const { existingContent, resourceText, mode, language, format } = await request.json()
|
||||||
|
|
||||||
if (!resourceText || typeof resourceText !== 'string') {
|
if (!resourceText || typeof resourceText !== 'string') {
|
||||||
return NextResponse.json({ error: 'resourceText is required' }, { status: 400 })
|
return NextResponse.json({ error: 'resourceText is required' }, { status: 400 })
|
||||||
@@ -20,6 +20,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lang = language || 'fr'
|
const lang = language || 'fr'
|
||||||
|
const outputFormat = format === 'html' ? 'HTML (with proper tags like <h2>, <p>, <ul>, <li>)' : 'Markdown (with ##, -, **, etc.)'
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getTagsProvider(config)
|
const provider = getTagsProvider(config)
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
|
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
|
||||||
|
|
||||||
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
||||||
|
FORMAT RULE: Respond in ${outputFormat}.
|
||||||
|
|
||||||
EXISTING NOTE:
|
EXISTING NOTE:
|
||||||
---
|
---
|
||||||
@@ -46,13 +48,14 @@ INSTRUCTIONS:
|
|||||||
- Append ONLY new, non-redundant information from the resource below the existing content
|
- Append ONLY new, non-redundant information from the resource below the existing content
|
||||||
- Use a clear separator (e.g., "---" or a new section heading) between existing and new content
|
- Use a clear separator (e.g., "---" or a new section heading) between existing and new content
|
||||||
- Skip information already covered in the existing note
|
- Skip information already covered in the existing note
|
||||||
- Format the new content consistently with the existing note style
|
- Format the new content consistently with the existing note style and the requested FORMAT RULE
|
||||||
- Respond ONLY with the enriched note content, no explanations`
|
- Respond ONLY with the enriched note content, no explanations`
|
||||||
} else {
|
} else {
|
||||||
// Merge: intelligently rewrite integrating both sources
|
// Merge: intelligently rewrite integrating both sources
|
||||||
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
|
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
|
||||||
|
|
||||||
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
||||||
|
FORMAT RULE: Respond in ${outputFormat}.
|
||||||
|
|
||||||
EXISTING NOTE:
|
EXISTING NOTE:
|
||||||
---
|
---
|
||||||
@@ -69,7 +72,7 @@ INSTRUCTIONS:
|
|||||||
- Eliminate redundancy — include each piece of information only once
|
- Eliminate redundancy — include each piece of information only once
|
||||||
- Preserve the key ideas from both sources
|
- Preserve the key ideas from both sources
|
||||||
- Maintain a logical structure with clear headings if appropriate
|
- Maintain a logical structure with clear headings if appropriate
|
||||||
- Keep the tone and style consistent
|
- Keep the tone and style consistent with the requested FORMAT RULE
|
||||||
- Respond ONLY with the merged content, no meta-commentary or explanations`
|
- Respond ONLY with the merged content, no meta-commentary or explanations`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||||
import { toolRegistry } from '@/lib/ai/tools'
|
import { toolRegistry } from '@/lib/ai/tools'
|
||||||
import { stepCountIs } from 'ai'
|
|
||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
@@ -47,36 +46,32 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
|
// 2. Parse request body
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
|
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext, format } = body as {
|
||||||
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext } = body as {
|
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
conversationId?: string
|
conversationId?: string
|
||||||
notebookId?: string
|
notebookId?: string
|
||||||
language?: string
|
language?: string
|
||||||
webSearch?: boolean
|
webSearch?: boolean
|
||||||
noteContext?: { title: string; content: string; tone: string; images?: string[] }
|
noteContext?: { title: string; content: string; tone: string; images?: string[] }
|
||||||
|
format?: 'html' | 'markdown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert UIMessages to CoreMessages for streamText
|
|
||||||
const incomingMessages = toCoreMessages(rawMessages)
|
const incomingMessages = toCoreMessages(rawMessages)
|
||||||
|
|
||||||
// 3. Manage conversation (create or fetch)
|
// 3. Manage conversation
|
||||||
let conversation: { id: string; messages: Array<{ role: string; content: string }> }
|
let conversation: { id: string; messages: Array<{ role: string; content: string }> }
|
||||||
|
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
const existing = await prisma.conversation.findUnique({
|
const existing = await prisma.conversation.findUnique({
|
||||||
where: { id: conversationId, userId },
|
where: { id: conversationId, userId },
|
||||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||||
})
|
})
|
||||||
if (!existing) {
|
if (!existing) return new Response('Conversation not found', { status: 404 })
|
||||||
return new Response('Conversation not found', { status: 404 })
|
|
||||||
}
|
|
||||||
conversation = existing
|
conversation = existing
|
||||||
} else {
|
} else {
|
||||||
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
|
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
|
||||||
const created = await prisma.conversation.create({
|
conversation = await prisma.conversation.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
notebookId: notebookId || null,
|
notebookId: notebookId || null,
|
||||||
@@ -84,33 +79,21 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
include: { messages: true },
|
include: { messages: true },
|
||||||
})
|
})
|
||||||
conversation = created
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. RAG retrieval
|
// 4. RAG retrieval
|
||||||
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
|
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
|
||||||
|
|
||||||
// Load translations for the requested language
|
|
||||||
const lang = (language || 'en') as SupportedLanguage
|
const lang = (language || 'en') as SupportedLanguage
|
||||||
const translations = await loadTranslations(lang)
|
const translations = await loadTranslations(lang)
|
||||||
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
|
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
|
||||||
|
|
||||||
// If a notebook is selected, fetch its recent notes directly as context
|
|
||||||
// This ensures the AI always has access to the notebook content,
|
|
||||||
// even for vague queries like "what's in this notebook?"
|
|
||||||
let notebookContext = ''
|
let notebookContext = ''
|
||||||
let searchNotes = ''
|
let searchNotes = ''
|
||||||
|
|
||||||
// When scope is "this note" (noteContext present), skip RAG retrieval entirely
|
|
||||||
// The note content is already injected as copilotContext below
|
|
||||||
if (!noteContext) {
|
if (!noteContext) {
|
||||||
if (notebookId) {
|
if (notebookId) {
|
||||||
const notebookNotes = await prisma.note.findMany({
|
const notebookNotes = await prisma.note.findMany({
|
||||||
where: {
|
where: { notebookId, userId, trashedAt: null },
|
||||||
notebookId,
|
|
||||||
userId,
|
|
||||||
trashedAt: null,
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
take: 20,
|
take: 20,
|
||||||
select: { id: true, title: true, content: true, updatedAt: true },
|
select: { id: true, title: true, content: true, updatedAt: true },
|
||||||
@@ -122,7 +105,6 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also run semantic search for the specific query
|
|
||||||
let searchResults: any[] = []
|
let searchResults: any[] = []
|
||||||
try {
|
try {
|
||||||
searchResults = await semanticSearchService.search(currentMessage, {
|
searchResults = await semanticSearchService.search(currentMessage, {
|
||||||
@@ -131,21 +113,16 @@ export async function POST(req: Request) {
|
|||||||
threshold: notebookId ? 0.3 : 0.5,
|
threshold: notebookId ? 0.3 : 0.5,
|
||||||
defaultTitle: untitledText,
|
defaultTitle: untitledText,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {}
|
||||||
// Search failure should not block chat
|
|
||||||
}
|
|
||||||
|
|
||||||
searchNotes = searchResults
|
searchNotes = searchResults
|
||||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||||||
.join('\n\n---\n\n')
|
.join('\n\n---\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine: full notebook context + semantic search results (deduplicated)
|
|
||||||
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
||||||
|
|
||||||
// 5. System prompt synthesis with RAG context
|
// 5. System prompt synthesis
|
||||||
// Language-aware prompts to avoid forcing French responses
|
|
||||||
// Note: lang is already declared above when loading translations
|
|
||||||
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
|
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
|
||||||
en: {
|
en: {
|
||||||
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
||||||
@@ -153,14 +130,24 @@ export async function POST(req: Request) {
|
|||||||
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
|
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
|
||||||
|
|
||||||
## Format rules
|
## Format rules
|
||||||
- Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.
|
- ${format === 'html' ? `Respond MANDATORILY using valid HTML fragments (e.g., <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||||||
|
- Do NOT use Markdown symbols (no #, *, -, etc.).
|
||||||
|
- Do not wrap your HTML code in a Markdown code block.` : 'Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.'}
|
||||||
- Structure your response with sections for technical questions or complex topics.
|
- Structure your response with sections for technical questions or complex topics.
|
||||||
- For simple, short questions, a direct paragraph is enough.
|
- For simple, short questions, a direct paragraph is enough.` + (format === 'html' ? `
|
||||||
|
|
||||||
|
## HTML OUTPUT EXAMPLE
|
||||||
|
<h3>Section Title</h3>
|
||||||
|
<p>Here is an explanation with <strong>bold text</strong> and a list:</p>
|
||||||
|
<ul>
|
||||||
|
<li>First important point</li>
|
||||||
|
<li>Second important point</li>
|
||||||
|
</ul>` : '') + `
|
||||||
|
|
||||||
## Tone rules
|
## Tone rules
|
||||||
- Natural tone, neither corporate nor too casual.
|
- Natural tone, neither corporate nor too casual.
|
||||||
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly.
|
- No unnecessary intro phrases. Answer directly.
|
||||||
- No upsell questions at the end ("Would you like me to...", "Do you want..."). If you have useful additional info, just give it.
|
- No upsell questions at the end. If you have useful additional info, just give it.
|
||||||
- If the user says "Momento" they mean Momento (this app).
|
- If the user says "Momento" they mean Momento (this app).
|
||||||
|
|
||||||
## About Momento
|
## About Momento
|
||||||
@@ -170,171 +157,90 @@ Momento is an intelligent note-taking application. Key features include:
|
|||||||
- **Search**: Advanced semantic search to find notes by meaning, not just keywords, and Web Search integration.
|
- **Search**: Advanced semantic search to find notes by meaning, not just keywords, and Web Search integration.
|
||||||
- **Agents**: Create specialized AI Agents with custom system prompts for specific recurring tasks.
|
- **Agents**: Create specialized AI Agents with custom system prompts for specific recurring tasks.
|
||||||
- **Lab**: Experimental AI tools for data analysis and deeper insights.
|
- **Lab**: Experimental AI tools for data analysis and deeper insights.
|
||||||
If the user asks how to use this tool, explain these features simply and helpfully.
|
|
||||||
|
|
||||||
## Available tools
|
## Available tools
|
||||||
You have access to these tools for deeper research:
|
You have access to: note_search, note_read, web_search, web_scrape.
|
||||||
- **note_search**: Search the user's notes by keyword or meaning. Use when the initial context above is insufficient or when the user asks about specific content in their notes. If a notebook is selected, pass its ID to restrict results.
|
Only use tools if you need more information. Never invent note IDs or URLs.`,
|
||||||
- **note_read**: Read a specific note by ID. Use when note_search returns a note you need the full content of.
|
|
||||||
- **web_search**: Search the web for information. Use when the user asks about something not in their notes.
|
|
||||||
- **web_scrape**: Scrape a web page and return its content as markdown. Use when web_search returns a URL you need to read.
|
|
||||||
|
|
||||||
## Tool usage rules
|
|
||||||
- You already have context from the user's notes above. Only use tools if you need more specific or additional information.
|
|
||||||
- Never invent note IDs, URLs, or notebook IDs. Use the IDs provided in the context or from tool results.
|
|
||||||
- For simple conversational questions (greetings, opinions, general knowledge), answer directly without using any tools.`,
|
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule. Si les notes ne couvrent pas le sujet, dis-le et complète avec tes connaissances générales.`,
|
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule.`,
|
||||||
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||||
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
|
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
|
||||||
|
|
||||||
## Règles de format
|
## Règles de format
|
||||||
- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables — tout ce qui rend la réponse lisible.
|
- ${format === 'html' ? `Réponds OBLIGATOIREMENT en utilisant des fragments HTML valides (ex: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||||||
|
- N'utilise PAS de symboles Markdown.
|
||||||
|
- Ne mets pas ton code HTML dans un bloc de code Markdown.` : '- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables.'}
|
||||||
- Structure ta réponse avec des sections quand c'est une question technique ou un sujet complexe.
|
- Structure ta réponse avec des sections quand c'est une question technique ou un sujet complexe.
|
||||||
- Pour les questions simples et courtes, un paragraphe direct suffit.
|
- Pour les questions simples et courtes, un paragraphe direct suffit.` + (format === 'html' ? `
|
||||||
|
|
||||||
|
## EXEMPLE DE SORTIE HTML
|
||||||
|
<h3>Titre de section</h3>
|
||||||
|
<p>Voici une explication avec du <strong>texte en gras</strong> et une liste :</p>
|
||||||
|
<ul>
|
||||||
|
<li>Premier point important</li>
|
||||||
|
<li>Deuxième point important</li>
|
||||||
|
</ul>` : '') + `
|
||||||
|
|
||||||
## Règles de ton
|
## Règles de ton
|
||||||
- Ton naturel, ni corporate ni trop familier.
|
- Ton naturel, direct, sans phrases d'intro inutiles.
|
||||||
- Pas de phrase d'intro inutile ("Voici ce que j'ai trouvé", "Basé sur vos notes"). Réponds directement.
|
- Pas de question upsell à la fin.
|
||||||
- Pas de question upsell à la fin ("Souhaitez-vous que je...", "Acceptez-vous que..."). Si tu as une info complémentaire utile, donne-la.
|
|
||||||
- Si l'utilisateur dit "Momento" il parle de Momento (cette application).
|
- Si l'utilisateur dit "Momento" il parle de Momento (cette application).
|
||||||
|
|
||||||
## À propos de Momento
|
## À propos de Momento
|
||||||
Momento est une application de prise de notes intelligente. Ses fonctionnalités principales :
|
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||||||
- **Éditeur de notes** : Prise de notes en Markdown riche avec un Copilot IA intégré pour réécrire, résumer ou traduire du texte.
|
|
||||||
- **Organisation** : Regroupement des notes dans des Carnets (Notebooks) et utilisation d'Étiquettes (Labels).
|
|
||||||
- **Recherche** : Recherche sémantique avancée pour trouver des notes par le sens, et recherche Web intégrée.
|
|
||||||
- **Agents** : Création d'Agents IA spécialisés avec des instructions personnalisées pour des tâches récurrentes.
|
|
||||||
- **Lab** : Outils IA expérimentaux pour l'analyse de données et les insights.
|
|
||||||
Si l'utilisateur demande comment utiliser cet outil, explique ces fonctionnalités simplement et avec bienveillance.
|
|
||||||
|
|
||||||
## Outils disponibles
|
## Outils disponibles
|
||||||
Tu as accès à ces outils pour des recherches approfondies :
|
Tu as accès à : note_search, note_read, web_search, web_scrape.`,
|
||||||
- **note_search** : Cherche dans les notes de l'utilisateur par mot-clé ou sens. Utilise quand le contexte initial ci-dessus est insuffisant ou quand l'utilisateur demande du contenu spécifique dans ses notes. Si un carnet est sélectionné, passe son ID pour restreindre les résultats.
|
|
||||||
- **note_read** : Lit une note spécifique par son ID. Utilise quand note_search retourne une note dont tu as besoin du contenu complet.
|
|
||||||
- **web_search** : Recherche sur le web. Utilise quand l'utilisateur demande quelque chose qui n'est pas dans ses notes.
|
|
||||||
- **web_scrape** : Scrape une page web et retourne son contenu en markdown. Utilise quand web_search retourne une URL que tu veux lire.
|
|
||||||
|
|
||||||
## Règles d'utilisation des outils
|
|
||||||
- Tu as déjà du contexte des notes de l'utilisateur ci-dessus. Utilise les outils seulement si tu as besoin d'informations plus spécifiques.
|
|
||||||
- N'invente jamais d'IDs de notes, d'URLs ou d'IDs de carnet. Utilise les IDs fournis dans le contexte ou les résultats d'outils.
|
|
||||||
- Pour les questions conversationnelles simples (salutations, opinions, connaissances générales), réponds directement sans utiliser d'outils.`,
|
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشتها موضوع را پوشش نمیدهند، بگویید و با دانش عمومی خود تکمیل کنید.`,
|
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,
|
||||||
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||||||
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژهها، مستندات فنی و یادداشتهایش سؤال میکند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
|
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژهها، مستندات فنی و یادداشتهایش سؤال میکند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
|
||||||
|
|
||||||
## قوانین قالببندی
|
## قوانین قالببندی
|
||||||
- از مارکداون آزادانه استفاده کنید: عناوین (##, ###)، لیستها، بلوکهای کد، پررنگ، جداول.
|
- ${format === 'html' ? `حتماً از تگهای HTML معتبر استفاده کنید (مانند <p>, <strong>, <em>, <ul>, <li>, <h3>).
|
||||||
|
- از نمادهای مارکداون استفاده نکنید.` : 'از مارکداون آزادانه استفاده کنید: عناوین (##, ###)، لیستها، بلوکهای کد، پررنگ، جداول.'}
|
||||||
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخشبندی کنید.
|
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخشبندی کنید.
|
||||||
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.
|
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.` + (format === 'html' ? `
|
||||||
|
|
||||||
|
## نمونه خروجی HTML
|
||||||
|
<h3>عنوان بخش</h3>
|
||||||
|
<p>این یک توضیح با <strong>متن برجسته</strong> و یک لیست است:</p>
|
||||||
|
<ul>
|
||||||
|
<li>نکته اول</li>
|
||||||
|
<li>نکته دوم</li>
|
||||||
|
</ul>` : '') + `
|
||||||
|
|
||||||
## قوانین لحن
|
## قوانین لحن
|
||||||
- لحن طبیعی، نه رسمی بیش از حد و نه خیلی غیررسمی.
|
- لحن طبیعی، مستقیم، بدون مقدمه اضافی.
|
||||||
- بدون جمله مقدمه اضافی. مستقیم پاسخ دهید.
|
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||||
- بدون سؤال فروشی در انتها. اگر اطلاعات تکمیلی مفید دارید، مستقیم بدهید.
|
|
||||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.
|
|
||||||
|
|
||||||
## ابزارهای موجود
|
|
||||||
- **note_search**: جستجو در یادداشتهای کاربر با کلیدواژه یا معنی. زمانی استفاده کنید که زمینه اولیه کافی نباشد. اگر دفترچه انتخاب شده، شناسه آن را ارسال کنید.
|
|
||||||
- **note_read**: خواندن یک یادداشت خاص با شناسه. زمانی استفاده کنید که note_search یادداشتی برگرداند که محتوای کامل آن را نیاز دارید.
|
|
||||||
- **web_search**: جستجو در وب. زمانی استفاده کنید که کاربر درباره چیزی خارج از یادداشتهایش میپرسد.
|
|
||||||
- **web_scrape**: استخراج محتوای صفحه وب. زمانی استفاده کنید که web_search نشانیای برگرداند که میخواهید بخوانید.
|
|
||||||
|
|
||||||
## قوانین استفاده از ابزارها
|
|
||||||
- شما از قبل زمینهای از یادداشتهای کاربر دارید. فقط در صورت نیاز به اطلاعات بیشتر از ابزارها استفاده کنید.
|
|
||||||
- هرگز شناسه یادداشت، نشانی یا شناسه دفترچه نسازید. از شناسههای موجود در زمینه یا نتایج ابزار استفاده کنید.
|
|
||||||
- برای سؤالات مکالمهای ساده (سلام، نظرات، دانش عمومی)، مستقیم پاسخ دهید.`,
|
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis. No copies palabra por palabra — reformula. Si las notas no cubren el tema, dilo y complementa con tu conocimiento general.`,
|
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis.`,
|
||||||
contextNoNotes: "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
|
contextNoNotes: "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
|
||||||
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas. Debes responder de forma estructurada y útil.
|
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas.
|
||||||
|
|
||||||
## Reglas de formato
|
## Reglas de formato
|
||||||
- Usa markdown libremente: títulos (##, ###), listas, bloques de código, negritas, tablas.
|
- ${format === 'html' ? `Responde OBLIGATORIAMENTE usando fragmentos HTML válidos (ej: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||||||
- Estructura tu respuesta con secciones para preguntas técnicas o temas complejos.
|
- NO uses símbolos Markdown.` : 'Usa markdown libremente: títulos (##, ###), listas, negritas, tablas.'}
|
||||||
- Para preguntas simples y cortas, un párrafo directo es suficiente.
|
- Estructura tu respuesta con secciones para temas complejos.
|
||||||
|
- Para preguntas simples, un párrafo directo es suficiente.` + (format === 'html' ? `
|
||||||
|
|
||||||
## Reglas de tono
|
## EJEMPLO DE SALIDA HTML
|
||||||
- Tono natural, ni corporativo ni demasiado informal.
|
<h3>Título de sección</h3>
|
||||||
- Sin frases de introducción innecesarias. Responde directamente.
|
<p>Aquí hay una explicación con <strong>texto en negrita</strong> y una lista:</p>
|
||||||
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente.
|
<ul>
|
||||||
|
<li>Primer punto importante</li>
|
||||||
## Herramientas disponibles
|
<li>Segundo punto importante</li>
|
||||||
- **note_search**: Busca en las notas del usuario por palabra clave o significado. Úsalo cuando el contexto inicial sea insuficiente. Si hay una libreta seleccionada, pasa su ID para restringir los resultados.
|
</ul>` : ''),
|
||||||
- **note_read**: Lee una nota específica por su ID. Úsalo cuando note_search devuelva una nota cuyo contenido completo necesites.
|
|
||||||
- **web_search**: Busca en la web. Úsalo cuando el usuario pregunte sobre algo que no está en sus notas.
|
|
||||||
- **web_scrape**: Extrae el contenido de una página web como markdown. Úsalo cuando web_search devuelva una URL que quieras leer.
|
|
||||||
|
|
||||||
## Reglas de uso de herramientas
|
|
||||||
- Ya tienes contexto de las notas del usuario arriba. Solo usa herramientas si necesitas información más específica.
|
|
||||||
- Nunca inventes IDs de notas, URLs o IDs de libreta. Usa los IDs proporcionados en el contexto o en los resultados de herramientas.
|
|
||||||
- Para preguntas conversacionales simples (saludos, opiniones, conocimiento general), responde directamente sin herramientas.`,
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
contextWithNotes: `## Notizen des Benutzers\n\n${contextNotes}\n\nWenn du Infos aus den obigen Notizen verwendest, zitiere den Titel der Quellnotiz in Klammern. Nicht Wort für Wort kopieren — umformulieren. Wenn die Notizen das Thema nicht abdecken, sag es und ergänze mit deinem Allgemeinwissen.`,
|
|
||||||
contextNoNotes: "Keine relevanten Notizen für diese Frage gefunden. Antworte mit deinem Allgemeinwissen.",
|
|
||||||
system: `Du bist der KI-Assistent von Memento. Der Benutzer stellt dir Fragen zu seinen Projekten, technischen Dokumentationen und Notizen. Du musst strukturiert und hilfreich antworten.
|
|
||||||
|
|
||||||
## Formatregeln
|
|
||||||
- Verwende Markdown frei: Überschriften (##, ###), Listen, Code-Blöcke, Fettdruck, Tabellen.
|
|
||||||
- Strukturiere deine Antwort mit Abschnitten bei technischen Fragen oder komplexen Themen.
|
|
||||||
- Bei einfachen, kurzen Fragen reicht ein direkter Absatz.
|
|
||||||
|
|
||||||
## Tonregeln
|
|
||||||
- Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich.
|
|
||||||
- Keine unnötigen Einleitungssätze. Antworte direkt.
|
|
||||||
- Keine Upsell-Fragen am Ende. Gib nützliche Zusatzinfos einfach direkt.
|
|
||||||
|
|
||||||
## Verfügbare Werkzeuge
|
|
||||||
- **note_search**: Durchsuche die Notizen des Benutzers nach Schlagwort oder Bedeutung. Verwende es, wenn der obige Kontext unzureichend ist. Wenn ein Notizbuch ausgewählt ist, gib dessen ID an, um die Ergebnisse einzuschränken.
|
|
||||||
- **note_read**: Lese eine bestimmte Notiz anhand ihrer ID. Verwende es, wenn note_search eine Notiz zurückgibt, deren vollständigen Inhalt du benötigst.
|
|
||||||
- **web_search**: Suche im Web. Verwende es, wenn der Benutzer nach etwas fragt, das nicht in seinen Notizen steht.
|
|
||||||
- **web_scrape**: Lese eine Webseite und gib den Inhalt als Markdown zurück. Verwende es, wenn web_search eine URL zurückgibt, die du lesen möchtest.
|
|
||||||
|
|
||||||
## Werkzeugregeln
|
|
||||||
- Du hast bereits Kontext aus den Notizen des Benutzers oben. Verwende Werkzeuge nur, wenn du spezifischere Informationen benötigst.
|
|
||||||
- Erfinde niemals Notiz-IDs, URLs oder Notizbuch-IDs. Verwende die im Kontext oder in Werkzeugergebnissen bereitgestellten IDs.
|
|
||||||
- Bei einfachen Gesprächsfragen (Begrüßungen, Meinungen, Allgemeinwissen) antworte direkt ohne Werkzeuge.`,
|
|
||||||
},
|
|
||||||
it: {
|
|
||||||
contextWithNotes: `## Note dell'utente\n\n${contextNotes}\n\nQuando usi informazioni dalle note sopra, cita il titolo della nota fonte tra parentesi. Non copiare parola per parola — riformula. Se le note non coprono l'argomento, dillo e integra con la tua conoscenza generale.`,
|
|
||||||
contextNoNotes: "Nessuna nota rilevante trovata per questa domanda. Rispondi con la tua conoscenza generale.",
|
|
||||||
system: `Sei l'assistente IA di Memento. L'utente ti fa domande sui suoi progetti, documentazione tecnica e note. Devi rispondere in modo strutturato e utile.
|
|
||||||
|
|
||||||
## Regole di formato
|
|
||||||
- Usa markdown liberamente: titoli (##, ###), elenchi, blocchi di codice, grassetto, tabelle.
|
|
||||||
- Struttura la risposta con sezioni per domande tecniche o argomenti complessi.
|
|
||||||
- Per domande semplici e brevi, un paragrafo diretto basta.
|
|
||||||
|
|
||||||
## Regole di tono
|
|
||||||
- Tono naturale, né aziendale né troppo informale.
|
|
||||||
- Nessuna frase introduttiva non necessaria. Rispondi direttamente.
|
|
||||||
- Nessuna domanda di upsell alla fine. Se hai informazioni aggiuntive utili, dalle direttamente.
|
|
||||||
|
|
||||||
## Strumenti disponibili
|
|
||||||
- **note_search**: Cerca nelle note dell'utente per parola chiave o significato. Usa quando il contesto iniziale è insufficiente. Se un quaderno è selezionato, passa il suo ID per restringere i risultati.
|
|
||||||
- **note_read**: Leggi una nota specifica per ID. Usa quando note_search restituisce una nota di cui hai bisogno del contenuto completo.
|
|
||||||
- **web_search**: Cerca sul web. Usa quando l'utente chiede qualcosa che non è nelle sue note.
|
|
||||||
- **web_scrape**: Estrai il contenuto di una pagina web come markdown. Usa quando web_search restituisce un URL che vuoi leggere.
|
|
||||||
|
|
||||||
## Regole di utilizzo degli strumenti
|
|
||||||
- Hai già contesto dalle note dell'utente sopra. Usa gli strumenti solo se hai bisogno di informazioni più specifiche.
|
|
||||||
- Non inventare mai ID di note, URL o ID di quaderno. Usa gli ID forniti nel contesto o nei risultati degli strumenti.
|
|
||||||
- Per domande conversazionali semplici (saluti, opinioni, conoscenza generale), rispondi direttamente senza strumenti.`,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to English if language not supported
|
|
||||||
const prompts = promptLang[lang] || promptLang.en
|
const prompts = promptLang[lang] || promptLang.en
|
||||||
const contextBlock = contextNotes.length > 0
|
const contextBlock = contextNotes.length > 0 ? prompts.contextWithNotes : prompts.contextNoNotes
|
||||||
? prompts.contextWithNotes
|
|
||||||
: prompts.contextNoNotes
|
|
||||||
|
|
||||||
// Load note images as base64 for vision-capable models
|
// Load note images for vision
|
||||||
let imageContextParts: Array<{ type: 'image'; image: string }> = []
|
let imageContextParts: Array<{ type: 'image'; image: string }> = []
|
||||||
if (noteContext?.images && noteContext.images.length > 0) {
|
if (noteContext?.images && noteContext.images.length > 0) {
|
||||||
for (const imgPath of noteContext.images.slice(0, 4)) {
|
for (const imgPath of noteContext.images.slice(0, 4)) {
|
||||||
@@ -343,8 +249,7 @@ Tu as accès à ces outils pour des recherches approfondies :
|
|||||||
const buffer = await readFile(fullPath)
|
const buffer = await readFile(fullPath)
|
||||||
const ext = path.extname(imgPath).toLowerCase()
|
const ext = path.extname(imgPath).toLowerCase()
|
||||||
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
|
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
|
||||||
const base64 = `data:${mime};base64,${buffer.toString('base64')}`
|
imageContextParts.push({ type: 'image', image: `data:${mime};base64,${buffer.toString('base64')}` })
|
||||||
imageContextParts.push({ type: 'image', image: base64 })
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,113 +257,37 @@ Tu as accès à ces outils pour des recherches approfondies :
|
|||||||
let copilotContext = ''
|
let copilotContext = ''
|
||||||
if (noteContext) {
|
if (noteContext) {
|
||||||
copilotContext = `\n\n## Current Note Context
|
copilotContext = `\n\n## Current Note Context
|
||||||
You are currently helping the user edit a specific note. Here is the current content of the note:
|
You are helping the user edit a specific note: ${noteContext.title || 'Untitled'}.
|
||||||
Title: ${noteContext.title || 'Untitled'}
|
Tone: ${noteContext.tone || 'professional'}.
|
||||||
|
Content: ${noteContext.content || '(empty)'}
|
||||||
Content:
|
Focus ONLY on this note unless asked otherwise.`
|
||||||
${noteContext.content || '(empty)'}
|
|
||||||
${imageContextParts.length > 0 ? `\nImages: ${imageContextParts.length} image(s) attached. When the user asks about images, describe what you see in them.` : ''}
|
|
||||||
|
|
||||||
The user wants you to write in a **${noteContext.tone || 'professional'}** tone.
|
|
||||||
IMPORTANT: Focus ONLY on this note. Do NOT reference other notes or external information unless the user explicitly asks. Your job is to help with this specific note — suggest rewrites, answer questions about it, or draft new sections.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = `${prompts.system}
|
const systemPrompt = `${prompts.system}\n${copilotContext}\n\n${contextBlock}\n\n## LANGUAGE RULE (MANDATORY)\nYou MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : 'English'}.`
|
||||||
${copilotContext}
|
|
||||||
|
|
||||||
${contextBlock}
|
// 6. Execute stream
|
||||||
|
const sysConfig = await getSystemConfig()
|
||||||
## LANGUAGE RULE (MANDATORY)
|
|
||||||
You MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : lang === 'de' ? 'German' : lang === 'it' ? 'Italian' : 'English'}.
|
|
||||||
Never switch to another language. Even if the user writes in a different language, respond in the configured language.`
|
|
||||||
|
|
||||||
// 6. Build message history from DB + current messages
|
|
||||||
const dbHistory = conversation.messages.map((m: { role: string; content: string }) => ({
|
|
||||||
role: m.role as 'user' | 'assistant' | 'system',
|
|
||||||
content: m.content,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Only add the current user message if it's not already in DB history
|
|
||||||
const lastIncoming = incomingMessages[incomingMessages.length - 1]
|
|
||||||
const currentDbMessage = dbHistory[dbHistory.length - 1]
|
|
||||||
const isNewMessage =
|
|
||||||
lastIncoming &&
|
|
||||||
(!currentDbMessage ||
|
|
||||||
currentDbMessage.role !== 'user' ||
|
|
||||||
currentDbMessage.content !== lastIncoming.content)
|
|
||||||
|
|
||||||
let allMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string | Array<any> }> = isNewMessage
|
|
||||||
? [...dbHistory, { role: lastIncoming.role, content: lastIncoming.content }]
|
|
||||||
: dbHistory
|
|
||||||
|
|
||||||
// Inject note images as a context message for vision models
|
|
||||||
if (imageContextParts.length > 0) {
|
|
||||||
allMessages = [
|
|
||||||
{ role: 'user', content: [{ type: 'text' as const, text: '[Attached note images — use these when the user asks about images]' }, ...imageContextParts] },
|
|
||||||
{ role: 'assistant', content: 'Understood. I can see the attached images and will describe or analyze them when asked.' },
|
|
||||||
...allMessages,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sliding window: keep first 2 messages (context) + last 48 to avoid context overflow
|
|
||||||
const WINDOW = 50
|
|
||||||
if (allMessages.length > WINDOW) {
|
|
||||||
allMessages = [...allMessages.slice(0, 2), ...allMessages.slice(-(WINDOW - 2))]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Get chat provider model
|
|
||||||
const config = await getSystemConfig()
|
|
||||||
const provider = getChatProvider(config)
|
|
||||||
const model = provider.getModel()
|
|
||||||
|
|
||||||
// 7b. Build chat tools
|
|
||||||
const chatToolContext = {
|
|
||||||
userId,
|
|
||||||
conversationId: conversation.id,
|
|
||||||
notebookId,
|
|
||||||
webSearch: !!webSearch,
|
|
||||||
config,
|
|
||||||
}
|
|
||||||
// When scoped to "this note", only provide web tools — no note_search/note_read
|
|
||||||
// to prevent the AI from pulling information from other notes
|
|
||||||
const chatTools = noteContext
|
const chatTools = noteContext
|
||||||
? toolRegistry.buildToolsForChat({ ...chatToolContext, webOnly: true })
|
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
|
||||||
: toolRegistry.buildToolsForChat(chatToolContext)
|
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch })
|
||||||
|
|
||||||
// 8. Save user message to DB before streaming
|
const provider = getChatProvider(sysConfig)
|
||||||
if (isNewMessage && lastIncoming) {
|
const result = await streamText({
|
||||||
|
model: provider.getModel(),
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: incomingMessages,
|
||||||
|
tools: chatTools,
|
||||||
|
maxSteps: 5,
|
||||||
|
onFinish: async (final) => {
|
||||||
|
const userContent = incomingMessages[incomingMessages.length - 1].content
|
||||||
await prisma.chatMessage.create({
|
await prisma.chatMessage.create({
|
||||||
data: {
|
data: { conversationId: conversation.id, role: 'user', content: userContent }
|
||||||
conversationId: conversation.id,
|
})
|
||||||
role: 'user',
|
await prisma.chatMessage.create({
|
||||||
content: lastIncoming.content,
|
data: { conversationId: conversation.id, role: 'assistant', content: final.text }
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Stream response
|
|
||||||
const result = streamText({
|
|
||||||
model,
|
|
||||||
system: systemPrompt,
|
|
||||||
messages: allMessages as any,
|
|
||||||
tools: chatTools,
|
|
||||||
stopWhen: stepCountIs(5),
|
|
||||||
async onFinish({ text }) {
|
|
||||||
// Save assistant message to DB after streaming completes
|
|
||||||
await prisma.chatMessage.create({
|
|
||||||
data: {
|
|
||||||
conversationId: conversation.id,
|
|
||||||
role: 'assistant',
|
|
||||||
content: text,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 10. Return streaming response with conversation ID header
|
return result.toUIMessageStreamResponse()
|
||||||
return result.toUIMessageStreamResponse({
|
|
||||||
headers: {
|
|
||||||
'X-Conversation-Id': conversation.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ import Script from "next/script";
|
|||||||
import { getThemeScript } from "@/lib/theme-script";
|
import { getThemeScript } from "@/lib/theme-script";
|
||||||
import { normalizeThemeId } from "@/lib/apply-document-theme";
|
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({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -30,6 +30,12 @@ const playfair = Playfair_Display({
|
|||||||
weight: ["400", "500", "600", "700"],
|
weight: ["400", "500", "600", "700"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
weight: ["400", "500"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Memento - Your Digital Notepad",
|
title: "Memento - Your Digital Notepad",
|
||||||
description: "A beautiful note-taking app built with Next.js 16",
|
description: "A beautiful note-taking app built with Next.js 16",
|
||||||
@@ -69,6 +75,10 @@ const directionScript = `
|
|||||||
(function(){
|
(function(){
|
||||||
try {
|
try {
|
||||||
var lang = localStorage.getItem('user-language');
|
var lang = localStorage.getItem('user-language');
|
||||||
|
if (!lang) {
|
||||||
|
var c = document.cookie.split(';').map(function(s){return s.trim()}).find(function(s){return s.startsWith('user-language=')});
|
||||||
|
if (c) lang = c.split('=')[1];
|
||||||
|
}
|
||||||
if (lang === 'fa' || lang === 'ar') {
|
if (lang === 'fa' || lang === 'ar') {
|
||||||
document.documentElement.dir = 'rtl';
|
document.documentElement.dir = 'rtl';
|
||||||
document.documentElement.lang = lang;
|
document.documentElement.lang = lang;
|
||||||
@@ -99,7 +109,7 @@ export default async function RootLayout({
|
|||||||
data-theme={htmlTheme.dataTheme}
|
data-theme={htmlTheme.dataTheme}
|
||||||
>
|
>
|
||||||
<head />
|
<head />
|
||||||
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable}`}>
|
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable}`}>
|
||||||
<Script
|
<Script
|
||||||
id="theme-early"
|
id="theme-early"
|
||||||
strategy="beforeInteractive"
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider'
|
import { LanguageProvider } from '@/lib/i18n/LanguageProvider'
|
||||||
|
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
||||||
|
import { QueryProvider } from '@/components/query-provider'
|
||||||
import type { Translations } from '@/lib/i18n/load-translations'
|
import type { Translations } from '@/lib/i18n/load-translations'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -16,11 +18,15 @@ export function AdminProvidersWrapper({
|
|||||||
initialTranslations,
|
initialTranslations,
|
||||||
}: AdminProvidersWrapperProps) {
|
}: AdminProvidersWrapperProps) {
|
||||||
return (
|
return (
|
||||||
|
<QueryProvider>
|
||||||
|
<NoteRefreshProvider>
|
||||||
<LanguageProvider
|
<LanguageProvider
|
||||||
initialLanguage={initialLanguage as any}
|
initialLanguage={initialLanguage as any}
|
||||||
initialTranslations={initialTranslations}
|
initialTranslations={initialTranslations}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
</NoteRefreshProvider>
|
||||||
|
</QueryProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
219
memento-note/components/admin-sidebar.tsx
Normal file
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
{ text },
|
{ text },
|
||||||
{
|
{
|
||||||
@@ -96,6 +97,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chat send error:', error)
|
||||||
|
toast.error(t('chat.assistantError') || 'Failed to send message')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchHistory = async () => {
|
const fetchHistory = async () => {
|
||||||
@@ -147,18 +152,18 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105"
|
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105 bg-muted text-foreground hover:bg-muted/80 border border-border"
|
||||||
size="icon"
|
size="icon"
|
||||||
title={t('ai.openAssistant')}
|
title={t('ai.openAssistant')}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-5 w-5" />
|
<Sparkles className="h-5 w-5 text-memento-accent" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={cn(
|
<aside className={cn(
|
||||||
"fixed bottom-20 right-6 border border-border/40 bg-card flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
|
"fixed bottom-20 right-6 border border-border/40 bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
|
||||||
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
|
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
|
||||||
)}>
|
)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -202,7 +207,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
onClick={() => setActiveTab('chat')}
|
onClick={() => setActiveTab('chat')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||||
activeTab === 'chat' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
activeTab === 'chat' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bot className="h-4 w-4" /> {t('ai.chatTab')}
|
<Bot className="h-4 w-4" /> {t('ai.chatTab')}
|
||||||
@@ -211,7 +216,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
onClick={() => setActiveTab('insights')}
|
onClick={() => setActiveTab('insights')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||||
activeTab === 'insights' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
activeTab === 'insights' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
|
<Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
|
||||||
@@ -220,7 +225,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
onClick={() => setActiveTab('history')}
|
onClick={() => setActiveTab('history')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||||
activeTab === 'history' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
activeTab === 'history' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<History className="h-4 w-4" /> {t('ai.historyTab')}
|
<History className="h-4 w-4" /> {t('ai.historyTab')}
|
||||||
@@ -234,10 +239,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
{/* AI Welcome Message */}
|
{/* AI Welcome Message */}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
|
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
<div className="bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||||
<p className="text-sm text-foreground leading-relaxed">
|
<p className="text-sm text-foreground leading-relaxed">
|
||||||
{t('ai.welcomeMsg')}
|
{t('ai.welcomeMsg')}
|
||||||
</p>
|
</p>
|
||||||
@@ -255,16 +260,16 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
|
? 'bg-muted border-border text-muted-foreground'
|
||||||
: 'bg-primary/10 text-primary border-primary/20',
|
: 'bg-memento-blue/10 text-memento-blue border-memento-blue/20',
|
||||||
)}>
|
)}>
|
||||||
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
|
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'bg-primary text-primary-foreground rounded-tr-sm'
|
? 'bg-memento-blue text-white rounded-tr-sm'
|
||||||
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
|
: 'bg-background border border-border/50 rounded-tl-sm text-foreground',
|
||||||
)}>
|
)}>
|
||||||
{msg.role === 'assistant'
|
{msg.role === 'assistant'
|
||||||
? <MarkdownContent content={text} />
|
? <MarkdownContent content={text} />
|
||||||
@@ -276,10 +281,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
|
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
<div className="bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,7 +295,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
|
|
||||||
{activeTab === 'insights' && (
|
{activeTab === 'insights' && (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> {t('ai.summaryLast5')}</h3>
|
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-memento-accent" /> {t('ai.summaryLast5')}</h3>
|
||||||
{insightsLoading ? (
|
{insightsLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-10 opacity-60">
|
<div className="flex flex-col items-center justify-center py-10 opacity-60">
|
||||||
<Loader2 className="h-8 w-8 animate-spin mb-4 text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin mb-4 text-muted-foreground" />
|
||||||
@@ -318,7 +323,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
history.map(conv => (
|
history.map(conv => (
|
||||||
<button
|
<button
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-primary/30 transition-all flex flex-col gap-1"
|
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-memento-blue/30 transition-all flex flex-col gap-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConversationId(conv.id)
|
setConversationId(conv.id)
|
||||||
setMessages(conv.messages.map((m: any) => ({
|
setMessages(conv.messages.map((m: any) => ({
|
||||||
@@ -345,12 +350,12 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input Area & Tone Controls (Only in Chat tab) */}
|
{/* Input Area & Tone Controls (Only in Chat tab) */}
|
||||||
<div className={cn("p-4 border-t border-border/40 bg-muted/10 shrink-0", activeTab !== 'chat' && "hidden")}>
|
<div className={cn("p-4 border-t border-border/40 bg-background shrink-0", activeTab !== 'chat' && "hidden")}>
|
||||||
{/* Context Scope */}
|
{/* Context Scope */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
|
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
|
||||||
<Select value={chatScope} onValueChange={setChatScope}>
|
<Select value={chatScope} onValueChange={setChatScope}>
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border/60">
|
<SelectTrigger className="h-8 text-xs bg-background border-border/60">
|
||||||
<SelectValue placeholder={t('ai.selectNotebook')} />
|
<SelectValue placeholder={t('ai.selectNotebook')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -387,8 +392,8 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
className={cn(
|
className={cn(
|
||||||
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
|
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary bg-primary/10 text-primary shadow-sm"
|
? "border-memento-blue bg-memento-blue/10 text-memento-blue shadow-sm"
|
||||||
: "border-border/60 bg-card text-muted-foreground hover:bg-muted hover:border-border"
|
: "border-border/60 bg-background text-muted-foreground hover:bg-muted hover:border-border"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
@@ -400,7 +405,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text Input */}
|
{/* Text Input */}
|
||||||
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-all shadow-sm">
|
<div className="relative bg-background border border-border/60 rounded-xl p-1 focus-within:border-memento-blue focus-within:ring-1 focus-within:ring-memento-blue/20 transition-all shadow-sm">
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
|
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
|
||||||
placeholder={t('ai.chatPlaceholder')}
|
placeholder={t('ai.chatPlaceholder')}
|
||||||
@@ -435,7 +440,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
|
className="h-8 w-8 rounded-lg bg-memento-blue text-white shadow-sm hover:shadow-md transition-all"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!input.trim()}
|
disabled={!input.trim()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
{ text: content },
|
{ text: content },
|
||||||
{
|
{
|
||||||
@@ -148,6 +149,10 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chat send error:', error)
|
||||||
|
toast.error(t('chat.assistantError') || 'Failed to send message')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewChat = () => {
|
const handleNewChat = () => {
|
||||||
@@ -183,7 +188,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
|||||||
}, [displayMessages])
|
}, [displayMessages])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden bg-white dark:bg-[#1a1c22]">
|
<div className="flex-1 flex overflow-hidden bg-background">
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
currentId={currentId}
|
currentId={currentId}
|
||||||
@@ -197,7 +202,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
|||||||
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
|
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-white dark:from-[#1a1c22] via-white/90 dark:via-[#1a1c22]/90 to-transparent pt-6 pb-4">
|
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
|
||||||
<div className="w-full max-w-4xl px-4">
|
<div className="w-full max-w-4xl px-4">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNoteboo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full relative">
|
<div className="w-full relative">
|
||||||
<div className="relative flex flex-col bg-slate-50 dark:bg-[#202228] rounded-[24px] border border-slate-200/60 dark:border-white/10 shadow-sm focus-within:shadow-md focus-within:border-slate-300 dark:focus-within:border-white/20 transition-all duration-300 overflow-hidden">
|
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -84,7 +84,7 @@ export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNoteboo
|
|||||||
value={selectedNotebook || 'global'}
|
value={selectedNotebook || 'global'}
|
||||||
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
|
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-slate-50 dark:hover:bg-[#252830] transition-colors">
|
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
|
||||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<SelectValue placeholder={t('chat.allNotebooks')} />
|
<SelectValue placeholder={t('chat.allNotebooks')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -115,7 +115,7 @@ export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNoteboo
|
|||||||
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
|
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
|
||||||
webSearchEnabled
|
webSearchEnabled
|
||||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
|
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
|
||||||
: "bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-[#252830]"
|
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Globe className="h-3.5 w-3.5" />
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{msg.role === 'user' ? (
|
{msg.role === 'user' ? (
|
||||||
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-[#f4f4f5] dark:bg-[#2a2d36] text-slate-800 dark:text-slate-100 rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-slate-200/50 dark:border-white/5">
|
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
|
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function ChatSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 border-r flex flex-col h-full bg-white dark:bg-[#1e2128]">
|
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
|
||||||
<div className="p-4 border-bottom">
|
<div className="p-4 border-bottom">
|
||||||
<Button
|
<Button
|
||||||
onClick={onNew}
|
onClick={onNew}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1049
memento-note/components/contextual-ai-chat.tsx.bak
Normal file
1049
memento-note/components/contextual-ai-chat.tsx.bak
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,45 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, X, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2 } from 'lucide-react'
|
import { motion, AnimatePresence } from 'motion/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 { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
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 {
|
interface CreateNotebookDialogProps {
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
@@ -49,166 +14,93 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { createNotebookOptimistic } = useNotebooks()
|
const { createNotebookOptimistic } = useNotebooks()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [selectedIcon, setSelectedIcon] = useState('folder')
|
|
||||||
const [selectedColor, setSelectedColor] = useState('#3B82F6')
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!name.trim()) return
|
if (!name.trim()) return
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createNotebookOptimistic({
|
await createNotebookOptimistic({ name: name.trim(), icon: 'folder', color: '#64748B' })
|
||||||
name: name.trim(),
|
setName('')
|
||||||
icon: selectedIcon,
|
|
||||||
color: selectedColor,
|
|
||||||
})
|
|
||||||
// Close dialog — context already updated sidebar state
|
|
||||||
onOpenChange?.(false)
|
onOpenChange?.(false)
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to create notebook:', error)
|
console.error('Failed to create notebook:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleClose = () => {
|
||||||
setName('')
|
setName('')
|
||||||
setSelectedIcon('folder')
|
onOpenChange?.(false)
|
||||||
setSelectedColor('#3B82F6')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectedIconComponent = NOTEBOOK_ICONS.find(i => i.name === selectedIcon)?.icon || Folder
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(val) => {
|
<AnimatePresence>
|
||||||
onOpenChange?.(val)
|
{open && (
|
||||||
if (!val) handleReset()
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
}}>
|
{/* Backdrop */}
|
||||||
<DialogContent className="sm:max-w-[500px] p-0">
|
<motion.div
|
||||||
<button
|
initial={{ opacity: 0 }}
|
||||||
onClick={() => onOpenChange?.(false)}
|
animate={{ opacity: 1 }}
|
||||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors z-10"
|
exit={{ opacity: 0 }}
|
||||||
>
|
onClick={handleClose}
|
||||||
<X className="h-5 w-5" />
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
</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>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="px-8 pb-8">
|
{/* Card */}
|
||||||
<div className="space-y-6">
|
<motion.div
|
||||||
{/* Notebook Name */}
|
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>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2 block">
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-foreground mb-2">
|
||||||
{t('notebook.name')}
|
{t('notebook.name') || 'Nom du carnet'}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder={t('notebook.namePlaceholder')}
|
placeholder={t('notebook.namePlaceholder') || 'Ex. : Projets, Recherche…'}
|
||||||
className="w-full"
|
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"
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icon Selection */}
|
<div className="flex gap-3 pt-2">
|
||||||
<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
|
<button
|
||||||
key={item.name}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedIcon(item.name)}
|
onClick={handleClose}
|
||||||
className={cn(
|
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"
|
||||||
"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" />
|
{t('notebook.cancel') || 'Annuler'}
|
||||||
</button>
|
</button>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</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
|
<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 }}
|
|
||||||
>
|
|
||||||
<SelectedIconComponent className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">{name.trim()}</span>
|
|
||||||
</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"
|
type="submit"
|
||||||
disabled={!name.trim() || isSubmitting}
|
disabled={!name.trim() || isSubmitting}
|
||||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6"
|
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') : t('notebook.create')}
|
{isSubmitting
|
||||||
</Button>
|
? (t('notebook.creating') || 'Création…')
|
||||||
|
: (t('notebook.create') || 'Créer')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</motion.div>
|
||||||
</Dialog>
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { useEffect } from 'react'
|
|||||||
export function DirectionInitializer() {
|
export function DirectionInitializer() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const lang = localStorage.getItem('user-language')
|
let lang = localStorage.getItem('user-language')
|
||||||
|
if (!lang) {
|
||||||
|
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('user-language='))
|
||||||
|
if (c) lang = c.split('=')[1]
|
||||||
|
}
|
||||||
if (lang === 'fa' || lang === 'ar') {
|
if (lang === 'fa' || lang === 'ar') {
|
||||||
document.documentElement.dir = 'rtl'
|
document.documentElement.dir = 'rtl'
|
||||||
document.documentElement.lang = lang
|
document.documentElement.lang = lang
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { Header } from './header'
|
import { Header } from './header'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
|
||||||
interface HeaderWrapperProps {
|
interface HeaderWrapperProps {
|
||||||
onColorFilterChange?: (color: string | null) => void
|
onColorFilterChange?: (color: string | null) => void
|
||||||
@@ -13,7 +13,7 @@ interface HeaderWrapperProps {
|
|||||||
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
|
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { labels } = useLabels()
|
const { labels } = useNotebooks()
|
||||||
|
|
||||||
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||||
const selectedColor = searchParams.get('color') || null
|
const selectedColor = searchParams.get('color') || null
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, S
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { LabelFilter } from './label-filter'
|
import { LabelFilter } from './label-filter'
|
||||||
import { NotificationPanel } from './notification-panel'
|
import { NotificationPanel } from './notification-panel'
|
||||||
import { updateTheme } from '@/app/actions/profile'
|
import { updateTheme } from '@/app/actions/profile'
|
||||||
@@ -53,7 +53,7 @@ export function Header({
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { labels, setNotebookId } = useLabels()
|
const { labels, setNotebookId } = useNotebooks()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
|
|||||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, ArrowUpDown } from 'lucide-react'
|
import { Plus, ArrowUpDown, Search, Sparkles, FileText } from 'lucide-react'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useHomeView } from '@/context/home-view-context'
|
import { useEditorUI } from '@/context/editor-ui-context'
|
||||||
import { NoteHistoryModal } from '@/components/note-history-modal'
|
import { NoteHistoryModal } from '@/components/note-history-modal'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
@@ -38,12 +38,17 @@ const AutoLabelSuggestionDialog = dynamic(
|
|||||||
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
|
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
)
|
||||||
|
const NotebookSummaryDialog = dynamic(
|
||||||
|
() => import('@/components/notebook-summary-dialog').then(m => ({ default: m.NotebookSummaryDialog })),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
type InitialSettings = {
|
type InitialSettings = {
|
||||||
showRecentNotes: boolean
|
showRecentNotes: boolean
|
||||||
notesViewMode: 'masonry' | 'tabs' | 'list'
|
notesViewMode: 'masonry' | 'tabs' | 'list'
|
||||||
noteHistory: boolean
|
noteHistory: boolean
|
||||||
noteHistoryMode: 'manual' | 'auto'
|
noteHistoryMode: 'manual' | 'auto'
|
||||||
|
aiAssistantEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HomeClientProps {
|
interface HomeClientProps {
|
||||||
@@ -71,12 +76,19 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
const [isCreating, startCreating] = useTransition()
|
const [isCreating, startCreating] = useTransition()
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
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()
|
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
||||||
const { labels } = useLabels()
|
const { refreshNotes } = useRefresh()
|
||||||
const { setControls } = useHomeView()
|
const { labels, notebooks } = useNotebooks()
|
||||||
|
const { setControls } = useEditorUI()
|
||||||
|
|
||||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||||
|
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldSuggestLabels && suggestNotebookId) {
|
if (shouldSuggestLabels && suggestNotebookId) {
|
||||||
@@ -84,18 +96,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
}
|
}
|
||||||
}, [shouldSuggestLabels, suggestNotebookId])
|
}, [shouldSuggestLabels, suggestNotebookId])
|
||||||
|
|
||||||
// BUG FIX: forceList param from sidebar carnet click → reset to editorial view
|
// Sidebar carnet / inbox: forceList → liste éditoriale + fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const forceList = searchParams.get('forceList')
|
const forceList = searchParams.get('forceList')
|
||||||
if (forceList === '1') {
|
if (forceList !== '1') return
|
||||||
setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev))
|
setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev))
|
||||||
|
setEditingNote(null)
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.delete('forceList')
|
params.delete('forceList')
|
||||||
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
|
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
|
||||||
router.replace(newUrl, { scroll: false })
|
router.replace(newUrl, { scroll: false })
|
||||||
}
|
}, [searchParams, router])
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
const notebookFilter = searchParams.get('notebook')
|
const notebookFilter = searchParams.get('notebook')
|
||||||
const handleNoteCreated = useCallback((note: Note) => {
|
const handleNoteCreated = useCallback((note: Note) => {
|
||||||
@@ -145,10 +156,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
}
|
}
|
||||||
}, [searchParams, labels, router])
|
}, [searchParams, labels, router])
|
||||||
|
|
||||||
const handleOpenNote = (noteId: string) => {
|
// Always fetch fresh from server — avoids stale state after a save regardless of
|
||||||
const note = notes.find(n => n.id === noteId)
|
// whether the notes list has re-fetched yet.
|
||||||
if (note) setEditingNote({ note, readOnly: false })
|
const handleOpenNoteFresh = useCallback(async (noteId: string, readOnly = false) => {
|
||||||
}
|
const note = await getNoteById(noteId)
|
||||||
|
if (note) setEditingNote({ note, readOnly })
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleAddNote = () => {
|
const handleAddNote = () => {
|
||||||
startCreating(async () => {
|
startCreating(async () => {
|
||||||
@@ -196,27 +209,24 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
useReminderCheck(notes)
|
useReminderCheck(notes)
|
||||||
|
|
||||||
|
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openNoteId = searchParams.get('openNote')
|
const openNoteId = searchParams.get('openNote')
|
||||||
if (!openNoteId) return
|
if (!openNoteId) return
|
||||||
|
|
||||||
const openNote = async () => {
|
let cancelled = false
|
||||||
const existing = notes.find(n => n.id === openNoteId)
|
const run = async () => {
|
||||||
if (existing) {
|
// Always fetch fresh data from DB to avoid showing stale content after a save.
|
||||||
setEditingNote({ note: existing, readOnly: false })
|
// notesRef.current can be stale if the notes list hasn't re-fetched yet when the
|
||||||
} else {
|
// user closes and re-opens the note quickly after saving.
|
||||||
const fetched = await getNoteById(openNoteId)
|
const note = await getNoteById(openNoteId)
|
||||||
if (fetched) {
|
if (cancelled || !note) return
|
||||||
setEditingNote({ note: fetched, readOnly: false })
|
setEditingNote({ note, readOnly: false })
|
||||||
}
|
}
|
||||||
|
run()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
params.delete('openNote')
|
|
||||||
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
openNote()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -258,8 +268,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
? await searchNotes(search, semanticMode, notebook || undefined)
|
? await searchNotes(search, semanticMode, notebook || undefined)
|
||||||
: await getAllNotes(false, notebook || undefined)
|
: await getAllNotes(false, notebook || undefined)
|
||||||
|
|
||||||
if (!notebook && !search) {
|
const sharedOnly = searchParams.get('shared') === '1'
|
||||||
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
const remindersOnly = searchParams.get('reminders') === '1'
|
||||||
|
|
||||||
|
if (sharedOnly) {
|
||||||
|
allNotes = allNotes.filter((note: any) => note._isShared)
|
||||||
|
} else if (remindersOnly) {
|
||||||
|
allNotes = allNotes.filter((note: any) => note.reminder !== null)
|
||||||
|
} else if (!notebook && !search) {
|
||||||
|
allNotes = allNotes.filter((note: any) => !note.notebookId && !note._isShared)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelFilter.length > 0) {
|
if (labelFilter.length > 0) {
|
||||||
@@ -291,10 +308,16 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
return () => { cancelled.value = true }
|
return () => { cancelled.value = true }
|
||||||
} else {
|
} else {
|
||||||
let filtered = initialNotes
|
let filtered = initialNotes
|
||||||
|
const sharedOnly = searchParams.get('shared') === '1'
|
||||||
|
const remindersOnly = searchParams.get('reminders') === '1'
|
||||||
if (notebook) {
|
if (notebook) {
|
||||||
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
||||||
|
} else if (sharedOnly) {
|
||||||
|
filtered = initialNotes.filter((n: any) => n._isShared)
|
||||||
|
} else if (remindersOnly) {
|
||||||
|
filtered = initialNotes.filter((n: any) => n.reminder !== null)
|
||||||
} else {
|
} else {
|
||||||
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
|
filtered = initialNotes.filter((n: any) => !n.notebookId && !n._isShared)
|
||||||
}
|
}
|
||||||
setNotes(prev => {
|
setNotes(prev => {
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||||
@@ -305,7 +328,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchParams, refreshKey])
|
}, [searchParams, refreshKey])
|
||||||
|
|
||||||
const { notebooks } = useNotebooks()
|
|
||||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -340,8 +362,23 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
const handleEditorClose = useCallback(() => {
|
const handleEditorClose = useCallback(() => {
|
||||||
setEditingNote(null)
|
setEditingNote(null)
|
||||||
triggerRefresh()
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
}, [triggerRefresh])
|
params.delete('openNote')
|
||||||
|
const qs = params.toString()
|
||||||
|
router.replace(qs ? `/?${qs}` : '/', { scroll: false })
|
||||||
|
// Invalidate notes cache and trigger refresh
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -355,21 +392,29 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
note={editingNote.note}
|
note={editingNote.note}
|
||||||
readOnly={editingNote.readOnly}
|
readOnly={editingNote.readOnly}
|
||||||
onClose={handleEditorClose}
|
onClose={handleEditorClose}
|
||||||
|
onNoteSaved={handleNoteSaved}
|
||||||
fullPage
|
fullPage
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper flex flex-col">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'px-12 pt-12 pb-8 flex flex-col gap-6',
|
'px-12 pt-12 pb-8 flex flex-col gap-6',
|
||||||
isEditorialMode ? 'sticky top-0 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-md z-30' : ''
|
isEditorialMode ? 'sticky top-0 bg-memento-paper/90 backdrop-blur-md z-30' : ''
|
||||||
)}>
|
)}>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
|
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
|
||||||
{currentNotebook ? currentNotebook.name : t('notes.title')}
|
{currentNotebook
|
||||||
|
? currentNotebook.name
|
||||||
|
: searchParams.get('shared') === '1'
|
||||||
|
? (t('sidebar.sharedWithMe') || 'Partagées avec moi')
|
||||||
|
: searchParams.get('reminders') === '1'
|
||||||
|
? (t('sidebar.reminders') || 'Rappels')
|
||||||
|
: t('notes.title')}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
|
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
<button
|
<button
|
||||||
onClick={handleAddNote}
|
onClick={handleAddNote}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
@@ -379,41 +424,103 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
<span>{t('notes.newNote') || 'Add Note'}</span>
|
<span>{t('notes.newNote') || 'Add Note'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Sort order */}
|
{/* Inline search — toggles an input within the toolbar */}
|
||||||
<div className="relative">
|
{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
|
<button
|
||||||
onClick={() => setShowSortMenu(s => !s)}
|
onClick={() => {
|
||||||
className="flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground font-medium transition-opacity"
|
setShowInlineSearch(false)
|
||||||
title={t('sidebar.sortOrder') || 'Sort order'}
|
setInlineSearchQuery('')
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.delete('search')
|
||||||
|
router.push(`/?${params.toString()}`)
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowUpDown size={14} />
|
<span className="text-[11px]">×</span>
|
||||||
<span className="hidden sm:inline text-[11px] uppercase tracking-wider font-bold">{sortLabels[sortOrder]}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<AnimatePresence>
|
)}
|
||||||
{showSortMenu && (
|
</div>
|
||||||
<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
|
<button
|
||||||
key={order}
|
onClick={() => {
|
||||||
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
|
setShowInlineSearch(true)
|
||||||
|
setTimeout(() => inlineSearchRef.current?.focus(), 50)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<Search size={16} />
|
||||||
|
<span>{t('notes.search') || 'Search'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!searchParams.get('notebook') && searchParams.get('shared') !== '1' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setBatchOrganizationOpen(true)}
|
||||||
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
<span>{t('notes.reorganize') || 'Réorganiser les notes'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{searchParams.get('notebook') && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSummaryDialogOpen(true)}
|
||||||
|
disabled={!initialSettings.aiAssistantEnabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-4 py-2 text-[12px] transition-colors',
|
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
|
||||||
sortOrder === order
|
initialSettings.aiAssistantEnabled ? "text-foreground hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
|
||||||
? 'font-bold text-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
|
||||||
)}
|
)}
|
||||||
|
title={initialSettings.aiAssistantEnabled ? t('notebook.summary') : "Activez l'Assistant IA dans les paramètres pour résumer"}
|
||||||
>
|
>
|
||||||
{sortLabels[order]}
|
<FileText size={16} />
|
||||||
|
<span>{t('notebook.summary') || 'Summarize'}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
<button
|
||||||
|
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : 'newest')}
|
||||||
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={16} />
|
||||||
|
<span>{sortLabels[sortOrder]}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,13 +543,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-3xl space-y-16">
|
<div className="max-w-3xl space-y-16">
|
||||||
{sortedPinnedNotes.length > 0 && (
|
{sortedPinnedNotes.length > 0 && (
|
||||||
<div className="mb-8">
|
<div className="mb-6">
|
||||||
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-6 px-2">
|
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
|
||||||
{t('notes.pinned')}
|
{t('notes.pinned')}
|
||||||
</h2>
|
</h2>
|
||||||
<NotesEditorialView
|
<NotesEditorialView
|
||||||
notes={sortedPinnedNotes}
|
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}
|
notebookName={currentNotebook?.name}
|
||||||
onOpenHistory={handleOpenHistory}
|
onOpenHistory={handleOpenHistory}
|
||||||
/>
|
/>
|
||||||
@@ -452,7 +559,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
|
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
|
||||||
<NotesEditorialView
|
<NotesEditorialView
|
||||||
notes={sortedNotes.filter((note) => !note.isPinned)}
|
notes={sortedNotes.filter((note) => !note.isPinned)}
|
||||||
onOpen={(note, readOnly) => setEditingNote({ note, readOnly })}
|
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||||||
notebookName={currentNotebook?.name}
|
notebookName={currentNotebook?.name}
|
||||||
onOpenHistory={handleOpenHistory}
|
onOpenHistory={handleOpenHistory}
|
||||||
/>
|
/>
|
||||||
@@ -481,10 +588,10 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
Memento — {new Date().getFullYear()}
|
Memento — {new Date().getFullYear()}
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
<MemoryEchoNotification onOpenNote={(noteId) => handleOpenNoteFresh(noteId)} />
|
||||||
|
|
||||||
{notebookSuggestion && (
|
{notebookSuggestion && (
|
||||||
<NotebookSuggestionToast
|
<NotebookSuggestionToast
|
||||||
@@ -522,6 +629,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
onEnableHistory={async () => { if (historyNote) await handleEnableHistory(historyNote.id) }}
|
onEnableHistory={async () => { if (historyNote) await handleEnableHistory(historyNote.id) }}
|
||||||
onRestored={handleHistoryRestored}
|
onRestored={handleHistoryRestored}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{searchParams.get('notebook') && (
|
||||||
|
<NotebookSummaryDialog
|
||||||
|
open={summaryDialogOpen}
|
||||||
|
onOpenChange={setSummaryDialogOpen}
|
||||||
|
notebookId={searchParams.get('notebook')}
|
||||||
|
notebookName={currentNotebook?.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Download, Presentation } from 'lucide-react'
|
import { Download, Presentation } from 'lucide-react'
|
||||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
||||||
@@ -107,6 +108,8 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
|||||||
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
||||||
|
const [localId, setLocalId] = useState<string | null>(canvasId || null)
|
||||||
|
const router = useRouter()
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
|
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
|
||||||
const filesRef = useRef<BinaryFiles>({})
|
const filesRef = useRef<BinaryFiles>({})
|
||||||
@@ -147,12 +150,22 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|||||||
saveTimeoutRef.current = setTimeout(async () => {
|
saveTimeoutRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
||||||
await fetch('/api/canvas', {
|
const res = await fetch('/api/canvas', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: canvasId || null, name, data: snapshot })
|
body: JSON.stringify({ id: localId || null, name, data: snapshot })
|
||||||
})
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.success && data.canvas?.id) {
|
||||||
|
if (!localId) {
|
||||||
|
setLocalId(data.canvas.id)
|
||||||
|
router.replace(`/lab?id=${data.canvas.id}`, { scroll: false })
|
||||||
|
}
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Failed to save')
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[CanvasBoard] Save failure:', e)
|
console.error('[CanvasBoard] Save failure:', e)
|
||||||
setSaveStatus('error')
|
setSaveStatus('error')
|
||||||
@@ -164,7 +177,7 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|||||||
return <PptxViewer data={scene.pptx} name={name} />
|
return <PptxViewer data={scene.pptx} name={name} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const excalKey = canvasId ? `excal-${canvasId}` : 'excal-new'
|
const excalKey = localId ? `excal-${localId}` : 'excal-new'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
||||||
@@ -182,7 +195,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
validateEmbeddable={false}
|
validateEmbeddable={false}
|
||||||
renderTopRightUI={() => null}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { X } from 'lucide-react'
|
import { X, Sparkles } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { LABEL_COLORS } from '@/lib/types'
|
import { LABEL_COLORS } from '@/lib/types'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
|
||||||
interface LabelBadgeProps {
|
interface LabelBadgeProps {
|
||||||
label: string
|
label: string
|
||||||
|
type?: 'ai' | 'user' // Optional: if provided, applies AI vs User styling
|
||||||
onRemove?: () => void
|
onRemove?: () => void
|
||||||
variant?: 'default' | 'filter' | 'clickable'
|
variant?: 'default' | 'filter' | 'clickable'
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
@@ -17,23 +18,27 @@ interface LabelBadgeProps {
|
|||||||
|
|
||||||
export function LabelBadge({
|
export function LabelBadge({
|
||||||
label,
|
label,
|
||||||
|
type,
|
||||||
onRemove,
|
onRemove,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
onClick,
|
onClick,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
}: LabelBadgeProps) {
|
}: LabelBadgeProps) {
|
||||||
const { getLabelColor } = useLabels()
|
const { getLabelColor } = useNotebooks()
|
||||||
const colorName = getLabelColor(label)
|
const colorName = getLabelColor(label)
|
||||||
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
|
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
|
||||||
|
|
||||||
|
// AI labels get special Blueprint styling with Sparkles icon
|
||||||
|
const isAI = type === 'ai'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs border gap-1',
|
'text-xs border gap-1 transition-all',
|
||||||
colorClasses.bg,
|
isAI
|
||||||
colorClasses.text,
|
? 'bg-blue-100/70 border-blue-200/50 text-sky-700 dark:bg-sky-900/30 dark:border-sky-700/50 dark:text-sky-300 hover:bg-blue-200/70'
|
||||||
colorClasses.border,
|
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`,
|
||||||
variant === 'filter' && 'cursor-pointer hover:opacity-80',
|
variant === 'filter' && 'cursor-pointer hover:opacity-80',
|
||||||
variant === 'clickable' && 'cursor-pointer',
|
variant === 'clickable' && 'cursor-pointer',
|
||||||
isDisabled && 'opacity-50',
|
isDisabled && 'opacity-50',
|
||||||
@@ -41,7 +46,8 @@ export function LabelBadge({
|
|||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{label}
|
{isAI && <Sparkles className="h-3 w-3 text-[#75B2D6]" />}
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -53,6 +59,12 @@ export function LabelBadge({
|
|||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isAI && (
|
||||||
|
<span className="relative flex h-1.5 w-1.5 ml-1">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#75B2D6] opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#75B2D6]"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Filter, Check } from 'lucide-react'
|
import { Filter, Check } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ interface LabelFilterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
|
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
|
||||||
const { labels, loading } = useLabels()
|
const { labels, isLoading: loading } = useNotebooks()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
|
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
|
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
|
||||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
|
|
||||||
export interface LabelManagementDialogProps {
|
export interface LabelManagementDialogProps {
|
||||||
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
|
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
|
||||||
@@ -26,9 +26,9 @@ export interface LabelManagementDialogProps {
|
|||||||
|
|
||||||
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||||
const { open, onOpenChange } = props
|
const { open, onOpenChange } = props
|
||||||
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
|
const { labels, isLoading: loading, addLabel, updateLabel, deleteLabel } = useNotebooks()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
const { refreshLabels } = useRefresh()
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||||
const [newLabel, setNewLabel] = useState('')
|
const [newLabel, setNewLabel] = useState('')
|
||||||
const [editingColorId, setEditingColorId] = useState<string | null>(null)
|
const [editingColorId, setEditingColorId] = useState<string | null>(null)
|
||||||
@@ -40,7 +40,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
try {
|
try {
|
||||||
await addLabel(trimmed, 'gray')
|
await addLabel(trimmed, 'gray')
|
||||||
triggerRefresh()
|
refreshLabels()
|
||||||
setNewLabel('')
|
setNewLabel('')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add label:', error)
|
console.error('Failed to add label:', error)
|
||||||
@@ -52,7 +52,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
try {
|
try {
|
||||||
const labelToDelete = labels.find(l => l.id === id)
|
const labelToDelete = labels.find(l => l.id === id)
|
||||||
await deleteLabel(id)
|
await deleteLabel(id)
|
||||||
triggerRefresh()
|
refreshLabels()
|
||||||
if (labelToDelete) {
|
if (labelToDelete) {
|
||||||
window.dispatchEvent(new CustomEvent('label-deleted', { detail: { name: labelToDelete.name } }))
|
window.dispatchEvent(new CustomEvent('label-deleted', { detail: { name: labelToDelete.name } }))
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
const handleChangeColor = async (id: string, color: LabelColorName) => {
|
const handleChangeColor = async (id: string, color: LabelColorName) => {
|
||||||
try {
|
try {
|
||||||
await updateLabel(id, { color })
|
await updateLabel(id, { color })
|
||||||
triggerRefresh()
|
refreshLabels()
|
||||||
setEditingColorId(null)
|
setEditingColorId(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update label color:', error)
|
console.error('Failed to update label color:', error)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Badge } from './ui/badge'
|
|||||||
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
|
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
|
||||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels, Label } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface LabelManagerProps {
|
interface LabelManagerProps {
|
||||||
@@ -26,7 +26,7 @@ interface LabelManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelManagerProps) {
|
export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelManagerProps) {
|
||||||
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels()
|
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useNotebooks()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [newLabel, setNewLabel] = useState('')
|
const [newLabel, setNewLabel] = useState('')
|
||||||
@@ -45,18 +45,11 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
|
|
||||||
if (trimmed && !selectedLabels.includes(trimmed)) {
|
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||||
try {
|
try {
|
||||||
// NotebookId is REQUIRED for label creation (PRD R2)
|
|
||||||
if (!notebookId) {
|
|
||||||
setErrorMessage(t('labels.notebookRequired'))
|
|
||||||
console.error(t('labels.notebookRequired'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing label color or use random
|
// Get existing label color or use random
|
||||||
const existingLabel = labels.find(l => l.name === trimmed)
|
const existingLabel = labels.find(l => l.name === trimmed)
|
||||||
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
||||||
|
|
||||||
await addLabel(trimmed, color, notebookId)
|
await addLabel(trimmed, color)
|
||||||
const updated = [...selectedLabels, trimmed]
|
const updated = [...selectedLabels, trimmed]
|
||||||
setSelectedLabels(updated)
|
setSelectedLabels(updated)
|
||||||
setNewLabel('')
|
setNewLabel('')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Tag, Plus, Check } from 'lucide-react'
|
import { Tag, Plus, Check } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function LabelSelector({
|
|||||||
triggerLabel,
|
triggerLabel,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
}: LabelSelectorProps) {
|
}: LabelSelectorProps) {
|
||||||
const { labels, loading, addLabel } = useLabels()
|
const { labels, isLoading: loading, addLabel } = useNotebooks()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
import rehypeKatex from 'rehype-katex'
|
import rehypeKatex from 'rehype-katex'
|
||||||
|
import rehypeRaw from 'rehype-raw'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
|
|
||||||
interface MarkdownContentProps {
|
interface MarkdownContentProps {
|
||||||
@@ -17,7 +18,7 @@ export const MarkdownContent = memo(function MarkdownContent({ content, classNam
|
|||||||
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
rehypePlugins={[rehypeKatex]}
|
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||||
components={{
|
components={{
|
||||||
a: ({ node, ...props }) => (
|
a: ({ node, ...props }) => (
|
||||||
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
|
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { Note } from '@/lib/types';
|
import { Note } from '@/lib/types';
|
||||||
import { NoteCard } from './note-card';
|
import { NoteCard } from './note-card';
|
||||||
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
||||||
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
import { useEditorUI } from '@/context/editor-ui-context';
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n';
|
||||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
|
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
@@ -175,7 +175,7 @@ export function MasonryGrid({
|
|||||||
}: MasonryGridProps) {
|
}: MasonryGridProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
||||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
const { startDrag, endDrag, draggedNoteId } = useEditorUI();
|
||||||
const cardSizeMode = useCardSizeMode();
|
const cardSizeMode = useCardSizeMode();
|
||||||
const isUniformMode = cardSizeMode === 'uniform';
|
const isUniformMode = cardSizeMode === 'uniform';
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
{/* Section 1: What is MCP */}
|
{/* Section 1: What is MCP */}
|
||||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500 shrink-0">
|
<div className="w-10 h-10 rounded-full bg-zinc-500/10 flex items-center justify-center text-zinc-500 shrink-0">
|
||||||
<Info className="h-5 w-5" />
|
<Info className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -135,7 +135,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
href="https://modelcontextprotocol.io"
|
href="https://modelcontextprotocol.io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline mt-4"
|
className="inline-flex items-center gap-1 text-sm text-zinc-600 hover:underline mt-4"
|
||||||
>
|
>
|
||||||
{t('mcpSettings.whatIsMcp.learnMore')}
|
{t('mcpSettings.whatIsMcp.learnMore')}
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
@@ -156,7 +156,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="space-y-4 text-sm">
|
<div className="space-y-4 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground">{t('mcpSettings.serverStatus.mode')}</span>
|
<span className="text-zinc-600">{t('mcpSettings.serverStatus.mode')}</span>
|
||||||
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
|
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{serverStatus.mode === 'sse' && serverStatus.url && (
|
{serverStatus.mode === 'sse' && serverStatus.url && (
|
||||||
|
|||||||
@@ -58,10 +58,9 @@ const ConnectionsOverlay = dynamic(() => import('./connections-overlay').then(m
|
|||||||
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
|
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
|
||||||
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
|
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
|
||||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
// Mapping of supported languages to date-fns locales
|
// Mapping of supported languages to date-fns locales
|
||||||
@@ -172,11 +171,10 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
}: NoteCardProps) {
|
}: NoteCardProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { refreshLabels } = useLabels()
|
const { refreshNotes } = useRefresh()
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
const { notebooks, moveNoteToNotebookOptimistic, refreshLabels } = useNotebooks()
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [isHidden, setIsHidden] = useState(false)
|
const [isHidden, setIsHidden] = useState(false)
|
||||||
@@ -203,7 +201,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
try {
|
try {
|
||||||
await updateNote(noteId, { reminder })
|
await updateNote(noteId, { reminder })
|
||||||
setReminderDate(reminder)
|
setReminderDate(reminder)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
if (reminder) {
|
if (reminder) {
|
||||||
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
|
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
|
||||||
} else {
|
} else {
|
||||||
@@ -219,7 +217,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
const handleMoveToNotebook = async (notebookId: string | null) => {
|
||||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
||||||
setShowNotebookMenu(false)
|
setShowNotebookMenu(false)
|
||||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
// No need for router.refresh() - refreshNotes(note?.notebookId) is already called in moveNoteToNotebookOptimistic
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic UI state for instant feedback
|
// Optimistic UI state for instant feedback
|
||||||
@@ -304,7 +302,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
try {
|
try {
|
||||||
await deleteNote(note.id)
|
await deleteNote(note.id)
|
||||||
await refreshLabels()
|
await refreshLabels()
|
||||||
triggerRefresh() // met à jour la liste et le compteur du carnet
|
refreshNotes(note?.notebookId) // met à jour la liste et le compteur du carnet
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete note:', error)
|
console.error('Failed to delete note:', error)
|
||||||
setIsHidden(false)
|
setIsHidden(false)
|
||||||
@@ -317,7 +315,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
setIsHidden(true)
|
setIsHidden(true)
|
||||||
try {
|
try {
|
||||||
await restoreNote(note.id)
|
await restoreNote(note.id)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
toast.success(t('trash.noteRestored'))
|
toast.success(t('trash.noteRestored'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to restore note:', error)
|
console.error('Failed to restore note:', error)
|
||||||
@@ -331,7 +329,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
setIsHidden(true)
|
setIsHidden(true)
|
||||||
try {
|
try {
|
||||||
await permanentDeleteNote(note.id)
|
await permanentDeleteNote(note.id)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
toast.success(t('trash.notePermanentlyDeleted'))
|
toast.success(t('trash.notePermanentlyDeleted'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to permanently delete note:', error)
|
console.error('Failed to permanently delete note:', error)
|
||||||
@@ -344,7 +342,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
addOptimisticNote({ isPinned: !note.isPinned })
|
addOptimisticNote({ isPinned: !note.isPinned })
|
||||||
await togglePin(note.id, !note.isPinned)
|
await togglePin(note.id, !note.isPinned)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
|
|
||||||
if (!note.isPinned) {
|
if (!note.isPinned) {
|
||||||
toast.success(t('notes.pinned') || 'Note pinned')
|
toast.success(t('notes.pinned') || 'Note pinned')
|
||||||
@@ -358,7 +356,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
addOptimisticNote({ isArchived: !note.isArchived })
|
addOptimisticNote({ isArchived: !note.isArchived })
|
||||||
await toggleArchive(note.id, !note.isArchived)
|
await toggleArchive(note.id, !note.isArchived)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,7 +809,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
}
|
}
|
||||||
toast.success(t('toast.notesFusionSuccess'))
|
toast.success(t('toast.notesFusionSuccess'))
|
||||||
setFusionNotes([])
|
setFusionNotes([])
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { Note } from '@/lib/types'
|
|||||||
import { format, formatDistanceToNow } from 'date-fns'
|
import { format, formatDistanceToNow } from 'date-fns'
|
||||||
import { fr } from 'date-fns/locale/fr'
|
import { fr } from 'date-fns/locale/fr'
|
||||||
import { enUS } from 'date-fns/locale/en-US'
|
import { enUS } from 'date-fns/locale/en-US'
|
||||||
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight } from 'lucide-react'
|
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
import { NoteHistoryModal } from './note-history-modal'
|
import { NoteHistoryModal } from './note-history-modal'
|
||||||
import { enableNoteHistory } from '@/app/actions/notes'
|
import { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
type Tab = 'info' | 'versions'
|
type Tab = 'info' | 'versions'
|
||||||
|
|
||||||
@@ -47,8 +48,58 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
|||||||
const [activeTab, setActiveTab] = useState<Tab>('info')
|
const [activeTab, setActiveTab] = useState<Tab>('info')
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false)
|
const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false)
|
||||||
|
const [isSavingVersion, setIsSavingVersion] = useState(false)
|
||||||
|
const [versionSaved, setVersionSaved] = useState(false)
|
||||||
|
const [historyEntries, setHistoryEntries] = useState<any[]>([])
|
||||||
|
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<string | null>(null)
|
||||||
|
const [isRestoring, setIsRestoring] = useState<string | null>(null)
|
||||||
const locale = getLocale(language)
|
const locale = getLocale(language)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'versions' && historyEnabled) {
|
||||||
|
loadHistory()
|
||||||
|
}
|
||||||
|
}, [activeTab, historyEnabled, note.id])
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
setIsLoadingHistory(true)
|
||||||
|
try {
|
||||||
|
const entries = await getNoteHistory(note.id, 50)
|
||||||
|
setHistoryEntries(entries)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingHistory(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteVersion = async (entryId: string) => {
|
||||||
|
if (!confirm('Supprimer cette version ?')) return
|
||||||
|
setIsDeleting(entryId)
|
||||||
|
try {
|
||||||
|
await deleteNoteHistoryEntry(note.id, entryId)
|
||||||
|
setHistoryEntries(prev => prev.filter(e => e.id !== entryId))
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestoreVersion = async (entryId: string) => {
|
||||||
|
setIsRestoring(entryId)
|
||||||
|
try {
|
||||||
|
const restored = await restoreNoteVersion(note.id, entryId)
|
||||||
|
onNoteRestored?.(restored)
|
||||||
|
loadHistory()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsRestoring(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const notebook = useMemo(
|
const notebook = useMemo(
|
||||||
() => notebooks.find(nb => nb.id === note.notebookId),
|
() => notebooks.find(nb => nb.id === note.notebookId),
|
||||||
[notebooks, note.notebookId]
|
[notebooks, note.notebookId]
|
||||||
@@ -62,20 +113,20 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-72 shrink-0 flex-col border-l border-border/60 bg-card overflow-hidden">
|
<div className="flex w-full h-full flex-col bg-background overflow-hidden">
|
||||||
|
|
||||||
{/* Header tabs */}
|
{/* Header tabs */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/60">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/40">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{(['info', 'versions'] as Tab[]).map(tab => (
|
{(['info', 'versions'] as Tab[]).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-all',
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'bg-foreground text-background'
|
? 'bg-foreground text-background'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
: 'text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab === 'info' && <Info className="h-3 w-3" />}
|
{tab === 'info' && <Info className="h-3 w-3" />}
|
||||||
@@ -86,7 +137,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -97,16 +148,16 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
|||||||
|
|
||||||
{/* ── INFO TAB ── */}
|
{/* ── INFO TAB ── */}
|
||||||
{activeTab === 'info' && (
|
{activeTab === 'info' && (
|
||||||
<div className="space-y-0">
|
<div>
|
||||||
{/* Stats */}
|
{/* Stats — grand display numbers */}
|
||||||
<div className="grid grid-cols-2 border-b border-border/40">
|
<div className="grid grid-cols-2 border-b border-border/30">
|
||||||
<div className="flex flex-col items-center gap-0.5 py-4 border-r border-border/40">
|
<div className="flex flex-col items-center gap-1 py-6 border-r border-border/30">
|
||||||
<span className="text-2xl font-bold font-memento-serif tabular-nums">{words}</span>
|
<span className="text-4xl font-bold font-memento-serif tabular-nums tracking-tight">{words}</span>
|
||||||
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">mots</span>
|
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">mots</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-0.5 py-4">
|
<div className="flex flex-col items-center gap-1 py-6">
|
||||||
<span className="text-2xl font-bold font-memento-serif tabular-nums">{chars}</span>
|
<span className="text-4xl font-bold font-memento-serif tabular-nums tracking-tight">{chars}</span>
|
||||||
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">caractères</span>
|
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">caractères</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,17 +250,123 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-3">Versions sauvegardées</p>
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">Versions sauvegardées</p>
|
||||||
|
|
||||||
|
{/* Save version button */}
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center justify-between p-3 rounded-xl border border-border hover:bg-muted transition-colors text-left"
|
disabled={isSavingVersion}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-center gap-2 p-3 rounded-xl border transition-all text-xs font-bold uppercase tracking-widest',
|
||||||
|
versionSaved
|
||||||
|
? 'border-emerald-500/40 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400'
|
||||||
|
: 'border-foreground/10 bg-foreground text-background hover:opacity-90 shadow-sm',
|
||||||
|
isSavingVersion && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsSavingVersion(true)
|
||||||
|
try {
|
||||||
|
await commitNoteHistory(note.id)
|
||||||
|
setVersionSaved(true)
|
||||||
|
loadHistory()
|
||||||
|
setTimeout(() => setVersionSaved(false), 3000)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsSavingVersion(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSavingVersion ? (
|
||||||
|
<><Loader2 className="h-3.5 w-3.5 animate-spin" />Sauvegarde…</>
|
||||||
|
) : versionSaved ? (
|
||||||
|
<><Check className="h-3.5 w-3.5" /> Version sauvegardée !</>
|
||||||
|
) : (
|
||||||
|
<>Sauvegarder cette version</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-px bg-border/30 my-2" />
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{isLoadingHistory && historyEntries.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 opacity-40">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mb-2" />
|
||||||
|
<p className="text-[10px] uppercase tracking-widest">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
) : historyEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-8 opacity-40 border border-dashed rounded-xl">
|
||||||
|
<Clock className="h-6 w-6 mx-auto mb-2" />
|
||||||
|
<p className="text-[10px] uppercase tracking-widest">Aucune version</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-px before:bg-border/40">
|
||||||
|
{historyEntries.map((entry, idx) => {
|
||||||
|
const colors = ['#E2E8F0', '#ACB995', '#E9ECEF']
|
||||||
|
const dotColor = colors[idx % colors.length]
|
||||||
|
const isLatest = idx === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="relative group">
|
||||||
|
{/* Dot */}
|
||||||
|
<div
|
||||||
|
className="absolute -left-[19px] top-1.5 h-3 w-3 rounded-full border-2 border-background z-10 shadow-sm"
|
||||||
|
style={{ backgroundColor: dotColor }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold font-mono">v{entry.version}</span>
|
||||||
|
{isLatest && (
|
||||||
|
<span className="text-[9px] px-1.5 py-0.5 rounded-md bg-primary/10 text-primary font-bold uppercase tracking-widest">Latest</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestoreVersion(entry.id)}
|
||||||
|
disabled={!!isRestoring || !!isDeleting}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
title="Restaurer"
|
||||||
|
>
|
||||||
|
{isRestoring === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteVersion(entry.id)}
|
||||||
|
disabled={!!isRestoring || !!isDeleting}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
{isDeleting === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground font-medium">
|
||||||
|
{format(new Date(entry.createdAt), 'd MMM · HH:mm', { locale })}
|
||||||
|
<span className="mx-1.5 opacity-30">·</span>
|
||||||
|
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true, locale })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Button to open the full modal (optional, but good to keep if user wants diff) */}
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-xl border border-border/40 hover:bg-muted/50 transition-colors text-left group mt-4"
|
||||||
onClick={() => setShowHistory(true)}
|
onClick={() => setShowHistory(true)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
|
<div className="w-8 h-8 rounded-full bg-primary/5 flex items-center justify-center text-primary group-hover:bg-primary/10 transition-colors">
|
||||||
|
<HistoryIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Voir l'historique</p>
|
<p className="text-xs font-bold uppercase tracking-wider">Mode Comparaison</p>
|
||||||
<p className="text-[11px] text-muted-foreground">Comparer et restaurer des versions</p>
|
<p className="text-[10px] text-muted-foreground">Comparer les versions côte à côte</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
38
memento-note/components/note-editor/index.tsx
Normal file
38
memento-note/components/note-editor/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { NoteEditorFullPage } from './note-editor-full-page'
|
||||||
|
import { NoteEditorDialog } from './note-editor-dialog'
|
||||||
|
import { Note } from '@/lib/types'
|
||||||
|
|
||||||
|
interface NoteEditorProps {
|
||||||
|
note: Note
|
||||||
|
readOnly?: boolean
|
||||||
|
onClose: () => void
|
||||||
|
fullPage?: boolean
|
||||||
|
onNoteSaved?: (savedNote: Note) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditor({ note, readOnly, onClose, fullPage = false, onNoteSaved }: NoteEditorProps) {
|
||||||
|
return (
|
||||||
|
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage} onNoteSaved={onNoteSaved}>
|
||||||
|
{fullPage ? (
|
||||||
|
<NoteEditorFullPage onClose={onClose} />
|
||||||
|
) : (
|
||||||
|
<NoteEditorDialog onClose={onClose} />
|
||||||
|
)}
|
||||||
|
</NoteEditorProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export context hook for backwards compatibility
|
||||||
|
export { useNoteEditorContext } from './note-editor-context'
|
||||||
|
|
||||||
|
// Re-export sub-components for advanced usage
|
||||||
|
export { NoteEditorFullPage } from './note-editor-full-page'
|
||||||
|
export { NoteEditorDialog } from './note-editor-dialog'
|
||||||
|
export { NoteEditorProvider } from './note-editor-context'
|
||||||
|
export { NoteTitleBlock } from './note-title-block'
|
||||||
|
export { NoteContentArea } from './note-content-area'
|
||||||
|
export { NoteMetadataSection } from './note-metadata-section'
|
||||||
|
export { NoteEditorToolbar } from './note-editor-toolbar'
|
||||||
179
memento-note/components/note-editor/note-content-area.tsx
Normal file
179
memento-note/components/note-editor/note-content-area.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { RichTextEditor } from '@/components/rich-text-editor'
|
||||||
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
|
import { MarkdownSlashCommands } from '@/components/markdown-slash-commands'
|
||||||
|
import { GhostTags } from '@/components/ghost-tags'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { X, Plus } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function NoteContentArea() {
|
||||||
|
const { state, actions, readOnly, fullPage, textareaRef } = useNoteEditorContext()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
const uploadImageFile = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
if (!response.ok) throw new Error('Upload failed')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.noteType === 'richtext') {
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<div className="fullpage-editor">
|
||||||
|
<RichTextEditor
|
||||||
|
content={state.content}
|
||||||
|
onChange={(v: string) => actions.setContent(v)}
|
||||||
|
className="min-h-[280px]"
|
||||||
|
onImageUpload={uploadImageFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RichTextEditor
|
||||||
|
content={state.content}
|
||||||
|
onChange={actions.setContent}
|
||||||
|
className="min-h-[200px]"
|
||||||
|
onImageUpload={uploadImageFile}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.noteType === 'markdown' && state.showMarkdownPreview) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-h-[280px] cursor-text prose prose-lg dark:prose-invert max-w-none leading-relaxed',
|
||||||
|
fullPage ? '' : 'p-3 rounded-md border border-border/40 bg-muted/20'
|
||||||
|
)}
|
||||||
|
onClick={() => !readOnly && actions.setShowMarkdownPreview(false)}
|
||||||
|
>
|
||||||
|
<MarkdownContent content={state.content} />
|
||||||
|
{!readOnly && (
|
||||||
|
<p className="text-[11px] text-foreground/30 mt-8 select-none not-prose italic">
|
||||||
|
Cliquez pour éditer
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.noteType === 'markdown' || state.noteType === 'text') {
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
dir="auto"
|
||||||
|
placeholder={t('notes.takeNote') || "Commencez à écrire… tapez '/' pour les commandes"}
|
||||||
|
value={state.content}
|
||||||
|
onFocus={() => actions.setShowMarkdownPreview(false)}
|
||||||
|
onChange={(e) => actions.setContent(e.target.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="w-full min-h-[280px] border-0 outline-none px-0 bg-transparent editor-body leading-relaxed resize-none overflow-hidden placeholder:text-foreground/30 text-foreground"
|
||||||
|
/>
|
||||||
|
{!readOnly && (
|
||||||
|
<MarkdownSlashCommands
|
||||||
|
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
value={state.content}
|
||||||
|
onChange={(v: string) => actions.setContent(v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog mode
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
dir="auto"
|
||||||
|
placeholder={state.isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
|
||||||
|
value={state.content}
|
||||||
|
onChange={(e) => actions.setContent(e.target.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none text-sm leading-relaxed",
|
||||||
|
readOnly && "cursor-default"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<GhostTags
|
||||||
|
suggestions={state.filteredSuggestions}
|
||||||
|
addedTags={state.labels}
|
||||||
|
isAnalyzing={state.isAnalyzingSuggestions}
|
||||||
|
onSelectTag={actions.handleSelectGhostTag}
|
||||||
|
onDismissTag={actions.handleDismissGhostTag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checklist mode
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{state.checkItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-2 group">
|
||||||
|
<Checkbox
|
||||||
|
checked={item.checked}
|
||||||
|
onCheckedChange={() => actions.handleCheckItem(item.id)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
|
||||||
|
placeholder={t('notes.listItem')}
|
||||||
|
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
||||||
|
onClick={() => actions.handleRemoveCheckItem(item.id)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('notes.addItem')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{state.checkItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-2 group">
|
||||||
|
<Checkbox
|
||||||
|
checked={item.checked}
|
||||||
|
onCheckedChange={() => actions.handleCheckItem(item.id)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
|
||||||
|
placeholder={t('notes.listItem')}
|
||||||
|
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
||||||
|
onClick={() => actions.handleRemoveCheckItem(item.id)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('notes.addItem')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
844
memento-note/components/note-editor/note-editor-context.tsx
Normal file
844
memento-note/components/note-editor/note-editor-context.tsx
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType, NoteSize } from '@/lib/types'
|
||||||
|
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
|
||||||
|
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||||
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
|
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||||
|
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
|
import { extractImagesFromHTML } from '@/lib/utils'
|
||||||
|
import { queryKeys } from '@/lib/query-keys'
|
||||||
|
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||||
|
import type { TagSuggestion } from '@/lib/ai/types'
|
||||||
|
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
|
||||||
|
|
||||||
|
const NoteEditorContext = createContext<NoteEditorContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
interface NoteEditorProviderProps {
|
||||||
|
note: Note
|
||||||
|
readOnly?: boolean
|
||||||
|
fullPage?: boolean
|
||||||
|
onNoteSaved?: (savedNote: Note) => void
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, onNoteSaved, children }: NoteEditorProviderProps) {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
|
||||||
|
const { triggerRefresh } = useNoteRefresh()
|
||||||
|
|
||||||
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||||
|
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user?.id) {
|
||||||
|
getAISettings(session.user.id).then(settings => {
|
||||||
|
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
||||||
|
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
||||||
|
}).catch(err => console.error("Failed to fetch AI settings", err))
|
||||||
|
}
|
||||||
|
}, [session?.user?.id])
|
||||||
|
|
||||||
|
// Core content state
|
||||||
|
const [title, setTitle] = useState(note.title || '')
|
||||||
|
const [content, setContent] = useState(note.content)
|
||||||
|
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||||
|
const [labels, setLabels] = useState<string[]>(note.labels || [])
|
||||||
|
const [images, setImages] = useState<string[]>(note.images || [])
|
||||||
|
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
|
||||||
|
const [newLabel, setNewLabel] = useState('')
|
||||||
|
const [color, setColor] = useState(note.color)
|
||||||
|
const [size, setSize] = useState<NoteSize>(note.size || 'small')
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
|
||||||
|
const [noteType, setNoteType] = useState<NoteType>(note.type)
|
||||||
|
const isMarkdown = noteType === 'markdown'
|
||||||
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const prevNoteRef = useRef(note)
|
||||||
|
|
||||||
|
// CRITICAL: Sync state when note.id changes (lines 101-116 from original)
|
||||||
|
useEffect(() => {
|
||||||
|
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
|
||||||
|
setTitle(note.title || '')
|
||||||
|
setContent(note.content)
|
||||||
|
setCheckItems(note.checkItems || [])
|
||||||
|
setLabels(note.labels || [])
|
||||||
|
setImages(note.images || [])
|
||||||
|
setLinks(note.links || [])
|
||||||
|
setColor(note.color)
|
||||||
|
setSize(note.size || 'small')
|
||||||
|
setNoteType(note.type)
|
||||||
|
setShowMarkdownPreview(note.type === 'markdown')
|
||||||
|
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
||||||
|
}
|
||||||
|
prevNoteRef.current = note
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
// Update context notebookId when note changes
|
||||||
|
useEffect(() => {
|
||||||
|
setContextNotebookId(note.notebookId || null)
|
||||||
|
}, [note.notebookId, setContextNotebookId])
|
||||||
|
|
||||||
|
// Auto-tagging hook
|
||||||
|
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
|
||||||
|
content: noteType !== 'checklist' ? content : '',
|
||||||
|
notebookId: note.notebookId,
|
||||||
|
enabled: noteType !== 'checklist' && autoLabelingEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reminder state
|
||||||
|
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||||
|
const [currentReminder, setCurrentReminder] = useState<Date | null>(
|
||||||
|
note.reminder ? new Date(note.reminder as unknown as string) : null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Link state
|
||||||
|
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||||||
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
|
|
||||||
|
// Title suggestions state
|
||||||
|
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
|
||||||
|
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
|
||||||
|
|
||||||
|
// Reformulation state
|
||||||
|
const [isReformulating, setIsReformulating] = useState(false)
|
||||||
|
const [reformulationModal, setReformulationModal] = useState<{
|
||||||
|
originalText: string
|
||||||
|
reformulatedText: string
|
||||||
|
option: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// AI processing state
|
||||||
|
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
||||||
|
const [aiOpen, setAiOpen] = useState(false)
|
||||||
|
const [infoOpen, setInfoOpen] = useState(false)
|
||||||
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
|
||||||
|
// fullPage — auto title suggestions
|
||||||
|
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
||||||
|
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
|
||||||
|
content,
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Memory Echo Connections state
|
||||||
|
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
||||||
|
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||||
|
|
||||||
|
// Tags dismissed by the user for this session
|
||||||
|
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Filter suggestions to exclude dismissed ones
|
||||||
|
// and those already present on the note
|
||||||
|
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
|
||||||
|
const filteredSuggestions = suggestions.filter(s => {
|
||||||
|
if (!s || !s.tag) return false
|
||||||
|
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
|
||||||
|
|
||||||
|
const uploadImageFile = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
if (!response.ok) throw new Error('Upload failed')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
const url = await uploadImageFile(file)
|
||||||
|
setImages(prev => prev.includes(url) ? prev : [...prev, url])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error)
|
||||||
|
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste handler: upload clipboard images
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
try {
|
||||||
|
const url = await uploadImageFile(file)
|
||||||
|
setImages(prev => prev.includes(url) ? prev : [...prev, url])
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('paste', handlePaste, { capture: true })
|
||||||
|
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||||
|
}, [t, noteType])
|
||||||
|
|
||||||
|
// Auto-grow textarea as content grows
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
// Also auto-grow when switching FROM preview TO edit mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (showMarkdownPreview) return // we're in preview, textarea not mounted
|
||||||
|
// Defer one frame so the textarea is in the DOM
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
|
||||||
|
el.focus()
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [showMarkdownPreview])
|
||||||
|
|
||||||
|
const handleRemoveImage = (index: number) => {
|
||||||
|
const removedUrl = images[index]
|
||||||
|
setImages(images.filter((_, i) => i !== index))
|
||||||
|
// Track removed images for cleanup on save
|
||||||
|
if (removedUrl) {
|
||||||
|
setRemovedImageUrls(prev => [...prev, removedUrl])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddLink = async () => {
|
||||||
|
if (!linkUrl) return
|
||||||
|
|
||||||
|
setShowLinkDialog(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await fetchLinkMetadata(linkUrl)
|
||||||
|
if (metadata) {
|
||||||
|
setLinks(prev => [...prev, metadata])
|
||||||
|
toast.success(t('notes.linkAdded'))
|
||||||
|
} else {
|
||||||
|
toast.warning(t('notes.linkMetadataFailed'))
|
||||||
|
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add link:', error)
|
||||||
|
toast.error(t('notes.linkAddFailed'))
|
||||||
|
} finally {
|
||||||
|
setLinkUrl('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveLink = (index: number) => {
|
||||||
|
setLinks(links.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const allImages = useMemo(() => {
|
||||||
|
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
||||||
|
return Array.from(new Set([...images, ...extracted]));
|
||||||
|
}, [images, content, noteType]);
|
||||||
|
|
||||||
|
const handleGenerateTitles = async () => {
|
||||||
|
const fullContentForAI = [
|
||||||
|
content,
|
||||||
|
...links.map(l => `${l.title || ''} ${l.description || ''}`)
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const wordCount = fullContentForAI.split(/\s+/).filter(word => word.length > 0).length
|
||||||
|
|
||||||
|
if (wordCount < 10) {
|
||||||
|
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingTitles(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/title-suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: fullContentForAI }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || t('ai.titleGenerationError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setTitleSuggestions(data.suggestions || [])
|
||||||
|
// 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'))
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingTitles(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectTitle = (title: string) => {
|
||||||
|
setTitle(title)
|
||||||
|
setTitleSuggestions([])
|
||||||
|
toast.success(t('ai.titleApplied'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReformulate = async (option: 'clarify' | 'shorten' | 'improve') => {
|
||||||
|
const selectedText = window.getSelection()?.toString()
|
||||||
|
|
||||||
|
if (!selectedText && (!content || content.trim().length === 0)) {
|
||||||
|
toast.error(t('ai.reformulationNoText'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let textToReformulate: string
|
||||||
|
if (selectedText && selectedText.trim().split(/\s+/).filter(word => word.length > 0).length >= 10) {
|
||||||
|
textToReformulate = selectedText
|
||||||
|
} else {
|
||||||
|
textToReformulate = content
|
||||||
|
if (selectedText) {
|
||||||
|
toast.info(t('ai.reformulationSelectionTooShort'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCount = textToReformulate.trim().split(/\s+/).filter(word => word.length > 0).length
|
||||||
|
|
||||||
|
if (wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordCount > 500) {
|
||||||
|
toast.error(t('ai.reformulationMaxWords'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsReformulating(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/reformulate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: textToReformulate,
|
||||||
|
option: option
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || t('ai.reformulationError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
setReformulationModal({
|
||||||
|
originalText: data.originalText,
|
||||||
|
reformulatedText: data.reformulatedText,
|
||||||
|
option: data.option
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error reformulating:', error)
|
||||||
|
toast.error(error.message || t('ai.reformulationFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsReformulating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClarifyDirect = async () => {
|
||||||
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||||
|
if (!content || wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/reformulate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content, option: 'clarify' })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
|
||||||
|
setContent(data.reformulatedText || data.text)
|
||||||
|
toast.success(t('ai.reformulationApplied'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clarify error:', error)
|
||||||
|
toast.error(t('notes.clarifyFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingAI(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShortenDirect = async () => {
|
||||||
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||||
|
if (!content || wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/reformulate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content, option: 'shorten' })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
|
||||||
|
setContent(data.reformulatedText || data.text)
|
||||||
|
toast.success(t('ai.reformulationApplied'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Shorten error:', error)
|
||||||
|
toast.error(t('notes.shortenFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingAI(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImproveDirect = async () => {
|
||||||
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||||
|
if (!content || wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/reformulate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content, option: 'improve' })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
|
||||||
|
setContent(data.reformulatedText || data.text)
|
||||||
|
toast.success(t('ai.reformulationApplied'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Improve error:', error)
|
||||||
|
toast.error(t('notes.improveFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingAI(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTransformMarkdown = async () => {
|
||||||
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||||
|
if (!content || wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordCount > 500) {
|
||||||
|
toast.error(t('ai.reformulationMaxWords'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/transform-markdown', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
||||||
|
|
||||||
|
setContent(data.transformedText)
|
||||||
|
setNoteType('markdown')
|
||||||
|
setShowMarkdownPreview(false)
|
||||||
|
|
||||||
|
toast.success(t('ai.transformSuccess'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Transform to markdown error:', error)
|
||||||
|
toast.error(t('ai.transformError'))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingAI(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyRefactor = () => {
|
||||||
|
if (!reformulationModal) return
|
||||||
|
|
||||||
|
const selectedText = window.getSelection()?.toString()
|
||||||
|
if (selectedText) {
|
||||||
|
setContent(reformulationModal.reformulatedText)
|
||||||
|
} else {
|
||||||
|
setContent(reformulationModal.reformulatedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
setReformulationModal(null)
|
||||||
|
toast.success(t('ai.reformulationApplied'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReminderSave = async (date: Date) => {
|
||||||
|
if (date < new Date()) {
|
||||||
|
toast.error(t('notes.reminderPastError'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCurrentReminder(date)
|
||||||
|
try {
|
||||||
|
await updateNote(note.id, { reminder: date })
|
||||||
|
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notebook.savingReminder'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveReminder = async () => {
|
||||||
|
setCurrentReminder(null)
|
||||||
|
try {
|
||||||
|
await updateNote(note.id, { reminder: null })
|
||||||
|
toast.success(t('notes.reminderRemoved'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notebook.removingReminder'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
console.log('[SAVE] handleSave called, note.id:', note.id)
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
console.log('[SAVE] Calling updateNote...')
|
||||||
|
const result = await updateNote(note.id, {
|
||||||
|
title: title.trim() || null,
|
||||||
|
content: noteType !== 'checklist' ? content : '',
|
||||||
|
checkItems: noteType === 'checklist' ? checkItems : null,
|
||||||
|
labels,
|
||||||
|
images,
|
||||||
|
links,
|
||||||
|
color,
|
||||||
|
reminder: currentReminder,
|
||||||
|
isMarkdown: noteType === 'markdown',
|
||||||
|
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()
|
||||||
|
toast.success(t('notes.saved') || 'Note sauvegardée !')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SAVE] updateNote failed:', error)
|
||||||
|
toast.error(t('notes.saveFailed') || 'Erreur lors de la sauvegarde.')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckItem = (id: string) => {
|
||||||
|
setCheckItems(items =>
|
||||||
|
items.map(item =>
|
||||||
|
item.id === id ? { ...item, checked: !item.checked } : item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateCheckItem = (id: string, text: string) => {
|
||||||
|
setCheckItems(items =>
|
||||||
|
items.map(item => (item.id === id ? { ...item, text } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddCheckItem = () => {
|
||||||
|
setCheckItems([
|
||||||
|
...checkItems,
|
||||||
|
{ id: Date.now().toString(), text: '', checked: false },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCheckItem = (id: string) => {
|
||||||
|
setCheckItems(items => items.filter(item => item.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectGhostTag = async (tag: string) => {
|
||||||
|
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
|
||||||
|
|
||||||
|
if (!tagExists) {
|
||||||
|
setLabels(prev => [...prev, tag])
|
||||||
|
|
||||||
|
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
|
||||||
|
if (!globalExists) {
|
||||||
|
try {
|
||||||
|
await addLabel(tag)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating auto-label:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(t('ai.tagAdded', { tag }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismissGhostTag = (tag: string) => {
|
||||||
|
setDismissedTags(prev => [...prev, tag])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveLabel = (label: string) => {
|
||||||
|
setLabels(labels.filter(l => l !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMakeCopy = async () => {
|
||||||
|
try {
|
||||||
|
const newNote = await createNote({
|
||||||
|
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
||||||
|
content: content,
|
||||||
|
color: color,
|
||||||
|
checkItems: checkItems,
|
||||||
|
labels: labels,
|
||||||
|
images: images,
|
||||||
|
links: links,
|
||||||
|
isMarkdown: noteType === 'markdown',
|
||||||
|
type: noteType,
|
||||||
|
size: size,
|
||||||
|
})
|
||||||
|
toast.success(t('notes.copySuccess'))
|
||||||
|
// Invalidate notes list cache for current notebook
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||||
|
triggerRefresh()
|
||||||
|
// Note: onClose is handled by the composition component
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy note:', error)
|
||||||
|
toast.error(t('notes.copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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,
|
||||||
|
labels,
|
||||||
|
images,
|
||||||
|
links,
|
||||||
|
color,
|
||||||
|
reminder: currentReminder,
|
||||||
|
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) })
|
||||||
|
triggerRefresh()
|
||||||
|
setIsDirty(false)
|
||||||
|
toast.success('Note sauvegardée !')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SAVE] updateNote failed:', error)
|
||||||
|
toast.error('Erreur lors de la sauvegarde.')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullPage) return
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSaveInPlace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [fullPage, isSaving])
|
||||||
|
|
||||||
|
// Build state object
|
||||||
|
const state: NoteEditorState = useMemo(() => ({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
checkItems,
|
||||||
|
labels,
|
||||||
|
images,
|
||||||
|
links,
|
||||||
|
newLabel,
|
||||||
|
color: color as NoteColor,
|
||||||
|
size,
|
||||||
|
noteType,
|
||||||
|
showMarkdownPreview,
|
||||||
|
removedImageUrls,
|
||||||
|
isSaving,
|
||||||
|
isDirty,
|
||||||
|
isProcessingAI,
|
||||||
|
aiOpen,
|
||||||
|
infoOpen,
|
||||||
|
isGeneratingTitles,
|
||||||
|
titleSuggestions,
|
||||||
|
dismissedTitleSuggestions,
|
||||||
|
isReformulating,
|
||||||
|
reformulationModal,
|
||||||
|
previousContentForCopilot,
|
||||||
|
showReminderDialog,
|
||||||
|
currentReminder,
|
||||||
|
showLinkDialog,
|
||||||
|
linkUrl,
|
||||||
|
comparisonNotes,
|
||||||
|
fusionNotes,
|
||||||
|
dismissedTags,
|
||||||
|
filteredSuggestions,
|
||||||
|
isAnalyzingSuggestions,
|
||||||
|
isMarkdown,
|
||||||
|
allImages,
|
||||||
|
colorClasses,
|
||||||
|
}), [
|
||||||
|
title, content, checkItems, labels, images, links, newLabel, color, size, noteType,
|
||||||
|
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
|
||||||
|
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||||
|
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||||
|
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
|
||||||
|
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
|
||||||
|
])
|
||||||
|
|
||||||
|
// 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) },
|
||||||
|
setCheckItems,
|
||||||
|
handleCheckItem,
|
||||||
|
handleUpdateCheckItem,
|
||||||
|
handleAddCheckItem,
|
||||||
|
handleRemoveCheckItem,
|
||||||
|
setLabels: (l) => { setLabels(l); setIsDirty(true) },
|
||||||
|
handleSelectGhostTag,
|
||||||
|
handleDismissGhostTag,
|
||||||
|
handleRemoveLabel,
|
||||||
|
setImages,
|
||||||
|
handleImageUpload,
|
||||||
|
handleRemoveImage,
|
||||||
|
uploadImageFile,
|
||||||
|
setLinks,
|
||||||
|
handleAddLink,
|
||||||
|
handleRemoveLink,
|
||||||
|
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
|
||||||
|
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
|
||||||
|
setColor: (c) => { setColor(c); setIsDirty(true) },
|
||||||
|
setSize: (s) => { setSize(s); setIsDirty(true) },
|
||||||
|
setShowReminderDialog,
|
||||||
|
setCurrentReminder,
|
||||||
|
handleReminderSave,
|
||||||
|
handleRemoveReminder,
|
||||||
|
setShowLinkDialog,
|
||||||
|
setLinkUrl,
|
||||||
|
handleGenerateTitles,
|
||||||
|
handleSelectTitle,
|
||||||
|
handleReformulate,
|
||||||
|
handleApplyRefactor,
|
||||||
|
handleClarifyDirect,
|
||||||
|
handleShortenDirect,
|
||||||
|
handleImproveDirect,
|
||||||
|
handleTransformMarkdown,
|
||||||
|
handleSave,
|
||||||
|
handleSaveInPlace,
|
||||||
|
handleMakeCopy,
|
||||||
|
setComparisonNotes,
|
||||||
|
setFusionNotes,
|
||||||
|
setReformulationModal,
|
||||||
|
setIsDirty,
|
||||||
|
setAiOpen,
|
||||||
|
setInfoOpen,
|
||||||
|
setIsProcessingAI,
|
||||||
|
setIsGeneratingTitles,
|
||||||
|
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
|
||||||
|
setPreviousContentForCopilot,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const value: NoteEditorContextValue = useMemo(() => ({
|
||||||
|
note,
|
||||||
|
readOnly,
|
||||||
|
fullPage,
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
notebooks: notebooks.map(nb => ({ id: nb.id, name: nb.name })),
|
||||||
|
globalLabels,
|
||||||
|
fileInputRef,
|
||||||
|
textareaRef,
|
||||||
|
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoteEditorContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</NoteEditorContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNoteEditorContext() {
|
||||||
|
const context = useContext(NoteEditorContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
345
memento-note/components/note-editor/note-editor-dialog.tsx
Normal file
345
memento-note/components/note-editor/note-editor-dialog.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { NoteTitleBlock } from './note-title-block'
|
||||||
|
import { NoteContentArea } from './note-content-area'
|
||||||
|
import { NoteMetadataSection } from './note-metadata-section'
|
||||||
|
import { EditorImages } from '@/components/editor-images'
|
||||||
|
import { ComparisonModal } from '@/components/comparison-modal'
|
||||||
|
import { FusionModal } from '@/components/fusion-modal'
|
||||||
|
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||||
|
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||||
|
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Note } from '@/lib/types'
|
||||||
|
|
||||||
|
interface NoteEditorDialogProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||||
|
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
const handleSaveAndClose = async () => {
|
||||||
|
await actions.handleSave()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
|
||||||
|
state.colorClasses
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto space-y-4 px-6 py-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="sr-only">{t('notes.edit')}</DialogTitle>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
|
||||||
|
</div>
|
||||||
|
{readOnly && (
|
||||||
|
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
|
||||||
|
{t('notes.readOnly')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<NoteTitleBlock />
|
||||||
|
|
||||||
|
{/* Title Suggestions */}
|
||||||
|
{!readOnly && state.titleSuggestions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{/* TitleSuggestions component */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
<EditorImages images={state.images} onRemove={actions.handleRemoveImage} />
|
||||||
|
|
||||||
|
{/* Link Previews */}
|
||||||
|
{state.links.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{state.links.map((link, idx) => (
|
||||||
|
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
|
||||||
|
{link.imageUrl && (
|
||||||
|
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
||||||
|
)}
|
||||||
|
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
|
||||||
|
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
|
||||||
|
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
|
||||||
|
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary truncate hover:underline block mt-1">
|
||||||
|
{new URL(link.url).hostname}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
|
||||||
|
onClick={() => actions.handleRemoveLink(idx)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<NoteContentArea />
|
||||||
|
|
||||||
|
{/* Metadata Section */}
|
||||||
|
<NoteMetadataSection />
|
||||||
|
|
||||||
|
{/* Memory Echo Connections Section */}
|
||||||
|
{!readOnly && (
|
||||||
|
<EditorConnectionsSection
|
||||||
|
noteId={note.id}
|
||||||
|
onOpenNote={(noteId: string) => {
|
||||||
|
onClose()
|
||||||
|
window.location.href = `/?note=${noteId}`
|
||||||
|
}}
|
||||||
|
onCompareNotes={(noteIds: string[]) => {
|
||||||
|
Promise.all(noteIds.map(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${id}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`Failed to fetch note ${id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching note ${id}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.then(notes => notes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||||
|
.then(fetchedNotes => {
|
||||||
|
actions.setComparisonNotes(fetchedNotes)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onMergeNotes={async (noteIds: string[]) => {
|
||||||
|
const fetchedNotes = await Promise.all(noteIds.map(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${id}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`Failed to fetch note ${id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching note ${id}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
actions.setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog Toolbar - inline for now */}
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('general.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveAndClose} disabled={state.isSaving}>
|
||||||
|
{state.isSaving ? t('notes.saving') : t('general.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={actions.handleImageUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── AI Copilot Side Panel ── */}
|
||||||
|
{state.aiOpen && (
|
||||||
|
<ContextualAIChat
|
||||||
|
onClose={() => actions.setAiOpen(false)}
|
||||||
|
noteTitle={state.title}
|
||||||
|
noteContent={state.content}
|
||||||
|
noteImages={state.allImages}
|
||||||
|
noteId={note.id}
|
||||||
|
onApplyToNote={(newContent: string) => {
|
||||||
|
actions.setPreviousContentForCopilot(state.content)
|
||||||
|
actions.setContent(newContent)
|
||||||
|
}}
|
||||||
|
onUndoLastAction={state.previousContentForCopilot !== null ? () => {
|
||||||
|
if (state.previousContentForCopilot !== null) {
|
||||||
|
actions.setContent(state.previousContentForCopilot)
|
||||||
|
}
|
||||||
|
actions.setPreviousContentForCopilot(null)
|
||||||
|
} : undefined}
|
||||||
|
lastActionApplied={state.previousContentForCopilot !== null}
|
||||||
|
notebooks={notebooks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Reminder Dialog */}
|
||||||
|
<ReminderDialog
|
||||||
|
open={state.showReminderDialog}
|
||||||
|
onOpenChange={actions.setShowReminderDialog}
|
||||||
|
currentReminder={state.currentReminder}
|
||||||
|
onSave={actions.handleReminderSave}
|
||||||
|
onRemove={actions.handleRemoveReminder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Link Dialog */}
|
||||||
|
<Dialog open={state.showLinkDialog} onOpenChange={actions.setShowLinkDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('notes.addLink')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com"
|
||||||
|
value={state.linkUrl}
|
||||||
|
onChange={(e) => actions.setLinkUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
actions.handleAddLink()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => actions.setShowLinkDialog(false)}>
|
||||||
|
{t('general.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={actions.handleAddLink}>
|
||||||
|
{t('general.add')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Reformulation Modal */}
|
||||||
|
{state.reformulationModal && (
|
||||||
|
<Dialog open={!!state.reformulationModal} onOpenChange={() => actions.setReformulationModal(null)}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('ai.reformulationComparison')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 text-sm text-gray-600 dark:text-gray-400">{t('ai.original')}</h3>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-sm">
|
||||||
|
{state.reformulationModal.originalText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 text-sm text-purple-600 dark:text-purple-400">
|
||||||
|
{t('ai.reformulated')} ({state.reformulationModal.option})
|
||||||
|
</h3>
|
||||||
|
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-sm">
|
||||||
|
{state.reformulationModal.reformulatedText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => actions.setReformulationModal(null)}>
|
||||||
|
{t('general.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={actions.handleApplyRefactor}>
|
||||||
|
{t('general.apply')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparison Modal */}
|
||||||
|
{state.comparisonNotes && state.comparisonNotes.length > 0 && (
|
||||||
|
<ComparisonModal
|
||||||
|
isOpen={!!state.comparisonNotes}
|
||||||
|
onClose={() => actions.setComparisonNotes([])}
|
||||||
|
notes={state.comparisonNotes}
|
||||||
|
onOpenNote={(noteId: string) => {
|
||||||
|
onClose()
|
||||||
|
window.location.href = `/?note=${noteId}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fusion Modal */}
|
||||||
|
{state.fusionNotes && state.fusionNotes.length > 0 && (
|
||||||
|
<FusionModal
|
||||||
|
isOpen={!!state.fusionNotes}
|
||||||
|
onClose={() => actions.setFusionNotes([])}
|
||||||
|
notes={state.fusionNotes}
|
||||||
|
onConfirmFusion={async ({ title, content }: { title: string; content: string }, options: { keepAllTags: boolean; archiveOriginals: boolean }) => {
|
||||||
|
// Save current first
|
||||||
|
await actions.handleSave()
|
||||||
|
|
||||||
|
// Use createNote directly since handleMakeCopy doesn't handle fusion
|
||||||
|
const { createNote } = await import('@/app/actions/notes')
|
||||||
|
await createNote({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
labels: options.keepAllTags
|
||||||
|
? [...new Set(state.fusionNotes.flatMap(n => n.labels || []))]
|
||||||
|
: state.fusionNotes[0].labels || [],
|
||||||
|
color: state.fusionNotes[0].color,
|
||||||
|
type: 'text',
|
||||||
|
isMarkdown: true,
|
||||||
|
autoGenerated: true,
|
||||||
|
aiProvider: 'fusion',
|
||||||
|
notebookId: state.fusionNotes[0].notebookId ?? undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// Archive original notes if option is selected
|
||||||
|
if (options.archiveOriginals) {
|
||||||
|
const { updateNote } = await import('@/app/actions/notes')
|
||||||
|
for (const fusionNote of state.fusionNotes) {
|
||||||
|
if (fusionNote.id) {
|
||||||
|
await updateNote(fusionNote.id, { isArchived: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(t('toast.notesFusionSuccess'))
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
memento-note/components/note-editor/note-editor-full-page.tsx
Normal file
146
memento-note/components/note-editor/note-editor-full-page.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { NoteEditorToolbar } from './note-editor-toolbar'
|
||||||
|
import { NoteTitleBlock } from './note-title-block'
|
||||||
|
import { NoteContentArea } from './note-content-area'
|
||||||
|
import { EditorImages } from '@/components/editor-images'
|
||||||
|
import { ComparisonModal } from '@/components/comparison-modal'
|
||||||
|
import { FusionModal } from '@/components/fusion-modal'
|
||||||
|
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||||
|
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||||
|
import { NoteDocumentInfoPanel } from '@/components/note-document-info-panel'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Note } from '@/lib/types'
|
||||||
|
|
||||||
|
interface NoteEditorFullPageProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||||
|
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
|
||||||
|
|
||||||
|
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── outer container ── */}
|
||||||
|
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
|
||||||
|
|
||||||
|
{/* ── main scrollable column ── */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-zinc-950">
|
||||||
|
|
||||||
|
{/* TOOLBAR */}
|
||||||
|
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
|
||||||
|
|
||||||
|
{/* BODY — max-w-4xl, responsive px, py-16 */}
|
||||||
|
<div className="max-w-4xl mx-auto w-full px-6 sm:px-12 py-16 space-y-12 min-w-0">
|
||||||
|
|
||||||
|
{/* Breadcrumb + Title block */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Breadcrumb: Notebook › Date */}
|
||||||
|
<div className="flex items-center gap-3 text-[12px] text-foreground/50 uppercase tracking-[.25em] font-bold">
|
||||||
|
{notebookName && <span>{notebookName}</span>}
|
||||||
|
{notebookName && <span>›</span>}
|
||||||
|
<span suppressHydrationWarning>
|
||||||
|
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<NoteTitleBlock />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero image — show first note image if present */}
|
||||||
|
{state.allImages.length > 0 && (
|
||||||
|
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src={state.allImages[0]}
|
||||||
|
alt={state.title}
|
||||||
|
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content area — max-w-3xl for wider reading column */}
|
||||||
|
<div className="max-w-3xl mx-auto w-full pb-32">
|
||||||
|
<NoteContentArea />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Side panel: AI Chat ── */}
|
||||||
|
{state.aiOpen && (
|
||||||
|
<div className="h-full self-stretch bg-background flex flex-col z-50 shrink-0">
|
||||||
|
<ContextualAIChat
|
||||||
|
onClose={() => actions.setAiOpen(false)}
|
||||||
|
noteTitle={state.title}
|
||||||
|
noteContent={state.content}
|
||||||
|
noteImages={state.allImages}
|
||||||
|
noteId={note.id}
|
||||||
|
onApplyToNote={(nc: string) => {
|
||||||
|
actions.setPreviousContentForCopilot(state.content)
|
||||||
|
actions.setContent(nc)
|
||||||
|
if (state.noteType === 'markdown') actions.setShowMarkdownPreview(true)
|
||||||
|
}}
|
||||||
|
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
|
||||||
|
lastActionApplied={state.previousContentForCopilot !== null}
|
||||||
|
notebooks={notebooks}
|
||||||
|
diagramInsertFormat={state.noteType === 'richtext' ? 'html' : 'markdown'}
|
||||||
|
onGenerateTitle={async () => {
|
||||||
|
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||||
|
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||||
|
if (wordCount < 10) {
|
||||||
|
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/title-suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: plain }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const s = data.suggestions?.[0]?.title ?? ''
|
||||||
|
if (s) {
|
||||||
|
actions.setTitle(s)
|
||||||
|
toast.success('Titre généré !')
|
||||||
|
} else {
|
||||||
|
toast.error('Impossible de générer un titre.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Erreur lors de la génération du titre.')
|
||||||
|
}
|
||||||
|
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Side panel: Document Info ── */}
|
||||||
|
{state.infoOpen && (
|
||||||
|
<div className="w-[400px] h-full self-stretch border-l border-black/10 dark:border-white/10 bg-background flex flex-col z-50 shrink-0">
|
||||||
|
<NoteDocumentInfoPanel
|
||||||
|
note={note}
|
||||||
|
content={state.content}
|
||||||
|
onClose={() => actions.setInfoOpen(false)}
|
||||||
|
onNoteRestored={(r: Note) => { actions.setContent(r.content || ''); actions.setTitle(r.title || ''); actions.setIsDirty(false) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={actions.handleImageUpload} />
|
||||||
|
<ReminderDialog
|
||||||
|
open={state.showReminderDialog}
|
||||||
|
onOpenChange={actions.setShowReminderDialog}
|
||||||
|
currentReminder={state.currentReminder}
|
||||||
|
onSave={actions.handleReminderSave}
|
||||||
|
onRemove={actions.handleRemoveReminder}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
397
memento-note/components/note-editor/note-editor-toolbar.tsx
Normal file
397
memento-note/components/note-editor/note-editor-toolbar.tsx
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
'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'
|
||||||
|
import { GhostTags } from '@/components/ghost-tags'
|
||||||
|
import { EditorImages } from '@/components/editor-images'
|
||||||
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||||
|
import { NoteTypeSelector } from '@/components/note-type-selector'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
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, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||||
|
Trash2, LogOut, Wand2, Share2
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { NoteShareDialog } from './note-share-dialog'
|
||||||
|
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||||
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
interface NoteEditorToolbarProps {
|
||||||
|
mode: 'fullPage' | 'dialog'
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||||
|
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const { refreshNotes } = useRefresh()
|
||||||
|
const [isConverting, setIsConverting] = useState(false)
|
||||||
|
const [shareOpen, setShareOpen] = useState(false)
|
||||||
|
|
||||||
|
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||||
|
|
||||||
|
// 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">
|
||||||
|
{/* Left: back */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
<span className="text-sm font-medium">Back to collection</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Right: status + type + AI + Info */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Save status */}
|
||||||
|
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
||||||
|
{state.isSaving
|
||||||
|
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving…</span></>
|
||||||
|
: state.isDirty
|
||||||
|
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
|
||||||
|
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Note type */}
|
||||||
|
<NoteTypeSelector
|
||||||
|
value={state.noteType}
|
||||||
|
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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(
|
||||||
|
'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} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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(
|
||||||
|
'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} />
|
||||||
|
</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(
|
||||||
|
'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} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Share button */}
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
title="Partager la note"
|
||||||
|
aria-label="Partager la note"
|
||||||
|
onClick={() => setShareOpen(true)}
|
||||||
|
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
<Share2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Three-dot options menu */}
|
||||||
|
{!readOnly && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button aria-label="Menu des options" className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteNote(note.id)
|
||||||
|
refreshNotes(note.notebookId)
|
||||||
|
toast.success('Note supprimée.')
|
||||||
|
onClose()
|
||||||
|
} catch { toast.error('Impossible de supprimer.') }
|
||||||
|
}}
|
||||||
|
className="text-red-600 dark:text-red-400 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Supprimer la note
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Share Dialog portal */}
|
||||||
|
{shareOpen && (
|
||||||
|
<NoteShareDialog
|
||||||
|
noteId={note.id}
|
||||||
|
noteTitle={state.title}
|
||||||
|
onClose={() => setShareOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info panel toggle — rightmost, icon only */}
|
||||||
|
<button
|
||||||
|
aria-label="Informations du document"
|
||||||
|
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded-full border transition-all duration-300',
|
||||||
|
state.infoOpen
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PanelRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog toolbar
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
{/* Reminder */}
|
||||||
|
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
|
||||||
|
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{/* Add Image */}
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
|
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Add Link */}
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
|
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} />
|
||||||
|
|
||||||
|
{state.noteType === 'markdown' && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
|
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||||
|
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Copilot */}
|
||||||
|
{state.noteType !== 'checklist' && (
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
|
||||||
|
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">IA Note</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Size Selector */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<div className="flex flex-col gap-1 p-1">
|
||||||
|
{(['small', 'medium', 'large'] as const).map((s) => (
|
||||||
|
<Button key={s} variant="ghost" size="sm"
|
||||||
|
onClick={() => actions.setSize(s)}
|
||||||
|
className={cn('justify-start capitalize', state.size === s && 'bg-accent')}>
|
||||||
|
{s}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<div className="grid grid-cols-5 gap-2 p-2">
|
||||||
|
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
||||||
|
<button key={colorName}
|
||||||
|
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
|
||||||
|
state.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
|
||||||
|
onClick={() => actions.setColor(colorName as NoteColor)} title={colorName} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Label Manager */}
|
||||||
|
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{readOnly && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="text-xs">{t('notes.sharedReadOnly')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{readOnly ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={actions.handleMakeCopy}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
{t('notes.makeCopy')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await leaveSharedNote(note.id)
|
||||||
|
toast.success(t('notes.leftShare') || 'Share removed')
|
||||||
|
refreshNotes(note.notebookId)
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error(t('general.error'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
{t('notes.leaveShare')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('general.close')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('general.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={actions.handleSave} disabled={state.isSaving}>
|
||||||
|
{state.isSaving ? t('notes.saving') : t('general.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { LabelBadge } from '../label-badge'
|
||||||
|
import { GhostTags } from '../ghost-tags'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function NoteMetadataSection() {
|
||||||
|
const { state, actions, readOnly } = useNoteEditorContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Labels */}
|
||||||
|
{state.labels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{state.labels.map((label) => (
|
||||||
|
<LabelBadge
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
onRemove={() => actions.handleRemoveLabel(label)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ghost Tags - only show in dialog mode */}
|
||||||
|
{!readOnly && state.noteType !== 'richtext' && (
|
||||||
|
<GhostTags
|
||||||
|
suggestions={state.filteredSuggestions}
|
||||||
|
addedTags={state.labels}
|
||||||
|
isAnalyzing={state.isAnalyzingSuggestions}
|
||||||
|
onSelectTag={actions.handleSelectGhostTag}
|
||||||
|
onDismissTag={actions.handleDismissGhostTag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Color indicator */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-foreground/50">Color:</span>
|
||||||
|
<div className={cn('w-4 h-4 rounded-full', state.colorClasses?.bg || 'bg-gray-100')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size indicator */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-foreground/50">Size:</span>
|
||||||
|
<span className="text-xs capitalize">{state.size}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
222
memento-note/components/note-editor/note-share-dialog.tsx
Normal file
222
memento-note/components/note-editor/note-share-dialog.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { createShareRequest, removeCollaborator, getNoteCollaborators } from '@/app/actions/notes'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { X, UserPlus, Users, Mail, Trash2, Loader2, Share2, Check } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface Collaborator {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string | null
|
||||||
|
image: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteShareDialogProps {
|
||||||
|
noteId: string
|
||||||
|
noteTitle: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogProps) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [permission, setPermission] = useState<'view' | 'edit'>('view')
|
||||||
|
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
// Close on Escape
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const loadCollaborators = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await getNoteCollaborators(noteId)
|
||||||
|
setCollaborators(list as Collaborator[])
|
||||||
|
} catch {
|
||||||
|
// owner-only view — silently ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [noteId])
|
||||||
|
|
||||||
|
useEffect(() => { loadCollaborators() }, [loadCollaborators])
|
||||||
|
|
||||||
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmed = email.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
await createShareRequest(noteId, trimmed, permission)
|
||||||
|
setSent(true)
|
||||||
|
setEmail('')
|
||||||
|
toast.success(`Invitation envoyée à ${trimmed}`)
|
||||||
|
setTimeout(() => setSent(false), 2000)
|
||||||
|
loadCollaborators()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message || 'Erreur lors du partage'
|
||||||
|
if (msg.includes('not found')) toast.error('Aucun compte trouvé avec cet email.')
|
||||||
|
else if (msg.includes('already shared')) toast.error('Cette note est déjà partagée avec cet utilisateur.')
|
||||||
|
else toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = async (collaboratorId: string, collaboratorEmail: string | null) => {
|
||||||
|
setRemovingId(collaboratorId)
|
||||||
|
try {
|
||||||
|
await removeCollaborator(noteId, collaboratorId)
|
||||||
|
setCollaborators(prev => prev.filter(c => c.id !== collaboratorId))
|
||||||
|
toast.success(`Accès retiré à ${collaboratorEmail || "l'utilisateur"}`)
|
||||||
|
} catch {
|
||||||
|
toast.error("Impossible de retirer l'accès.")
|
||||||
|
} finally {
|
||||||
|
setRemovingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden border border-black/10 dark:border-white/10"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 pt-6 pb-4 border-b border-black/10 dark:border-white/10 flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Share2 size={15} className="text-[#75B2D6]" />
|
||||||
|
<h2 className="text-sm font-bold text-foreground tracking-tight">Partager</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-foreground/40 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite form */}
|
||||||
|
<form onSubmit={handleInvite} className="px-6 py-5 space-y-3">
|
||||||
|
<label className="text-[9px] uppercase tracking-[0.25em] font-bold text-foreground/40">
|
||||||
|
Inviter par email
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Mail size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/30" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-9 pr-3 py-2.5 text-[13px] rounded-xl border border-black/15 dark:border-white/15 bg-transparent outline-none focus:ring-2 ring-[#75B2D6]/30 focus:border-[#75B2D6] transition-all placeholder:text-foreground/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Permission toggle */}
|
||||||
|
<div className="flex rounded-xl border border-black/15 dark:border-white/15 overflow-hidden shrink-0">
|
||||||
|
{(['view', 'edit'] as const).map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPermission(p)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||||
|
permission === p
|
||||||
|
? 'bg-[#75B2D6] text-white'
|
||||||
|
: 'text-foreground/50 hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p === 'view' ? 'Lire' : 'Éditer'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={sending || !email.trim()}
|
||||||
|
className={cn(
|
||||||
|
'w-full py-2.5 rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 transition-all',
|
||||||
|
sending || !email.trim()
|
||||||
|
? 'bg-black/5 dark:bg-white/5 text-foreground/30 cursor-not-allowed'
|
||||||
|
: sent
|
||||||
|
? 'bg-emerald-500 text-white'
|
||||||
|
: 'bg-[#75B2D6] text-white hover:opacity-90 shadow-sm shadow-[#75B2D6]/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sending
|
||||||
|
? <Loader2 size={13} className="animate-spin" />
|
||||||
|
: sent
|
||||||
|
? <><Check size={13} /> Invitation envoyée</>
|
||||||
|
: <><UserPlus size={13} /> Envoyer l'invitation</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Collaborators list */}
|
||||||
|
<div className="px-6 pb-6 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
|
||||||
|
<span className="text-[9px] uppercase tracking-[0.25em] font-bold text-foreground/30 flex items-center gap-1.5">
|
||||||
|
<Users size={10} /> Accès partagé
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Loader2 size={16} className="animate-spin text-foreground/30" />
|
||||||
|
</div>
|
||||||
|
) : collaborators.length === 0 ? (
|
||||||
|
<p className="text-center text-[11px] text-foreground/30 py-4">
|
||||||
|
Aucun collaborateur pour l'instant.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{collaborators.map(c => (
|
||||||
|
<li key={c.id} className="flex items-center gap-3 p-2.5 rounded-xl bg-black/[0.03] dark:bg-white/[0.03] border border-black/[0.06] dark:border-white/[0.06]">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-[#E9ECEF]/20 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{c.image
|
||||||
|
? <img src={c.image} alt={c.name || ''} className="h-full w-full object-cover" />
|
||||||
|
: <span className="text-[11px] font-bold text-[#E9ECEF]">{(c.name || c.email || '?')[0].toUpperCase()}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[12px] font-semibold text-foreground truncate">{c.name || 'Utilisateur'}</p>
|
||||||
|
<p className="text-[10px] text-foreground/40 truncate">{c.email}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(c.id, c.email)}
|
||||||
|
disabled={removingId === c.id}
|
||||||
|
className="p-1.5 rounded-lg text-foreground/30 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
|
||||||
|
title="Retirer l'accès"
|
||||||
|
>
|
||||||
|
{removingId === c.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
140
memento-note/components/note-editor/note-title-block.tsx
Normal file
140
memento-note/components/note-editor/note-title-block.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||||
|
import { Loader2, Sparkles } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export function NoteTitleBlock() {
|
||||||
|
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
if (fullPage) {
|
||||||
|
// Adaptive font size: short = big editorial, long = smaller but still premium
|
||||||
|
const titleLen = (state.title || '').length
|
||||||
|
const titleSizeClass =
|
||||||
|
titleLen === 0 ? 'text-5xl md:text-6xl' :
|
||||||
|
titleLen < 40 ? 'text-5xl md:text-6xl' :
|
||||||
|
titleLen < 70 ? 'text-4xl md:text-5xl' :
|
||||||
|
titleLen < 100 ? 'text-3xl md:text-4xl' :
|
||||||
|
'text-2xl md:text-3xl'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title — auto-resizing textarea, adaptive size */}
|
||||||
|
<div className="group relative">
|
||||||
|
<textarea
|
||||||
|
dir="auto"
|
||||||
|
rows={1}
|
||||||
|
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
|
||||||
|
value={state.title}
|
||||||
|
onChange={(e) => { actions.setTitle(e.target.value) }}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = el.scrollHeight + 'px'
|
||||||
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
|
className={cn(
|
||||||
|
'w-full font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground',
|
||||||
|
'leading-[1.15] tracking-tight',
|
||||||
|
'placeholder:text-foreground/20 resize-none overflow-hidden',
|
||||||
|
titleSizeClass,
|
||||||
|
!readOnly && 'pr-12'
|
||||||
|
)}
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
ref={(el) => {
|
||||||
|
// Force correct initial height on mount
|
||||||
|
if (el) {
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = el.scrollHeight + 'px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* AI title generation — visible on hover */}
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||||
|
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||||
|
if (wordCount < 10) {
|
||||||
|
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/title-suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: plain }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const s = data.suggestions?.[0]?.title ?? ''
|
||||||
|
if (s) {
|
||||||
|
actions.setTitle(s)
|
||||||
|
toast.success('Titre généré !')
|
||||||
|
} else {
|
||||||
|
toast.error('Impossible de générer un titre.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Erreur lors de la génération du titre.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Erreur réseau.')
|
||||||
|
} finally { actions.setIsProcessingAI(false) }
|
||||||
|
}}
|
||||||
|
disabled={state.isProcessingAI}
|
||||||
|
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
|
||||||
|
title="Générer un titre automatique avec l'IA"
|
||||||
|
>
|
||||||
|
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto title suggestions */}
|
||||||
|
{!state.title && !state.dismissedTitleSuggestions && state.titleSuggestions.length > 0 && (
|
||||||
|
<TitleSuggestions
|
||||||
|
suggestions={state.titleSuggestions}
|
||||||
|
onSelect={(s: string) => { actions.setTitle(s); actions.setDismissedTitleSuggestions(true) }}
|
||||||
|
onDismiss={() => actions.setDismissedTitleSuggestions(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Dialog mode title block
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
dir="auto"
|
||||||
|
placeholder={t('notes.titlePlaceholder')}
|
||||||
|
value={state.title}
|
||||||
|
onChange={(e) => actions.setTitle(e.target.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
|
||||||
|
readOnly && "cursor-default"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={actions.handleGenerateTitles}
|
||||||
|
disabled={state.isGeneratingTitles || readOnly}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 hover:bg-purple-100 dark:hover:bg-purple-900 rounded transition-colors"
|
||||||
|
title={state.isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')}
|
||||||
|
>
|
||||||
|
{state.isGeneratingTitles ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-4 h-4 text-purple-600 hover:text-purple-700 dark:text-purple-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
memento-note/components/note-editor/types.ts
Normal file
174
memento-note/components/note-editor/types.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType, LinkMetadata, NoteSize } from '@/lib/types'
|
||||||
|
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||||
|
import type { TagSuggestion } from '@/lib/ai/types'
|
||||||
|
|
||||||
|
// State interface - all local state from NoteEditor
|
||||||
|
export interface NoteEditorState {
|
||||||
|
// Core content state
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
checkItems: CheckItem[]
|
||||||
|
labels: string[]
|
||||||
|
images: string[]
|
||||||
|
links: LinkMetadata[]
|
||||||
|
newLabel: string
|
||||||
|
color: NoteColor
|
||||||
|
size: NoteSize
|
||||||
|
noteType: NoteType
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
showMarkdownPreview: boolean
|
||||||
|
removedImageUrls: string[]
|
||||||
|
isSaving: boolean
|
||||||
|
isDirty: boolean
|
||||||
|
|
||||||
|
// AI state
|
||||||
|
isProcessingAI: boolean
|
||||||
|
aiOpen: boolean
|
||||||
|
infoOpen: boolean
|
||||||
|
isGeneratingTitles: boolean
|
||||||
|
titleSuggestions: TitleSuggestion[]
|
||||||
|
dismissedTitleSuggestions: boolean
|
||||||
|
isReformulating: boolean
|
||||||
|
reformulationModal: {
|
||||||
|
originalText: string
|
||||||
|
reformulatedText: string
|
||||||
|
option: string
|
||||||
|
} | null
|
||||||
|
previousContentForCopilot: string | null
|
||||||
|
|
||||||
|
// Reminder state
|
||||||
|
showReminderDialog: boolean
|
||||||
|
currentReminder: Date | null
|
||||||
|
|
||||||
|
// Link dialog state
|
||||||
|
showLinkDialog: boolean
|
||||||
|
linkUrl: string
|
||||||
|
|
||||||
|
// Memory Echo Connections
|
||||||
|
comparisonNotes: Array<Partial<Note>>
|
||||||
|
fusionNotes: Array<Partial<Note>>
|
||||||
|
|
||||||
|
// Ghost tags
|
||||||
|
dismissedTags: string[]
|
||||||
|
|
||||||
|
// Tag suggestions (from auto-tagging)
|
||||||
|
filteredSuggestions: TagSuggestion[]
|
||||||
|
isAnalyzingSuggestions: boolean
|
||||||
|
|
||||||
|
// Context-derived values
|
||||||
|
isMarkdown: boolean
|
||||||
|
allImages: string[]
|
||||||
|
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions interface - all handlers from NoteEditor
|
||||||
|
export interface NoteEditorActions {
|
||||||
|
// Title actions
|
||||||
|
setTitle: (title: string) => void
|
||||||
|
setDismissedTitleSuggestions: (dismissed: boolean) => void
|
||||||
|
|
||||||
|
// Content actions
|
||||||
|
setContent: (content: string) => void
|
||||||
|
|
||||||
|
// CheckItems actions
|
||||||
|
setCheckItems: (items: CheckItem[]) => void
|
||||||
|
handleCheckItem: (id: string) => void
|
||||||
|
handleUpdateCheckItem: (id: string, text: string) => void
|
||||||
|
handleAddCheckItem: () => void
|
||||||
|
handleRemoveCheckItem: (id: string) => void
|
||||||
|
|
||||||
|
// Labels actions
|
||||||
|
setLabels: (labels: string[]) => void
|
||||||
|
handleSelectGhostTag: (tag: string) => void
|
||||||
|
handleDismissGhostTag: (tag: string) => void
|
||||||
|
handleRemoveLabel: (label: string) => void
|
||||||
|
|
||||||
|
// Images actions
|
||||||
|
setImages: (images: string[]) => void
|
||||||
|
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
handleRemoveImage: (index: number) => void
|
||||||
|
uploadImageFile: (file: File) => Promise<string>
|
||||||
|
|
||||||
|
// Links actions
|
||||||
|
setLinks: (links: LinkMetadata[]) => void
|
||||||
|
handleAddLink: () => Promise<void>
|
||||||
|
handleRemoveLink: (index: number) => void
|
||||||
|
|
||||||
|
// Note properties
|
||||||
|
setNoteType: (type: NoteType) => void
|
||||||
|
setShowMarkdownPreview: (show: boolean) => void
|
||||||
|
setColor: (color: NoteColor) => void
|
||||||
|
setSize: (size: NoteSize) => void
|
||||||
|
|
||||||
|
// Reminder actions
|
||||||
|
setShowReminderDialog: (show: boolean) => void
|
||||||
|
setCurrentReminder: (date: Date | null) => void
|
||||||
|
handleReminderSave: (date: Date) => Promise<void>
|
||||||
|
handleRemoveReminder: () => Promise<void>
|
||||||
|
|
||||||
|
// Link dialog
|
||||||
|
setShowLinkDialog: (show: boolean) => void
|
||||||
|
setLinkUrl: (url: string) => void
|
||||||
|
|
||||||
|
// Title suggestions
|
||||||
|
handleGenerateTitles: () => Promise<void>
|
||||||
|
handleSelectTitle: (title: string) => void
|
||||||
|
|
||||||
|
// Reformulation
|
||||||
|
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
|
||||||
|
handleApplyRefactor: () => void
|
||||||
|
|
||||||
|
// AI Direct handlers
|
||||||
|
handleClarifyDirect: () => Promise<void>
|
||||||
|
handleShortenDirect: () => Promise<void>
|
||||||
|
handleImproveDirect: () => Promise<void>
|
||||||
|
handleTransformMarkdown: () => Promise<void>
|
||||||
|
|
||||||
|
// Save actions
|
||||||
|
handleSave: () => Promise<void>
|
||||||
|
handleSaveInPlace: () => Promise<void>
|
||||||
|
handleMakeCopy: () => Promise<void>
|
||||||
|
|
||||||
|
// Memory Echo
|
||||||
|
setComparisonNotes: (notes: Array<Partial<Note>>) => void
|
||||||
|
setFusionNotes: (notes: Array<Partial<Note>>) => void
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
|
||||||
|
|
||||||
|
// State setters
|
||||||
|
setIsDirty: (dirty: boolean) => void
|
||||||
|
setAiOpen: (open: boolean) => void
|
||||||
|
setInfoOpen: (open: boolean) => void
|
||||||
|
setIsProcessingAI: (processing: boolean) => void
|
||||||
|
setIsGeneratingTitles: (generating: boolean) => void
|
||||||
|
setIsAnalyzingSuggestions: (analyzing: boolean) => void
|
||||||
|
setPreviousContentForCopilot: (content: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context value - combines state + actions + note reference
|
||||||
|
export interface NoteEditorContextValue {
|
||||||
|
// The current note (external source of truth)
|
||||||
|
note: Note
|
||||||
|
|
||||||
|
// Read-only flag
|
||||||
|
readOnly: boolean
|
||||||
|
|
||||||
|
// FullPage flag
|
||||||
|
fullPage: boolean
|
||||||
|
|
||||||
|
// All state
|
||||||
|
state: NoteEditorState
|
||||||
|
|
||||||
|
// All actions
|
||||||
|
actions: NoteEditorActions
|
||||||
|
|
||||||
|
// Computed values from contexts
|
||||||
|
notebooks: Array<{ id: string; name: string }>
|
||||||
|
globalLabels: Array<{ name: string }>
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||||
|
}
|
||||||
@@ -52,9 +52,8 @@ import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
|||||||
import { GhostTags } from '@/components/ghost-tags'
|
import { GhostTags } from '@/components/ghost-tags'
|
||||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { fr } from 'date-fns/locale/fr'
|
import { fr } from 'date-fns/locale/fr'
|
||||||
@@ -108,21 +107,20 @@ export function NoteInlineEditor({
|
|||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||||
|
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
const userId = session.user.id
|
getAISettings(session.user.id).then((settings) => {
|
||||||
import('@/app/actions/ai-settings').then(({ getAISettings }) => {
|
|
||||||
getAISettings(userId).then(settings => {
|
|
||||||
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
||||||
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
||||||
|
setAutoSaveEnabled(settings.autoSave !== false)
|
||||||
}).catch(err => console.error("Failed to fetch AI settings", err))
|
}).catch(err => console.error("Failed to fetch AI settings", err))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [session?.user?.id])
|
}, [session?.user?.id])
|
||||||
const { labels: globalLabels, addLabel } = useLabels()
|
const { labels: globalLabels, addLabel } = useNotebooks()
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
const { refreshNotes } = useRefresh()
|
||||||
|
|
||||||
// ── Local edit state ──────────────────────────────────────────────────────
|
// ── Local edit state ──────────────────────────────────────────────────────
|
||||||
const [title, setTitle] = useState(note.title || '')
|
const [title, setTitle] = useState(note.title || '')
|
||||||
@@ -208,6 +206,10 @@ export function NoteInlineEditor({
|
|||||||
|
|
||||||
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
|
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
|
||||||
const scheduleSave = useCallback(() => {
|
const scheduleSave = useCallback(() => {
|
||||||
|
if (!autoSaveEnabled) {
|
||||||
|
setIsDirty(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
saveTimerRef.current = setTimeout(async () => {
|
saveTimerRef.current = setTimeout(async () => {
|
||||||
@@ -312,7 +314,7 @@ export function NoteInlineEditor({
|
|||||||
}
|
}
|
||||||
toast.success(t('toast.notesFusionSuccess'))
|
toast.success(t('toast.notesFusionSuccess'))
|
||||||
setFusionNotes([])
|
setFusionNotes([])
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
||||||
@@ -335,7 +337,7 @@ export function NoteInlineEditor({
|
|||||||
onArchive?.(note.id)
|
onArchive?.(note.id)
|
||||||
try {
|
try {
|
||||||
await toggleArchive(note.id, !note.isArchived)
|
await toggleArchive(note.id, !note.isArchived)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
} catch {
|
} catch {
|
||||||
// Cannot easily revert since onArchive removes from list
|
// Cannot easily revert since onArchive removes from list
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
@@ -361,7 +363,7 @@ export function NoteInlineEditor({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await deleteNote(note.id)
|
await deleteNote(note.id)
|
||||||
onDelete?.(note.id)
|
onDelete?.(note.id)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,10 +570,11 @@ export function NoteInlineEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{previousContent !== null && (
|
{previousContent !== null && (
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600"
|
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30 font-medium"
|
||||||
title={t('ai.undoAI') }
|
title={t('ai.undoAI') }
|
||||||
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
|
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px]">{t('general.undo') || 'Annuler'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -602,7 +605,31 @@ export function NoteInlineEditor({
|
|||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
|
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
|
||||||
) : isDirty ? (
|
) : isDirty ? (
|
||||||
|
!autoSaveEnabled ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[11px] text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSaving(true)
|
||||||
|
saveInline(note.id, { title, content, checkItems, type: noteType, isMarkdown: showMarkdownPreview && noteType === 'markdown' })
|
||||||
|
.then(() => {
|
||||||
|
setIsSaving(false)
|
||||||
|
setIsDirty(false)
|
||||||
|
toast.success(t('notes.savedStatus'))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setIsSaving(false)
|
||||||
|
toast.error(t('general.error'))
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 mr-1.5" />
|
||||||
|
{t('notes.saveNow') || 'Enregistrer'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
|
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
|
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
|
||||||
)}
|
)}
|
||||||
@@ -708,13 +735,22 @@ export function NoteInlineEditor({
|
|||||||
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
|
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
|
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
|
||||||
<input
|
<textarea
|
||||||
type="text"
|
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
|
rows={1}
|
||||||
|
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40 resize-none overflow-hidden min-h-[1.5em]"
|
||||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
onChange={(e) => {
|
||||||
|
changeTitle(e.target.value);
|
||||||
|
scheduleSave();
|
||||||
|
e.target.style.height = 'auto';
|
||||||
|
e.target.style.height = e.target.scrollHeight + 'px';
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.height = 'auto';
|
||||||
|
e.target.style.height = e.target.scrollHeight + 'px';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -921,9 +957,20 @@ export function NoteInlineEditor({
|
|||||||
noteImages={allImages}
|
noteImages={allImages}
|
||||||
noteId={note.id}
|
noteId={note.id}
|
||||||
onApplyToNote={(newContent) => {
|
onApplyToNote={(newContent) => {
|
||||||
setPreviousContent(content)
|
const current = content
|
||||||
|
setPreviousContent(current)
|
||||||
changeContent(newContent)
|
changeContent(newContent)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
|
toast.success(t('ai.appliedToNote') || 'Applied to note', {
|
||||||
|
action: {
|
||||||
|
label: t('general.undo') || 'Undo',
|
||||||
|
onClick: () => {
|
||||||
|
changeContent(current)
|
||||||
|
setPreviousContent(null)
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
onUndoLastAction={previousContent !== null ? () => {
|
onUndoLastAction={previousContent !== null ? () => {
|
||||||
changeContent(previousContent)
|
changeContent(previousContent)
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import { GhostTags } from './ghost-tags'
|
|||||||
import { TitleSuggestions } from './title-suggestions'
|
import { TitleSuggestions } from './title-suggestions'
|
||||||
import { CollaboratorDialog } from './collaborator-dialog'
|
import { CollaboratorDialog } from './collaborator-dialog'
|
||||||
import { AIAssistantActionBar } from './ai-assistant-action-bar'
|
import { AIAssistantActionBar } from './ai-assistant-action-bar'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
@@ -106,7 +105,7 @@ export function NoteInput({
|
|||||||
forceExpanded = false,
|
forceExpanded = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
}: NoteInputProps) {
|
}: NoteInputProps) {
|
||||||
const { labels: globalLabels, addLabel } = useLabels()
|
const { labels: globalLabels, addLabel } = useNotebooks()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||||
@@ -1071,13 +1070,22 @@ export function NoteInput({
|
|||||||
noteContent={content}
|
noteContent={content}
|
||||||
noteImages={allImages}
|
noteImages={allImages}
|
||||||
onApplyToNote={(newContent) => {
|
onApplyToNote={(newContent) => {
|
||||||
|
// Save current state to history before applying AI content
|
||||||
|
setHistory(prev => [...prev.slice(0, historyIndex + 1), { title, content }])
|
||||||
|
setHistoryIndex(prev => prev + 1)
|
||||||
|
|
||||||
if (type === 'richtext') {
|
if (type === 'richtext') {
|
||||||
// If content looks like markdown, convert to HTML before injecting into richtext
|
|
||||||
const looksLikeMarkdown = /^#{1,6}\s|^[-*]\s|\*\*[^*]+\*\*|^>\s/.test(newContent)
|
const looksLikeMarkdown = /^#{1,6}\s|^[-*]\s|\*\*[^*]+\*\*|^>\s/.test(newContent)
|
||||||
setContent(looksLikeMarkdown ? markdownToBasicHtml(newContent) : newContent)
|
setContent(looksLikeMarkdown ? markdownToBasicHtml(newContent) : newContent)
|
||||||
} else {
|
} else {
|
||||||
setContent(newContent)
|
setContent(newContent)
|
||||||
}
|
}
|
||||||
|
toast.success(t('ai.appliedToNote'), {
|
||||||
|
action: {
|
||||||
|
label: t('general.undo'),
|
||||||
|
onClick: () => handleUndo()
|
||||||
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
lastActionApplied={false}
|
lastActionApplied={false}
|
||||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function NoteTypeSelector({ value, onChange, compact = false, className }
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size={compact ? 'icon' : 'sm'}
|
size={compact ? 'icon' : 'sm'}
|
||||||
|
aria-label={t('notes.noteType') || 'Changer le type de note'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'gap-1.5 shrink-0',
|
'gap-1.5 shrink-0',
|
||||||
compact ? 'h-8 w-8' : 'h-8 px-2 text-xs font-medium',
|
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
|
<DropdownMenuItem
|
||||||
key={type}
|
key={type}
|
||||||
onClick={() => onChange(type)}
|
onClick={() => onChange(type)}
|
||||||
|
aria-label={t(TYPE_I18N_KEYS[type])}
|
||||||
className={cn('gap-2 cursor-pointer', isActive && 'bg-accent')}
|
className={cn('gap-2 cursor-pointer', isActive && 'bg-accent')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
|
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useNotebookDrag } from '@/context/notebook-drag-context'
|
import { useEditorUI } from '@/context/editor-ui-context'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { CreateNotebookDialog } from './create-notebook-dialog'
|
import { CreateNotebookDialog } from './create-notebook-dialog'
|
||||||
import { NotebookActions } from './notebook-actions'
|
import { NotebookActions } from './notebook-actions'
|
||||||
@@ -15,7 +15,6 @@ import { DeleteNotebookDialog } from './delete-notebook-dialog'
|
|||||||
import { EditNotebookDialog } from './edit-notebook-dialog'
|
import { EditNotebookDialog } from './edit-notebook-dialog'
|
||||||
import { NotebookSummaryDialog } from './notebook-summary-dialog'
|
import { NotebookSummaryDialog } from './notebook-summary-dialog'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
||||||
import { Notebook } from '@/lib/types'
|
import { Notebook } from '@/lib/types'
|
||||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||||
@@ -35,9 +34,8 @@ export function NotebooksList() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading } = useNotebooks()
|
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading, labels } = useNotebooks()
|
||||||
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
|
const { draggedNoteId, dragOverNotebookId, dragOver } = useEditorUI()
|
||||||
const { labels } = useLabels()
|
|
||||||
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||||
const [editingNotebook, setEditingNotebook] = useState<Notebook | null>(null)
|
const [editingNotebook, setEditingNotebook] = useState<Notebook | null>(null)
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition, useEffect } from 'react'
|
||||||
import type { Note } from '@/lib/types'
|
import type { Note } from '@/lib/types'
|
||||||
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
|
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil } from 'lucide-react'
|
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen, StickyNote } from 'lucide-react'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
|
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { deleteNote, toggleArchive, togglePin } from '@/app/actions/notes'
|
import { deleteNote, toggleArchive, togglePin, updateNote } from '@/app/actions/notes'
|
||||||
|
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||||
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
type NotesEditorialViewProps = {
|
type NotesEditorialViewProps = {
|
||||||
@@ -39,15 +47,18 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
|||||||
onOpenHistory?: (note: Note) => void
|
onOpenHistory?: (note: Note) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
const { refreshNotes } = useRefresh()
|
||||||
|
const { notebooks } = useNotebooks()
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
|
const [showReminder, setShowReminder] = useState(false)
|
||||||
|
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent) => {
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await deleteNote(note.id)
|
await deleteNote(note.id)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
@@ -60,7 +71,7 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await toggleArchive(note.id, !note.isArchived)
|
await toggleArchive(note.id, !note.isArchived)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
toast.success(note.isArchived ? (t('notes.unarchived') || 'Désarchivée') : (t('notes.archived') || 'Archivée'))
|
toast.success(note.isArchived ? (t('notes.unarchived') || 'Désarchivée') : (t('notes.archived') || 'Archivée'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
@@ -73,7 +84,19 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await togglePin(note.id, !note.isPinned)
|
await togglePin(note.id, !note.isPinned)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('general.error'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoveToNotebook = (notebookId: string | null) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await updateNote(note.id, { notebookId })
|
||||||
|
refreshNotes(note?.notebookId)
|
||||||
|
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
}
|
}
|
||||||
@@ -106,6 +129,52 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
|||||||
{t('notes.history') || 'Historique'}
|
{t('notes.history') || 'Historique'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rappel */}
|
||||||
|
<DropdownMenuItem onClick={e => { e.stopPropagation(); setShowReminder(true) }}>
|
||||||
|
<Bell className="h-4 w-4 mr-2" />
|
||||||
|
{note.reminder ? (t('reminder.changeReminder') || 'Modifier le rappel') : (t('reminder.setReminder') || 'Définir un rappel')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<ReminderDialog
|
||||||
|
open={showReminder}
|
||||||
|
onOpenChange={setShowReminder}
|
||||||
|
currentReminder={note.reminder ? new Date(note.reminder) : null}
|
||||||
|
onSave={(date) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateNote(note.id, { reminder: date })
|
||||||
|
refreshNotes(note?.notebookId)
|
||||||
|
setShowReminder(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateNote(note.id, { reminder: null })
|
||||||
|
refreshNotes(note?.notebookId)
|
||||||
|
setShowReminder(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Déplacer vers notebook */}
|
||||||
|
<DropdownMenuSub open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
||||||
|
<DropdownMenuSubTrigger onClick={e => e.stopPropagation()}>
|
||||||
|
<FolderOpen className="h-4 w-4 mr-2" />
|
||||||
|
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers notebook'}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent alignOffset={0} className="w-48">
|
||||||
|
<DropdownMenuItem onClick={e => { e.stopPropagation(); handleMoveToNotebook(null) }}>
|
||||||
|
<StickyNote className="h-4 w-4 mr-2" />
|
||||||
|
{t('notebookSuggestion.generalNotes') || 'Notes générales'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{notebooks.map((nb: any) => (
|
||||||
|
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
|
||||||
|
<FolderOpen className="h-4 w-4 mr-2" />
|
||||||
|
{nb.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400 focus:text-red-600">
|
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400 focus:text-red-600">
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
@@ -116,6 +185,130 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deterministic hue from a string — consistent per note */
|
||||||
|
function stringToHue(s: string): number {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff
|
||||||
|
return h % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorialThumbnail({
|
||||||
|
note,
|
||||||
|
title,
|
||||||
|
aiIllustrationEnabled,
|
||||||
|
}: {
|
||||||
|
note: Note
|
||||||
|
title: string
|
||||||
|
aiIllustrationEnabled: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const { refreshNotes } = useRefresh()
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const img = getNoteFeedImage(note)
|
||||||
|
|
||||||
|
const handleGenerateSvg = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!aiIllustrationEnabled || busy || img) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await generateNoteIllustrationSvg(note.id)
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(res.error)
|
||||||
|
} else {
|
||||||
|
toast.success(t('notes.illustrationGenerated') || 'Illustration générée')
|
||||||
|
refreshNotes(note?.notebookId)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full md:w-56 aspect-[4/3] bg-card/80 border border-border overflow-hidden rounded shadow-sm flex-shrink-0 group/thumb">
|
||||||
|
{img ? (
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
|
||||||
|
/>
|
||||||
|
) : note.illustrationSvg ? (
|
||||||
|
<div
|
||||||
|
className="w-full h-full flex items-center justify-center bg-muted/30 p-2 [&_svg]:max-w-full [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:h-auto"
|
||||||
|
// SVG déjà sanitisé côté serveur (note-illustration.ts)
|
||||||
|
dangerouslySetInnerHTML={{ __html: note.illustrationSvg }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<NoteThumbnailPlaceholder title={title} noteId={note.id} />
|
||||||
|
{aiIllustrationEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
|
||||||
|
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
|
||||||
|
className="absolute bottom-2 right-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10 opacity-0 group-hover/thumb:opacity-100 md:opacity-100 focus-visible:opacity-100"
|
||||||
|
onClick={handleGenerateSvg}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG thumbnail for notes without an image */
|
||||||
|
function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: string }) {
|
||||||
|
// Try to extract the first emoji from the title
|
||||||
|
const emojiMatch = title.match(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u)
|
||||||
|
const emoji = emojiMatch?.[0]
|
||||||
|
const letter = title.replace(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu, '').trim()[0]?.toUpperCase() || '?'
|
||||||
|
const hue = stringToHue(noteId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-full w-full flex items-center justify-center relative overflow-hidden"
|
||||||
|
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% 94%) 0%, hsl(${hue} 18% 87%) 100%)` }}
|
||||||
|
>
|
||||||
|
{/* Decorative concentric circles */}
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
viewBox="0 0 224 168"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
style={{ color: `hsl(${hue} 30% 60%)` }}
|
||||||
|
>
|
||||||
|
<circle cx="112" cy="84" r="90" stroke="currentColor" strokeWidth="0.6" opacity="0.25" />
|
||||||
|
<circle cx="112" cy="84" r="64" stroke="currentColor" strokeWidth="0.6" opacity="0.2" />
|
||||||
|
<circle cx="112" cy="84" r="38" stroke="currentColor" strokeWidth="0.6" opacity="0.15" />
|
||||||
|
<line x1="22" y1="84" x2="202" y2="84" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
|
||||||
|
<line x1="112" y1="4" x2="112" y2="164" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
|
||||||
|
</svg>
|
||||||
|
{emoji ? (
|
||||||
|
<span
|
||||||
|
className="relative text-5xl leading-none select-none"
|
||||||
|
style={{ filter: `drop-shadow(0 2px 8px hsl(${hue} 40% 40% / 0.2))` }}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="relative font-memento-serif font-bold select-none leading-none"
|
||||||
|
style={{
|
||||||
|
fontSize: '4.5rem',
|
||||||
|
color: `hsl(${hue} 35% 35%)`,
|
||||||
|
opacity: 0.35,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function NotesEditorialView({
|
export function NotesEditorialView({
|
||||||
notes,
|
notes,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -123,13 +316,24 @@ export function NotesEditorialView({
|
|||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
}: NotesEditorialViewProps) {
|
}: NotesEditorialViewProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
setAiIllustrationEnabled(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
getAISettings(session.user.id)
|
||||||
|
.then((s) => setAiIllustrationEnabled(s.paragraphRefactor !== false))
|
||||||
|
.catch(() => setAiIllustrationEnabled(false))
|
||||||
|
}, [session?.user?.id])
|
||||||
|
|
||||||
return (
|
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>
|
<AnimatePresence>
|
||||||
{notes.map((note: Note, index: number) => {
|
{notes.map((note: Note, index: number) => {
|
||||||
const title = getNoteDisplayTitle(note, t('notes.untitled') || 'Untitled')
|
const title = getNoteDisplayTitle(note, t('notes.untitled') || 'Untitled')
|
||||||
const img = getNoteFeedImage(note)
|
|
||||||
const excerpt = getNotePlainExcerpt(note)
|
const excerpt = getNotePlainExcerpt(note)
|
||||||
const dateStr = formatNoteDate(note.createdAt)
|
const dateStr = formatNoteDate(note.createdAt)
|
||||||
|
|
||||||
@@ -139,14 +343,19 @@ export function NotesEditorialView({
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.05 * index, duration: 0.6 }}
|
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)}
|
onClick={() => onOpen(note)}
|
||||||
>
|
>
|
||||||
{/* Date / breadcrumb + actions menu */}
|
{/* Date / breadcrumb */}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="note-date-badge">
|
<div className="note-date-badge">
|
||||||
{notebookName ? `${notebookName} — ${dateStr}` : dateStr}
|
{notebookName ? `${notebookName} — ${dateStr}` : dateStr}
|
||||||
</div>
|
</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} />
|
<EditorialNoteMenu note={note} onOpen={onOpen} onOpenHistory={onOpenHistory} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,17 +367,7 @@ export function NotesEditorialView({
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||||
<div className="w-full md:w-56 aspect-[4/3] bg-white/50 border border-border overflow-hidden rounded shadow-sm flex-shrink-0">
|
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
|
||||||
{img ? (
|
|
||||||
<img
|
|
||||||
src={img}
|
|
||||||
alt=""
|
|
||||||
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-full w-full bg-gradient-to-br from-muted/40 to-muted/10" aria-hidden />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 flex-1">
|
<div className="space-y-3 flex-1">
|
||||||
{excerpt ? (
|
{excerpt ? (
|
||||||
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
|
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useState, useTransition, useRef } from 'react'
|
||||||
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
||||||
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
@@ -635,7 +636,7 @@ export function NotesTabsView({
|
|||||||
onNoteCreated,
|
onNoteCreated,
|
||||||
}: NotesTabsViewProps) {
|
}: NotesTabsViewProps) {
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { triggerRefresh } = useNoteRefreshOptional()
|
const { refreshNotes } = useRefresh()
|
||||||
const [items, setItems] = useState<Note[]>(notes)
|
const [items, setItems] = useState<Note[]>(notes)
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [isCreating, startCreating] = useTransition()
|
const [isCreating, startCreating] = useTransition()
|
||||||
@@ -796,8 +797,8 @@ export function NotesTabsView({
|
|||||||
})
|
})
|
||||||
setSelectedId(newNote.id)
|
setSelectedId(newNote.id)
|
||||||
onNoteCreated?.(newNote)
|
onNoteCreated?.(newNote)
|
||||||
// NOTE: No triggerRefresh() here — the note is already added to items above.
|
// NOTE: No refreshNotes(note.notebookId) here — the note is already added to items above.
|
||||||
// triggerRefresh() would call getAllNotes() which may return stale cache
|
// refreshNotes(note.notebookId) would call getAllNotes() which may return stale cache
|
||||||
// in production (skipRevalidation:true skips cache invalidation).
|
// in production (skipRevalidation:true skips cache invalidation).
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
|
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
|
||||||
@@ -810,7 +811,7 @@ export function NotesTabsView({
|
|||||||
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
|
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
|
||||||
try {
|
try {
|
||||||
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
|
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
|
||||||
triggerRefresh()
|
refreshNotes(note.notebookId)
|
||||||
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
|
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
|
||||||
} catch {
|
} catch {
|
||||||
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
|
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
|
||||||
@@ -823,7 +824,7 @@ export function NotesTabsView({
|
|||||||
await toggleArchive(note.id, true)
|
await toggleArchive(note.id, true)
|
||||||
setItems((prev) => prev.filter((n) => n.id !== note.id))
|
setItems((prev) => prev.filter((n) => n.id !== note.id))
|
||||||
setSelectedId((prev) => (prev === note.id ? null : prev))
|
setSelectedId((prev) => (prev === note.id ? null : prev))
|
||||||
triggerRefresh()
|
refreshNotes(note.notebookId)
|
||||||
toast.success(t('notes.archived') || 'Note archivée')
|
toast.success(t('notes.archived') || 'Note archivée')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('notes.archiveFailed') || 'Archivage échoué')
|
toast.error(t('notes.archiveFailed') || 'Archivage échoué')
|
||||||
@@ -1022,7 +1023,7 @@ export function NotesTabsView({
|
|||||||
onArchive={(noteId) => {
|
onArchive={(noteId) => {
|
||||||
setItems((prev) => prev.filter((n) => n.id !== noteId))
|
setItems((prev) => prev.filter((n) => n.id !== noteId))
|
||||||
setSelectedId((prev) => (prev === noteId ? null : prev))
|
setSelectedId((prev) => (prev === noteId ? null : prev))
|
||||||
triggerRefresh()
|
refreshNotes(selected?.notebookId)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Toggle sidebar button — top-right of editor, always visible */}
|
{/* Toggle sidebar button — top-right of editor, always visible */}
|
||||||
@@ -1057,7 +1058,7 @@ export function NotesTabsView({
|
|||||||
} else {
|
} else {
|
||||||
toast.info(t('reminder.removeReminder'))
|
toast.info(t('reminder.removeReminder'))
|
||||||
}
|
}
|
||||||
triggerRefresh()
|
refreshNotes(items.find(n => n.id === noteId)?.notebookId)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
}
|
}
|
||||||
@@ -1110,7 +1111,7 @@ export function NotesTabsView({
|
|||||||
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
|
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
|
||||||
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
|
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
|
||||||
setNoteToDelete(null)
|
setNoteToDelete(null)
|
||||||
triggerRefresh()
|
refreshNotes(noteToDelete.notebookId)
|
||||||
toast.success(t('notes.deleted'))
|
toast.success(t('notes.deleted'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('notes.deleteFailed'))
|
toast.error(t('notes.deleteFailed'))
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
||||||
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
|
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
@@ -46,8 +46,17 @@ interface ReminderNote {
|
|||||||
isReminderDone: boolean
|
isReminderDone: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Memento brand tokens ──────────────────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
blue: '#FDFDFE',
|
||||||
|
gold: '#D4A373',
|
||||||
|
green: '#A3B18A',
|
||||||
|
dark: '#1C1C1C',
|
||||||
|
beige: '#FDFDFE',
|
||||||
|
}
|
||||||
|
|
||||||
export function NotificationPanel() {
|
export function NotificationPanel() {
|
||||||
const { triggerRefresh } = useNoteRefreshOptional()
|
const { refreshNotes } = useRefresh()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [requests, setRequests] = useState<ShareRequest[]>([])
|
const [requests, setRequests] = useState<ShareRequest[]>([])
|
||||||
@@ -97,10 +106,9 @@ export function NotificationPanel() {
|
|||||||
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
})
|
})
|
||||||
triggerRefresh()
|
refreshNotes(null)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[NOTIFICATION] Error:', error)
|
|
||||||
toast.error(error.message || t('general.error'))
|
toast.error(error.message || t('general.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +120,6 @@ export function NotificationPanel() {
|
|||||||
toast.info(t('notification.declined'))
|
toast.info(t('notification.declined'))
|
||||||
if (requests.length <= 1) setOpen(false)
|
if (requests.length <= 1) setOpen(false)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[NOTIFICATION] Error:', error)
|
|
||||||
toast.error(error.message || t('general.error'))
|
toast.error(error.message || t('general.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +128,7 @@ export function NotificationPanel() {
|
|||||||
try {
|
try {
|
||||||
await toggleReminderDone(noteId, done)
|
await toggleReminderDone(noteId, done)
|
||||||
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: done } : r))
|
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: done } : r))
|
||||||
triggerRefresh()
|
refreshNotes(null)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
}
|
}
|
||||||
@@ -139,73 +146,92 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
|
const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
|
||||||
|
|
||||||
|
// ── icon bg/color per notification type ──────────────────────────────────
|
||||||
|
const notifIconStyle = (type: string) => {
|
||||||
|
if (type === 'agent_success') return { bg: `${C.gold}20`, color: C.gold }
|
||||||
|
if (type === 'agent_slides_ready') return { bg: `${C.gold}20`, color: C.gold }
|
||||||
|
if (type === 'agent_canvas_ready') return { bg: `${C.gold}20`, color: C.gold }
|
||||||
|
if (type === 'agent_failure') return { bg: '#EF444420', color: '#EF4444' }
|
||||||
|
return { bg: `${C.green}20`, color: C.green }
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifLabelColor = (type: string) => {
|
||||||
|
if (type.startsWith('agent')) {
|
||||||
|
if (type === 'agent_failure') return '#EF4444'
|
||||||
|
return C.gold
|
||||||
|
}
|
||||||
|
return C.green
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
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"
|
||||||
size="sm"
|
|
||||||
className="relative h-9 w-9 p-0 hover:bg-accent/50 transition-all duration-200"
|
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<Badge
|
<span
|
||||||
variant="destructive"
|
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm"
|
||||||
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs animate-pulse shadow-lg"
|
style={{ background: C.green }}
|
||||||
>
|
>
|
||||||
{pendingCount > 9 ? '9+' : pendingCount}
|
{pendingCount > 9 ? '9+' : pendingCount}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-80 p-0">
|
|
||||||
<div className="px-4 py-3 border-b bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15">
|
<PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-black/20">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: '#FDFDFE' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
|
<Bell className="h-4 w-4" style={{ color: C.dark }} />
|
||||||
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
|
<span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
|
||||||
|
{t('notification.notifications')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{appNotifications.length > 0 && (
|
{appNotifications.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleMarkAllRead}
|
onClick={handleMarkAllRead}
|
||||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
|
||||||
title={t('notification.markAllRead') || 'Mark all read'}
|
title={t('notification.markAllRead') || 'Mark all read'}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
|
<span
|
||||||
|
className="h-5 px-1.5 flex items-center justify-center rounded-full text-white text-[9px] font-bold"
|
||||||
|
style={{ background: C.green }}
|
||||||
|
>
|
||||||
{pendingCount}
|
{pendingCount}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
|
<div className="animate-spin h-6 w-6 border-2 border-t-transparent rounded-full mx-auto mb-2" style={{ borderColor: C.blue, borderTopColor: 'transparent' }} />
|
||||||
</div>
|
</div>
|
||||||
) : !hasContent ? (
|
) : !hasContent ? (
|
||||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
<div className="p-8 text-center">
|
||||||
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
<Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
|
||||||
<p className="font-medium">{t('notification.noNotifications') || 'No new notifications'}</p>
|
<p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto divide-y divide-black/5">
|
||||||
{/* App notifications (agents, system) */}
|
|
||||||
|
{/* ── App notifications (agents, system) ── */}
|
||||||
{appNotifications.map((notif) => {
|
{appNotifications.map((notif) => {
|
||||||
const isSlides = notif.type === 'agent_slides_ready'
|
const isSlides = notif.type === 'agent_slides_ready'
|
||||||
const isCanvas = notif.type === 'agent_canvas_ready'
|
const isCanvas = notif.type === 'agent_canvas_ready'
|
||||||
const canvasId = notif.relatedId
|
const canvasId = notif.relatedId
|
||||||
|
const iconStyle = notifIconStyle(notif.type)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||||
key={notif.id}
|
|
||||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="flex items-start gap-3 cursor-pointer"
|
className="flex items-start gap-3 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -216,69 +242,73 @@ export function NotificationPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
{/* Icon badge */}
|
||||||
"mt-0.5 flex-none rounded-full p-1",
|
<div
|
||||||
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
|
className="mt-0.5 flex-none rounded-lg p-1.5"
|
||||||
notif.type === 'agent_slides_ready' && 'bg-purple-100 dark:bg-purple-900/30 text-purple-600',
|
style={{ background: iconStyle.bg, color: iconStyle.color }}
|
||||||
notif.type === 'agent_canvas_ready' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
|
>
|
||||||
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
|
{isSlides ? <Presentation className="w-3.5 h-3.5" />
|
||||||
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
|
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
||||||
)}>
|
: notif.type.startsWith('agent') ? <Bot className="w-3.5 h-3.5" />
|
||||||
{isSlides ? (
|
: <AlertCircle className="w-3.5 h-3.5" />}
|
||||||
<Presentation className="w-3.5 h-3.5" />
|
|
||||||
) : isCanvas ? (
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
) : notif.type.startsWith('agent') ? (
|
|
||||||
<Bot className="w-3.5 h-3.5" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<span
|
||||||
<span className={cn(
|
className="text-[9px] font-bold uppercase tracking-[0.2em]"
|
||||||
"text-[10px] font-semibold uppercase tracking-wider",
|
style={{ color: notifLabelColor(notif.type) }}
|
||||||
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
|
>
|
||||||
notif.type === 'agent_slides_ready' && 'text-purple-600 dark:text-purple-400',
|
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
|
||||||
notif.type === 'agent_canvas_ready' && 'text-blue-600 dark:text-blue-400',
|
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
|
||||||
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
|
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
|
||||||
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
|
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
|
||||||
)}>
|
{notif.type === 'system' && 'Système'}
|
||||||
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Slides Ready')}
|
|
||||||
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagram Ready')}
|
|
||||||
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
|
|
||||||
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
|
|
||||||
{notif.type === 'system' && 'System'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
|
||||||
<p className="text-sm font-medium truncate">{notif.title}</p>
|
|
||||||
{notif.message && (
|
{notif.message && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
|
<p className="text-[11px] text-foreground/50 mt-0.5 line-clamp-2">{notif.message}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
|
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
|
||||||
className="mt-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
|
className="mt-0.5 text-foreground/20 hover:text-foreground transition-colors"
|
||||||
title={t('notification.dismiss') || 'Dismiss'}
|
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Download PPTX button */}
|
||||||
{isSlides && canvasId && (
|
{isSlides && canvasId && (
|
||||||
<div className="mt-2 ml-8">
|
<div className="mt-2 ml-8">
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
handleMarkNotifRead(notif.id)
|
handleMarkNotifRead(notif.id)
|
||||||
window.open(`/api/canvas/download?id=${canvasId}`, '_blank')
|
try {
|
||||||
|
const res = await fetch(`/api/canvas?id=${canvasId}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.canvas?.data) throw new Error()
|
||||||
|
const parsed = JSON.parse(data.canvas.data)
|
||||||
|
if (!parsed.base64) throw new Error()
|
||||||
|
const bytes = Uint8Array.from(atob(parsed.base64), c => c.charCodeAt(0))
|
||||||
|
const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = parsed.filename || `${data.canvas.name || 'presentation'}.pptx`
|
||||||
|
document.body.appendChild(a); a.click()
|
||||||
|
document.body.removeChild(a); URL.revokeObjectURL(url)
|
||||||
|
} catch { toast.error('Échec du téléchargement') }
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md bg-purple-500 text-white hover:bg-purple-600 shadow-sm transition-all active:scale-95"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
|
||||||
|
style={{ background: C.blue }}
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
{t('notification.downloadPptx') || 'Download .pptx'}
|
{t('notification.downloadPptx') || 'Télécharger .pptx'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -286,29 +316,27 @@ export function NotificationPanel() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Overdue reminders */}
|
{/* ── Overdue reminders ── */}
|
||||||
{overdueReminders.map((note) => (
|
{overdueReminders.map((note) => (
|
||||||
<div
|
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||||
key={note.id}
|
|
||||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleReminder(note.id, true)}
|
onClick={() => handleToggleReminder(note.id, true)}
|
||||||
className="mt-0.5 flex-none text-amber-500 hover:text-green-500 transition-colors"
|
className="mt-0.5 flex-none transition-colors hover:opacity-70"
|
||||||
|
style={{ color: C.green }}
|
||||||
title={t('reminders.markDone')}
|
title={t('reminders.markDone')}
|
||||||
>
|
>
|
||||||
<Circle className="w-4 h-4" />
|
<Circle className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<AlertCircle className="w-3 h-3 text-amber-500" />
|
<AlertCircle className="w-3 h-3" style={{ color: C.green }} />
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
|
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.green }}>
|
||||||
{t('reminders.overdue')}
|
{t('reminders.overdue')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p>
|
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
||||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
|
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
|
||||||
</div>
|
</div>
|
||||||
@@ -317,17 +345,14 @@ export function NotificationPanel() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Upcoming reminders */}
|
{/* ── Upcoming reminders ── */}
|
||||||
{upcomingReminders.slice(0, 5).map((note) => (
|
{upcomingReminders.slice(0, 5).map((note) => (
|
||||||
<div
|
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||||
key={note.id}
|
|
||||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Clock className="w-4 h-4 mt-0.5 flex-none text-primary" />
|
<Clock className="w-4 h-4 mt-0.5 flex-none" style={{ color: C.blue }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p>
|
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-[11px] text-foreground/40 mt-0.5">
|
||||||
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
|
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
|
||||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
})}
|
})}
|
||||||
@@ -337,56 +362,48 @@ export function NotificationPanel() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Share requests */}
|
{/* ── Share requests ── */}
|
||||||
{requests.map((request) => (
|
{requests.map((request) => (
|
||||||
|
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
<div
|
<div
|
||||||
key={request.id}
|
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
|
||||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
style={{ background: `linear-gradient(135deg, ${C.blue}, ${C.green})` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 mb-2">
|
|
||||||
<div className="h-7 w-7 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-[10px] shadow-md shrink-0">
|
|
||||||
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-semibold truncate">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<Share2 className="w-3 h-3" style={{ color: C.blue }} />
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.blue }}>
|
||||||
|
Partage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] font-semibold truncate">
|
||||||
{request.sharer.name || request.sharer.email}
|
{request.sharer.name || request.sharer.email}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
<p className="text-[11px] text-foreground/50 truncate">
|
||||||
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 ml-11">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDecline(request.id)}
|
onClick={() => handleDecline(request.id)}
|
||||||
className={cn(
|
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||||
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
|
|
||||||
"border border-border bg-background",
|
|
||||||
"text-muted-foreground",
|
|
||||||
"hover:bg-muted hover:text-foreground",
|
|
||||||
"transition-all duration-200",
|
|
||||||
"flex items-center justify-center gap-1",
|
|
||||||
"active:scale-95"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
{t('notification.decline') || t('general.cancel')}
|
{t('notification.decline') || 'Refuser'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAccept(request.id)}
|
onClick={() => handleAccept(request.id)}
|
||||||
className={cn(
|
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
|
||||||
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
|
style={{ background: C.blue }}
|
||||||
"bg-primary text-primary-foreground",
|
|
||||||
"hover:bg-primary/90",
|
|
||||||
"shadow-sm",
|
|
||||||
"transition-all duration-200",
|
|
||||||
"flex items-center justify-center gap-1",
|
|
||||||
"active:scale-95"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
{t('notification.accept') || t('general.confirm')}
|
{t('notification.accept') || 'Accepter'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,14 +411,15 @@ export function NotificationPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer link to reminders page */}
|
{/* Footer */}
|
||||||
{activeReminders.length > 0 && (
|
{activeReminders.length > 0 && (
|
||||||
<div className="px-4 py-2 border-t bg-muted/30">
|
<div className="px-4 py-2.5 border-t bg-black/[0.02]">
|
||||||
<a
|
<a
|
||||||
href="/reminders"
|
href="/reminders"
|
||||||
className="text-[11px] font-medium text-primary hover:underline"
|
className="text-[11px] font-semibold hover:opacity-70 transition-opacity"
|
||||||
|
style={{ color: C.blue }}
|
||||||
>
|
>
|
||||||
{t('reminders.viewAll') || t('reminders.title') || 'Voir tous les rappels'}
|
{t('reminders.viewAll') || 'Voir tous les rappels →'}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider'
|
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||||
import { LabelProvider } from '@/context/LabelContext'
|
|
||||||
import { NotebooksProvider } from '@/context/notebooks-context'
|
import { NotebooksProvider } from '@/context/notebooks-context'
|
||||||
import { NotebookDragProvider } from '@/context/notebook-drag-context'
|
import { EditorUIProvider } from '@/context/editor-ui-context'
|
||||||
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
||||||
import { HomeViewProvider } from '@/context/home-view-context'
|
import { QueryProvider } from '@/components/query-provider'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { Translations } from '@/lib/i18n/load-translations'
|
import type { Translations } from '@/lib/i18n/load-translations'
|
||||||
|
|
||||||
@@ -26,18 +25,18 @@ interface ProvidersWrapperProps {
|
|||||||
|
|
||||||
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
|
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
|
||||||
return (
|
return (
|
||||||
|
<QueryProvider>
|
||||||
<NoteRefreshProvider>
|
<NoteRefreshProvider>
|
||||||
<LabelProvider>
|
|
||||||
<NotebooksProvider>
|
<NotebooksProvider>
|
||||||
<NotebookDragProvider>
|
<EditorUIProvider>
|
||||||
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
||||||
<DirWrapper>
|
<DirWrapper>
|
||||||
<HomeViewProvider>{children}</HomeViewProvider>
|
{children}
|
||||||
</DirWrapper>
|
</DirWrapper>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</NotebookDragProvider>
|
</EditorUIProvider>
|
||||||
</NotebooksProvider>
|
</NotebooksProvider>
|
||||||
</LabelProvider>
|
|
||||||
</NoteRefreshProvider>
|
</NoteRefreshProvider>
|
||||||
|
</QueryProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
26
memento-note/components/query-provider.tsx
Normal file
26
memento-note/components/query-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes (was cacheTime)
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
|||||||
import { togglePin, deleteNote, dismissFromRecent } from '@/app/actions/notes'
|
import { togglePin, deleteNote, dismissFromRecent } from '@/app/actions/notes'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { StickyNote } from 'lucide-react'
|
import { StickyNote } from 'lucide-react'
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ function CompactCard({
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
const { refreshNotes } = useRefresh()
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
@@ -86,7 +86,7 @@ function CompactCard({
|
|||||||
await togglePin(note.id, newPinnedState)
|
await togglePin(note.id, newPinnedState)
|
||||||
|
|
||||||
// Trigger global refresh to update lists
|
// Trigger global refresh to update lists
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
|
|
||||||
if (newPinnedState) {
|
if (newPinnedState) {
|
||||||
@@ -100,7 +100,7 @@ function CompactCard({
|
|||||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
const handleMoveToNotebook = async (notebookId: string | null) => {
|
||||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
||||||
setShowNotebookMenu(false)
|
setShowNotebookMenu(false)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent) => {
|
const handleDelete = async (e: React.MouseEvent) => {
|
||||||
@@ -112,7 +112,7 @@ function CompactCard({
|
|||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
await deleteNote(note.id)
|
await deleteNote(note.id)
|
||||||
triggerRefresh()
|
refreshNotes(note?.notebookId)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete note:', error)
|
console.error('Failed to delete note:', error)
|
||||||
@@ -135,7 +135,7 @@ function CompactCard({
|
|||||||
try {
|
try {
|
||||||
await dismissFromRecent(note.id)
|
await dismissFromRecent(note.id)
|
||||||
// Don't refresh list to prevent immediate replacement
|
// Don't refresh list to prevent immediate replacement
|
||||||
// triggerRefresh()
|
// refreshNotes(note?.notebookId)
|
||||||
// router.refresh()
|
// router.refresh()
|
||||||
toast.success(t('notes.dismissed') || 'Note dismissed from recent')
|
toast.success(t('notes.dismissed') || 'Note dismissed from recent')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import Image from '@tiptap/extension-image'
|
|||||||
import TextAlign from '@tiptap/extension-text-align'
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import TaskList from '@tiptap/extension-task-list'
|
import TaskList from '@tiptap/extension-task-list'
|
||||||
import TaskItem from '@tiptap/extension-task-item'
|
import TaskItem from '@tiptap/extension-task-item'
|
||||||
|
import { Table } from '@tiptap/extension-table'
|
||||||
|
import { TableRow } from '@tiptap/extension-table-row'
|
||||||
|
import { TableCell } from '@tiptap/extension-table-cell'
|
||||||
|
import { TableHeader } from '@tiptap/extension-table-header'
|
||||||
import Superscript from '@tiptap/extension-superscript'
|
import Superscript from '@tiptap/extension-superscript'
|
||||||
import Subscript from '@tiptap/extension-subscript'
|
import Subscript from '@tiptap/extension-subscript'
|
||||||
import Typography from '@tiptap/extension-typography'
|
import Typography from '@tiptap/extension-typography'
|
||||||
@@ -26,7 +30,8 @@ import {
|
|||||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||||
SpellCheck, Languages, BookOpen } from 'lucide-react'
|
SpellCheck, Languages, BookOpen, Presentation
|
||||||
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -45,13 +50,13 @@ interface RichTextEditorProps {
|
|||||||
type SlashItem = {
|
type SlashItem = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
icon: typeof Bold
|
icon: any
|
||||||
category?: string
|
category?: string
|
||||||
shortcut?: string
|
shortcut?: string
|
||||||
isImage?: boolean
|
isImage?: boolean
|
||||||
isAi?: boolean
|
isAi?: boolean
|
||||||
aiOption?: 'clarify' | 'shorten' | 'improve'
|
aiOption?: 'clarify' | 'shorten' | 'improve'
|
||||||
command: (editor: Editor) => void
|
command: (editor: Editor, range?: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomImage = Image.extend({
|
const CustomImage = Image.extend({
|
||||||
@@ -71,28 +76,50 @@ const CustomImage = Image.extend({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const slashCommands: SlashItem[] = [
|
const slashCommands: SlashItem[] = [
|
||||||
// Basic blocks (indices 0-9)
|
// Basic blocks
|
||||||
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
|
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
|
||||||
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||||
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||||
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||||
|
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||||
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
|
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
|
||||||
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
|
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
|
||||||
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
|
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
|
||||||
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
|
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
|
||||||
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
|
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
|
||||||
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
|
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
|
||||||
// Media (index 10)
|
// Media
|
||||||
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => {} },
|
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
|
||||||
// Formatting (indices 11-13) — super/subscript removed, use BubbleMenu
|
// Formatting
|
||||||
{ title: 'Align Left', description: 'Align text left', icon: AlignLeft, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('left').run() },
|
{ title: 'Align Left', description: 'Align text left', icon: AlignLeft, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('left').run() },
|
||||||
{ title: 'Align Center', description: 'Center text', icon: AlignCenter, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('center').run() },
|
{ title: 'Align Center', description: 'Center text', icon: AlignCenter, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('center').run() },
|
||||||
{ title: 'Align Right', description: 'Align text right', icon: AlignRight, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('right').run() },
|
{ title: 'Align Right', description: 'Align text right', icon: AlignRight, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('right').run() },
|
||||||
// IA Note (indices 14-17)
|
// IA Note
|
||||||
{ title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
|
{ title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } },
|
||||||
{ title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => {} },
|
{ title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => { } },
|
||||||
{ title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => {} },
|
{ title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => { } },
|
||||||
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
|
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } },
|
||||||
|
// Formatting extensions
|
||||||
|
{ title: 'Bold', description: 'Make text bold', icon: Bold, category: 'Formatting', command: (e) => e.chain().focus().toggleBold().run() },
|
||||||
|
{ title: 'Italic', description: 'Make text italic', icon: Italic, category: 'Formatting', command: (e) => e.chain().focus().toggleItalic().run() },
|
||||||
|
{ title: 'Underline', description: 'Underline text', icon: UnderlineIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleUnderline().run() },
|
||||||
|
{ title: 'Strike', description: 'Strikethrough text', icon: Strikethrough, category: 'Formatting', command: (e) => e.chain().focus().toggleStrike().run() },
|
||||||
|
{ title: 'Highlight', description: 'Highlight text', icon: Highlighter, category: 'Formatting', command: (e) => e.chain().focus().toggleHighlight().run() },
|
||||||
|
{ title: 'Superscript', description: 'Text above the baseline', icon: SuperscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSuperscript().run() },
|
||||||
|
{ title: 'Subscript', description: 'Text below the baseline', icon: SubscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSubscript().run() },
|
||||||
|
// AI Tools
|
||||||
|
{
|
||||||
|
title: 'Diagramme', description: 'Générer un diagramme Excalidraw', icon: BookOpen, category: 'IA Note', command: (e) => {
|
||||||
|
const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'diagram' } })
|
||||||
|
window.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Présentation', description: 'Générer des slides HTML/PPTX', icon: Presentation, category: 'IA Note', command: (e) => {
|
||||||
|
const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'slides' } })
|
||||||
|
window.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||||
@@ -146,6 +173,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
|
||||||
TaskList,
|
TaskList,
|
||||||
TaskItem.configure({ nested: true }),
|
TaskItem.configure({ nested: true }),
|
||||||
|
Table.configure({ resizable: true }),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
Superscript,
|
Superscript,
|
||||||
Subscript,
|
Subscript,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -278,7 +309,7 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AI_LANGS = ['Francais','English','Espanol','Deutsch','Persan','Portugais','Italiano','Chinois','Japonais']
|
const AI_LANGS = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais']
|
||||||
|
|
||||||
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
@@ -472,10 +503,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
const [aiLoading, setAiLoading] = useState(false)
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const selectedItemRef = useRef<HTMLButtonElement>(null)
|
const selectedItemRef = useRef<HTMLButtonElement>(null)
|
||||||
// Flag: true while user is interacting with the menu (prevents selectionUpdate from closing it)
|
|
||||||
const menuInteracting = useRef(false)
|
const menuInteracting = useRef(false)
|
||||||
|
|
||||||
// Translated category names (keys match slashCommands category field)
|
|
||||||
const CAT_LABELS: Record<string, string> = {
|
const CAT_LABELS: Record<string, string> = {
|
||||||
'Basic blocks': t('richTextEditor.slashCatBasic'),
|
'Basic blocks': t('richTextEditor.slashCatBasic'),
|
||||||
'Media': t('richTextEditor.slashCatMedia'),
|
'Media': t('richTextEditor.slashCatMedia'),
|
||||||
@@ -483,26 +512,35 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
'IA Note': t('richTextEditor.slashCatAi'),
|
'IA Note': t('richTextEditor.slashCatAi'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translated command list (keeps same order/icons/shortcuts as global slashCommands)
|
|
||||||
const localCommands: SlashItem[] = [
|
const localCommands: SlashItem[] = [
|
||||||
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: 'Basic blocks' },
|
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: 'Basic blocks' },
|
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: 'Basic blocks' },
|
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: 'Basic blocks' },
|
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[4], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: 'Basic blocks' },
|
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[5], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: 'Basic blocks' },
|
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[6], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: 'Basic blocks' },
|
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[7], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: 'Basic blocks' },
|
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[8], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: 'Basic blocks' },
|
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[9], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: 'Basic blocks' },
|
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[10], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: 'Media' },
|
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||||
{ ...slashCommands[11], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: 'Formatting' },
|
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: t('richTextEditor.slashCatMedia') },
|
||||||
{ ...slashCommands[12], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: 'Formatting' },
|
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
{ ...slashCommands[13], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: 'Formatting' },
|
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
{ ...slashCommands[14], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: 'IA Note' },
|
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
{ ...slashCommands[15], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: 'IA Note' },
|
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: t('richTextEditor.slashCatAi') },
|
||||||
{ ...slashCommands[16], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: 'IA Note' },
|
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: t('richTextEditor.slashCatAi') },
|
||||||
{ ...slashCommands[17], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: 'IA Note' },
|
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: t('richTextEditor.slashCatAi') },
|
||||||
|
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: t('richTextEditor.slashCatAi') },
|
||||||
|
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
|
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
|
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
|
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
|
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
|
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
|
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||||
|
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), category: t('richTextEditor.slashCatAi') },
|
||||||
|
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), category: t('richTextEditor.slashCatAi') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const closeMenu = useCallback(() => {
|
const closeMenu = useCallback(() => {
|
||||||
@@ -536,13 +574,11 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
}
|
}
|
||||||
}, [editor, closeMenu, deleteSlashText, onInsertImage])
|
}, [editor, closeMenu, deleteSlashText, onInsertImage])
|
||||||
|
|
||||||
// All category names in order
|
|
||||||
const allCategories = Array.from(new Set(localCommands.map(c => c.category || 'Basic blocks')))
|
const allCategories = Array.from(new Set(localCommands.map(c => c.category || 'Basic blocks')))
|
||||||
|
|
||||||
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
|
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
|
||||||
const filtered = activeCategory ? textFiltered.filter(c => (c.category || 'Basic blocks') === activeCategory) : textFiltered
|
const filtered = activeCategory ? textFiltered.filter(c => (c.category || 'Basic blocks') === activeCategory) : textFiltered
|
||||||
|
|
||||||
// Compute categories based on full search to keep tabs visible even when one is selected
|
|
||||||
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
|
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
|
||||||
const cat = item.category || 'Basic blocks'
|
const cat = item.category || 'Basic blocks'
|
||||||
if (!acc[cat]) acc[cat] = []
|
if (!acc[cat]) acc[cat] = []
|
||||||
@@ -602,8 +638,17 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const { from } = editor.state.selection
|
const { from } = editor.state.selection
|
||||||
const c = editor.view.coordsAtPos(from)
|
const c = editor.view.coordsAtPos(from)
|
||||||
|
|
||||||
|
// Check if menu would overflow bottom
|
||||||
|
const menuHeight = menuRef.current?.offsetHeight || 300
|
||||||
|
const wouldOverflow = c.bottom + menuHeight + 20 > window.innerHeight
|
||||||
|
|
||||||
|
if (wouldOverflow) {
|
||||||
|
setCoords({ top: c.top - menuHeight - 8, left: c.left })
|
||||||
|
} else {
|
||||||
setCoords({ top: c.bottom + 8, left: c.left })
|
setCoords({ top: c.bottom + 8, left: c.left })
|
||||||
}, [isOpen, editor, query])
|
}
|
||||||
|
}, [isOpen, editor, query, filtered.length])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
|||||||
@@ -34,16 +34,16 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
|||||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={cn('flex items-center gap-1', className)}>
|
<nav className={cn('flex items-center gap-6 border-b border-border/40', className)}>
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<Link
|
<Link
|
||||||
key={section.id}
|
key={section.id}
|
||||||
href={section.href}
|
href={section.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
|
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
|
||||||
isActive(section.href)
|
isActive(section.href)
|
||||||
? 'border-primary text-primary'
|
? 'border-[#D4A373] text-[#1C1C1C]'
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
: 'border-transparent text-[#1C1C1C]/40 hover:text-[#1C1C1C]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{section.icon}
|
{section.icon}
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
Shield,
|
||||||
|
GripVertical,
|
||||||
|
Users,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { getAllNotes } from '@/app/actions/notes'
|
import { getAllNotes } from '@/app/actions/notes'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
@@ -29,6 +34,15 @@ import { motion, AnimatePresence } from 'motion/react'
|
|||||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||||
import { CreateNotebookDialog } from './create-notebook-dialog'
|
import { CreateNotebookDialog } from './create-notebook-dialog'
|
||||||
import { NotificationPanel } from './notification-panel'
|
import { NotificationPanel } from './notification-panel'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
|
|
||||||
type NavigationView = 'notebooks' | 'agents'
|
type NavigationView = 'notebooks' | 'agents'
|
||||||
type SortOrder = 'newest' | 'oldest' | 'alpha'
|
type SortOrder = 'newest' | 'oldest' | 'alpha'
|
||||||
@@ -48,7 +62,7 @@ function NoteLink({
|
|||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg',
|
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg text-left',
|
||||||
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
|
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -56,7 +70,7 @@ function NoteLink({
|
|||||||
'w-1.5 h-1.5 rounded-full shrink-0',
|
'w-1.5 h-1.5 rounded-full shrink-0',
|
||||||
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
|
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
|
||||||
)} />
|
)} />
|
||||||
<span className="truncate">{title}</span>
|
<span className="break-words line-clamp-2 leading-tight">{title}</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -68,16 +82,31 @@ function SidebarCarnetItem({
|
|||||||
activeNoteId,
|
activeNoteId,
|
||||||
onCarnetClick,
|
onCarnetClick,
|
||||||
onNoteClick,
|
onNoteClick,
|
||||||
|
isDragging,
|
||||||
|
dragHandleProps,
|
||||||
}: {
|
}: {
|
||||||
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
notes: { id: string; title: string }[]
|
notes: { id: string; title: string }[]
|
||||||
activeNoteId: string | null
|
activeNoteId: string | null
|
||||||
onCarnetClick: () => void
|
onCarnetClick: () => void
|
||||||
onNoteClick: (noteId: string) => void
|
onNoteClick: (noteId: string, carnetId: string) => void
|
||||||
|
isDragging?: boolean
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<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"
|
||||||
|
>
|
||||||
|
<GripVertical size={12} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ x: 4 }}
|
whileHover={{ x: 4 }}
|
||||||
onClick={onCarnetClick}
|
onClick={onCarnetClick}
|
||||||
@@ -112,6 +141,7 @@ function SidebarCarnetItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
@@ -127,11 +157,11 @@ function SidebarCarnetItem({
|
|||||||
key={note.id}
|
key={note.id}
|
||||||
title={note.title}
|
title={note.title}
|
||||||
isActive={activeNoteId === note.id}
|
isActive={activeNoteId === note.id}
|
||||||
onClick={() => onNoteClick(note.id)}
|
onClick={() => onNoteClick(note.id, carnet.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{notes.length === 0 && (
|
{notes.length === 0 && (
|
||||||
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">No notes yet</p>
|
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">{t('common.noResults')}</p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -145,53 +175,82 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { refreshKey } = useNoteRefreshOptional()
|
const { notebooks, updateNotebookOrderOptimistic } = useNotebooks()
|
||||||
const { notebooks } = useNotebooks()
|
const { refreshKey } = useNoteRefresh()
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||||
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
|
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
|
||||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
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 currentNotebookId = searchParams.get('notebook')
|
||||||
const currentNoteId = searchParams.get('openNote')
|
const currentNoteId = searchParams.get('openNote')
|
||||||
|
|
||||||
// Determine if inbox is active (no notebook filter, on home page)
|
|
||||||
const isInboxActive =
|
const isInboxActive =
|
||||||
pathname === '/' &&
|
pathname === '/' &&
|
||||||
!searchParams.get('notebook') &&
|
!searchParams.get('notebook') &&
|
||||||
!searchParams.get('label') &&
|
!searchParams.get('labels') &&
|
||||||
!searchParams.get('archived') &&
|
!searchParams.get('archived') &&
|
||||||
!searchParams.get('trashed')
|
!searchParams.get('trashed')
|
||||||
|
|
||||||
// Sync activeView with current route
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) {
|
setActiveView(
|
||||||
setActiveView('agents')
|
pathname.startsWith('/agents') || pathname.startsWith('/lab') ? 'agents' : 'notebooks'
|
||||||
}
|
)
|
||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
const displayName = user?.name || user?.email || ''
|
const displayName = user?.name || user?.email || ''
|
||||||
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
||||||
|
|
||||||
useEffect(() => {
|
// Sorted list for the sort dropdown (not used directly when dragging)
|
||||||
if (!currentNotebookId) return
|
const sortedNotebooks = useMemo(() => {
|
||||||
if (notebookNotes[currentNotebookId]) return
|
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])
|
||||||
|
|
||||||
getAllNotes(false, currentNotebookId).then(notes => {
|
// 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])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notebookIdsKey) return
|
||||||
|
let cancelled = false
|
||||||
|
const load = async () => {
|
||||||
|
const mappedEntries = await Promise.all(
|
||||||
|
notebooks.map(async (nb: Notebook) => {
|
||||||
|
const notes = await getAllNotes(false, nb.id)
|
||||||
const mapped = notes.map((n: Note) => ({
|
const mapped = notes.map((n: Note) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
title: getNoteDisplayTitle(n, t('notes.untitled') || 'Untitled'),
|
title: getNoteDisplayTitle(n, t('notes.untitled')),
|
||||||
}))
|
}))
|
||||||
setNotebookNotes(prev => ({ ...prev, [currentNotebookId!]: mapped }))
|
return [nb.id, mapped] as const
|
||||||
})
|
})
|
||||||
}, [currentNotebookId, refreshKey])
|
)
|
||||||
|
if (cancelled) return
|
||||||
|
setNotebookNotes(Object.fromEntries(mappedEntries))
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
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 handleCarnetClick = (notebookId: string) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('notebook', notebookId)
|
params.set('notebook', notebookId)
|
||||||
// forceList resets to editorial view in home-client
|
|
||||||
params.set('forceList', '1')
|
params.set('forceList', '1')
|
||||||
router.push(`/?${params.toString()}`)
|
router.push(`/?${params.toString()}`)
|
||||||
}
|
}
|
||||||
@@ -200,70 +259,161 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
router.push('/?forceList=1')
|
router.push('/?forceList=1')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNoteClick = (noteId: string) => {
|
const handleNoteClick = (noteId: string, notebookId: string) => {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.set('notebook', notebookId)
|
||||||
params.set('openNote', noteId)
|
params.set('openNote', noteId)
|
||||||
params.delete('forceList')
|
params.delete('forceList')
|
||||||
router.push(`/?${params.toString()}`)
|
router.push(`/?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort notebooks
|
// ── Drag handlers ──
|
||||||
const sortedNotebooks = [...notebooks].sort((a: Notebook, b: Notebook) => {
|
const handleDragStart = (e: React.DragEvent, notebookId: string) => {
|
||||||
if (sortOrder === 'alpha') return a.name.localeCompare(b.name)
|
setDraggedId(notebookId)
|
||||||
if (sortOrder === 'newest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
if (sortOrder === 'oldest') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
}
|
||||||
return 0
|
|
||||||
|
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> = {
|
const sortLabels: Record<SortOrder, string> = {
|
||||||
newest: t('sidebar.sortNewest') || 'Newest first',
|
newest: t('sidebar.sortNewest'),
|
||||||
oldest: t('sidebar.sortOldest') || 'Oldest first',
|
oldest: t('sidebar.sortOldest'),
|
||||||
alpha: t('sidebar.sortAlpha') || 'A → Z',
|
alpha: t('sidebar.sortAlpha'),
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden h-full min-h-0 w-80 shrink-0 flex-col md:flex',
|
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
|
||||||
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-border/30 dark:bg-sidebar/90',
|
'border-e border-border/40 bg-memento-sidebar backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* ── Top: Avatar + View Toggle ── */}
|
{/* ── Top: Avatar + View Toggle ── */}
|
||||||
<div className="p-6 flex items-center justify-between mb-4">
|
<div className="p-6 flex items-center justify-between mb-4">
|
||||||
{/* Avatar → profile */}
|
<DropdownMenu>
|
||||||
<Link href="/settings/profile" className="shrink-0">
|
<DropdownMenuTrigger asChild>
|
||||||
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm hover:ring-2 hover:ring-primary/30 transition-all">
|
<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')}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-secondary border border-black/10 flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm">
|
||||||
{user?.image ? (
|
{user?.image ? (
|
||||||
<Avatar className="size-10 ring-1 ring-border/60">
|
<Avatar className="size-10 ring-1 ring-border/60">
|
||||||
<AvatarImage src={user.image} alt="" />
|
<AvatarImage src={user.image} alt="" />
|
||||||
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initial}</AvatarFallback>
|
<AvatarFallback className="bg-secondary text-sm font-semibold text-[#1C1C1C]/60">{initial}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
) : (
|
) : (
|
||||||
<span>{initial}</span>
|
<span>{initial}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
{t('sidebar.profile') || 'Profil'}
|
||||||
</Link>
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
{t('nav.settings') || 'Paramètres'}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{(user as { role?: string } | undefined)?.role === 'ADMIN' && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href="/admin" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
{t('nav.adminDashboard') || 'Administration'}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
{t('sidebar.signOut') || 'Se déconnecter'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Notebooks / Agents toggle */}
|
{/* Notification bell + Notebooks / Agents toggle */}
|
||||||
<div className="sidebar-view-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
|
<button
|
||||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
||||||
className={cn('sidebar-view-toggle-btn', activeView === 'notebooks' && 'active')}
|
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'}
|
title={t('nav.notebooks')}
|
||||||
>
|
>
|
||||||
<BookOpen size={14} />
|
<BookOpen size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
||||||
className={cn('sidebar-view-toggle-btn', activeView === 'agents' && 'active')}
|
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'}
|
title={t('nav.agents')}
|
||||||
>
|
>
|
||||||
<Bot size={14} />
|
<Bot size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Scrollable content ── */}
|
{/* ── Scrollable content ── */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar pb-4">
|
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar pb-4">
|
||||||
@@ -280,13 +430,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
{/* Section header with sort button */}
|
{/* Section header with sort button */}
|
||||||
<div className="flex items-center justify-between px-4 mb-3">
|
<div className="flex items-center justify-between px-4 mb-3">
|
||||||
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
||||||
{t('nav.notebooks') || 'Notebooks'}
|
{t('nav.notebooks')}
|
||||||
</p>
|
</p>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSortMenu(s => !s)}
|
onClick={() => setShowSortMenu(s => !s)}
|
||||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||||
title={t('sidebar.sortOrder') || 'Sort order'}
|
title={t('sidebar.sortOrder')}
|
||||||
>
|
>
|
||||||
<ArrowUpDown size={12} />
|
<ArrowUpDown size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -335,32 +485,105 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
'text-[13px] font-medium truncate',
|
'text-[13px] font-medium truncate',
|
||||||
isInboxActive ? 'text-foreground' : 'text-muted-foreground'
|
isInboxActive ? 'text-foreground' : 'text-muted-foreground'
|
||||||
)}>
|
)}>
|
||||||
{t('sidebar.inbox') || 'Inbox'}
|
{t('sidebar.inbox')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('shared', '1')
|
||||||
|
params.set('forceList', '1')
|
||||||
|
router.push(`/?${params.toString()}`)
|
||||||
|
}}
|
||||||
|
className={cn('sidebar-inbox-item', searchParams.get('shared') === '1' && pathname === '/' && 'active')}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||||
|
searchParams.get('shared') === '1' && pathname === '/'
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'bg-white/60 text-foreground border-border'
|
||||||
|
)}>
|
||||||
|
<Users size={14} />
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'text-[13px] font-medium truncate',
|
||||||
|
searchParams.get('shared') === '1' && pathname === '/' ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{t('sidebar.sharedWithMe')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('reminders', '1')
|
||||||
|
params.set('forceList', '1')
|
||||||
|
router.push(`/?${params.toString()}`)
|
||||||
|
}}
|
||||||
|
className={cn('sidebar-inbox-item', searchParams.get('reminders') === '1' && pathname === '/' && 'active')}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||||
|
searchParams.get('reminders') === '1' && pathname === '/'
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'bg-white/60 text-foreground border-border'
|
||||||
|
)}>
|
||||||
|
<Bell size={14} />
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'text-[13px] font-medium truncate',
|
||||||
|
searchParams.get('reminders') === '1' && pathname === '/' ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{t('sidebar.reminders')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-4 my-3 h-px bg-border/40" />
|
<div className="mx-4 my-3 h-px bg-border/40" />
|
||||||
|
|
||||||
{/* Notebooks list */}
|
{/* Notebooks list — draggable */}
|
||||||
<div className="space-y-1">
|
<div
|
||||||
{sortedNotebooks.map((notebook: Notebook) => {
|
className="space-y-1"
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{orderedNotebooks.map((notebook: Notebook) => {
|
||||||
const isActive = currentNotebookId === notebook.id
|
const isActive = currentNotebookId === notebook.id
|
||||||
const notes = notebookNotes[notebook.id] || []
|
const notes = notebookNotes[notebook.id] || []
|
||||||
|
const isDragging = draggedId === notebook.id
|
||||||
return (
|
return (
|
||||||
<SidebarCarnetItem
|
<motion.div
|
||||||
key={notebook.id}
|
key={notebook.id}
|
||||||
|
layout
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
mass: 0.8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, notebook.id)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SidebarCarnetItem
|
||||||
carnet={{
|
carnet={{
|
||||||
id: notebook.id,
|
id: notebook.id,
|
||||||
name: notebook.name,
|
name: notebook.name,
|
||||||
initial: notebook.name.charAt(0).toUpperCase(),
|
initial: notebook.name.charAt(0).toUpperCase(),
|
||||||
}}
|
}}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
notes={isActive ? notes : []}
|
notes={notes}
|
||||||
activeNoteId={currentNoteId}
|
activeNoteId={currentNoteId}
|
||||||
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
||||||
onNoteClick={handleNoteClick}
|
onNoteClick={handleNoteClick}
|
||||||
|
isDragging={isDragging}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
@@ -369,7 +592,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/40"
|
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/40"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
<span>{t('notebooks.create') || 'New Carnet'}</span>
|
<span>{t('notebook.create')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -382,13 +605,12 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
|
||||||
{t('agents.intelligenceOS') || 'Intelligence OS'}
|
{t('agents.intelligenceOS')}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{[
|
{[
|
||||||
{ id: 'agents', href: '/agents', label: t('agents.myAgents') || 'Mes Agents', icon: Bot },
|
{ id: 'agents', href: '/agents', label: t('agents.myAgents'), icon: Bot },
|
||||||
{ id: 'lab', href: '/lab', label: t('nav.lab') || 'Le Lab AI', icon: FlaskConical },
|
{ id: 'lab', href: '/lab', label: t('nav.lab'), icon: FlaskConical },
|
||||||
{ id: 'chat', href: '/chat', label: t('nav.chat') || 'Conversations', icon: MessageSquare },
|
|
||||||
].map(item => {
|
].map(item => {
|
||||||
const isActive = pathname.startsWith(item.href)
|
const isActive = pathname.startsWith(item.href)
|
||||||
return (
|
return (
|
||||||
@@ -412,17 +634,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* General Chat button (opens floating panel) */}
|
|
||||||
<button
|
|
||||||
onClick={() => window.dispatchEvent(new Event('toggle-ai-chat'))}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group text-muted-foreground hover:bg-white/40 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0 bg-white/60 border-border group-hover:border-foreground/20">
|
|
||||||
<Sparkles size={16} />
|
|
||||||
</div>
|
|
||||||
<span className="text-[13px] font-medium">{t('ai.openAssistant') || 'Assistant IA'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -431,34 +642,26 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── Footer ── */}
|
||||||
<div className="pt-4 p-5 border-t border-border space-y-1">
|
<div className="pt-4 p-5 border-t border-border space-y-1">
|
||||||
{/* 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
|
<Link
|
||||||
href="/archive"
|
href="/archive"
|
||||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
|
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||||
>
|
>
|
||||||
<Archive size={16} />
|
<Archive size={16} />
|
||||||
<span>{t('sidebar.archive') || 'Archives'}</span>
|
<span>{t('sidebar.archive')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/trash"
|
href="/trash"
|
||||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
|
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
<span>{t('sidebar.trash') || 'Corbeille'}</span>
|
<span>{t('sidebar.trash')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
|
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||||
>
|
>
|
||||||
<Settings size={16} />
|
<Settings size={16} />
|
||||||
<span>{t('nav.settings') || 'Paramètres'}</span>
|
<span>{t('nav.settings')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -47,11 +47,10 @@ export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializ
|
|||||||
const localFontFamily = localStorage.getItem('font-family')
|
const localFontFamily = localStorage.getItem('font-family')
|
||||||
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
|
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (effectiveFontFamily === 'system') {
|
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
|
||||||
root.classList.add('font-system')
|
if (effectiveFontFamily === 'system') root.classList.add('font-system')
|
||||||
} else {
|
if (effectiveFontFamily === 'playfair') root.classList.add('font-playfair')
|
||||||
root.classList.remove('font-system')
|
if (effectiveFontFamily === 'jetbrains') root.classList.add('font-jetbrains')
|
||||||
}
|
|
||||||
if (!localFontFamily && fontFamily) {
|
if (!localFontFamily && fontFamily) {
|
||||||
localStorage.setItem('font-family', fontFamily)
|
localStorage.setItem('font-family', fontFamily)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function AlertDialogOverlay({
|
|||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-[#1C1C1C]/40 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const AvatarFallback = React.forwardRef<
|
|||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
"flex h-full w-full items-center justify-center rounded-full bg-[#E9ECEF]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-[#1C1C1C]/40 backdrop-blur-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -18,18 +18,18 @@ export function Toaster() {
|
|||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast: [
|
toast: [
|
||||||
'toast pointer-events-auto',
|
'toast pointer-events-auto border-none',
|
||||||
'!bg-[#1C1C1C] !text-[#F2F0E9] !border !border-white/10',
|
'bg-[var(--color-memento-ink)] text-[var(--color-memento-paper)]',
|
||||||
'!rounded-xl !shadow-xl !shadow-black/30',
|
'rounded-xl shadow-acrylic',
|
||||||
'!text-[13px] !font-medium !py-3 !px-4',
|
'text-[13px] font-medium py-3 px-4',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
description: '!text-[#F2F0E9]/70 !text-[12px]',
|
description: 'text-[var(--color-memento-paper)]/70 text-[12px]',
|
||||||
actionButton: '!bg-[#F2F0E9] !text-[#1C1C1C] !text-[11px] !font-bold !rounded-lg !px-3 !py-1',
|
actionButton: 'bg-[var(--color-memento-paper)] text-[var(--color-memento-ink)] text-[11px] font-bold rounded-lg px-4 py-1.5 hover:opacity-90 transition-opacity',
|
||||||
closeButton: '!bg-white/10 !text-[#F2F0E9]/70 !border-white/10 hover:!bg-white/20',
|
closeButton: 'bg-white/10 text-[var(--color-memento-paper)]/70 border-white/10 hover:bg-white/20',
|
||||||
success: '!border-l-4 !border-l-emerald-400/70',
|
success: 'border-l-4 border-l-emerald-400',
|
||||||
error: '!border-l-4 !border-l-red-400/70',
|
error: 'border-l-4 border-l-red-400',
|
||||||
warning: '!border-l-4 !border-l-amber-400/70',
|
warning: 'border-l-4 border-l-amber-400',
|
||||||
info: '!border-l-4 !border-l-sky-400/70',
|
info: 'border-l-4 border-l-zinc-400',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react'
|
|
||||||
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
|
|
||||||
import { getHashColor } from '@/lib/utils'
|
|
||||||
|
|
||||||
export interface Label {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
color: LabelColorName
|
|
||||||
userId?: string | null
|
|
||||||
createdAt: Date
|
|
||||||
updatedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LabelContextType {
|
|
||||||
labels: Label[]
|
|
||||||
loading: boolean
|
|
||||||
notebookId?: string | null
|
|
||||||
setNotebookId: (notebookId: string | null) => void
|
|
||||||
addLabel: (name: string, color?: LabelColorName, notebookId?: string | null) => Promise<void>
|
|
||||||
updateLabel: (id: string, updates: Partial<Pick<Label, 'name' | 'color'>>) => Promise<void>
|
|
||||||
deleteLabel: (id: string) => Promise<void>
|
|
||||||
getLabelColor: (name: string) => LabelColorName
|
|
||||||
refreshLabels: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const LabelContext = createContext<LabelContextType | undefined>(undefined)
|
|
||||||
|
|
||||||
export function LabelProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [labels, setLabels] = useState<Label[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [notebookId, setNotebookId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const fetchLabels = useCallback(async (nbId: string | null) => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
const url = new URL('/api/labels', window.location.origin)
|
|
||||||
if (nbId) {
|
|
||||||
url.searchParams.set('notebookId', nbId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
cache: 'no-store',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.success && data.data) {
|
|
||||||
setLabels(data.data)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch labels:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchLabels(notebookId)
|
|
||||||
}, [notebookId, fetchLabels])
|
|
||||||
|
|
||||||
const addLabel = useCallback(async (name: string, color?: LabelColorName, labelNotebookId?: string | null) => {
|
|
||||||
try {
|
|
||||||
const labelColor = color || getHashColor(name);
|
|
||||||
const finalNotebookId = labelNotebookId || notebookId
|
|
||||||
|
|
||||||
const response = await fetch('/api/labels', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, color: labelColor, notebookId: finalNotebookId }),
|
|
||||||
})
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.success && data.data) {
|
|
||||||
setLabels(prev => [...prev, data.data])
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add label:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}, [notebookId])
|
|
||||||
|
|
||||||
const updateLabel = useCallback(async (id: string, updates: Partial<Pick<Label, 'name' | 'color'>>) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/labels/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
})
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.success && data.data) {
|
|
||||||
setLabels(prev => prev.map(label =>
|
|
||||||
label.id === id ? { ...label, ...data.data } : label
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update label:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const deleteLabel = useCallback(async (id: string) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/labels/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
setLabels(prev => prev.filter(label => label.id !== id))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete label:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getLabelColor = useCallback((name: string): LabelColorName => {
|
|
||||||
const label = labels.find(l => l.name.toLowerCase() === name.toLowerCase())
|
|
||||||
return label?.color || 'gray'
|
|
||||||
}, [labels])
|
|
||||||
|
|
||||||
const refreshLabels = useCallback(async () => {
|
|
||||||
await fetchLabels(notebookId)
|
|
||||||
}, [fetchLabels, notebookId])
|
|
||||||
|
|
||||||
const value = useMemo<LabelContextType>(() => ({
|
|
||||||
labels,
|
|
||||||
loading,
|
|
||||||
notebookId,
|
|
||||||
setNotebookId,
|
|
||||||
addLabel,
|
|
||||||
updateLabel,
|
|
||||||
deleteLabel,
|
|
||||||
getLabelColor,
|
|
||||||
refreshLabels,
|
|
||||||
}), [labels, loading, notebookId, addLabel, updateLabel, deleteLabel, getLabelColor, refreshLabels])
|
|
||||||
|
|
||||||
return <LabelContext.Provider value={value}>{children}</LabelContext.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLabels() {
|
|
||||||
const context = useContext(LabelContext)
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useLabels must be used within a LabelProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, useMemo } from 'react'
|
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use React Query's `useQueryClient.invalidateQueries()` instead.
|
||||||
|
* This context is kept for backward compatibility during migration.
|
||||||
|
*
|
||||||
|
* Migration guide:
|
||||||
|
* - Replace `const { triggerRefresh } = useNoteRefresh()` with `const { refreshNotes } = useRefresh()`
|
||||||
|
* - Replace `triggerRefresh()` with `refreshNotes(notebookId)` or `refreshNotes(null)`
|
||||||
|
* - Replace `triggerNotebooksRefresh()` with `refreshNotebooks()`
|
||||||
|
*
|
||||||
|
* @see {@link https://tanstack.com/query/latest/docs/react/reference/queryclient | React Query invalidateQueries}
|
||||||
|
* @see lib/use-refresh.ts
|
||||||
|
*/
|
||||||
interface NoteRefreshContextType {
|
interface NoteRefreshContextType {
|
||||||
refreshKey: number
|
refreshKey: number
|
||||||
triggerRefresh: () => void
|
triggerRefresh: () => void
|
||||||
@@ -11,7 +23,7 @@ interface NoteRefreshContextType {
|
|||||||
|
|
||||||
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
|
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
|
||||||
|
|
||||||
export function NoteRefreshProvider({ children }: { children: React.ReactNode }) {
|
export function NoteRefreshProvider({ children }: { children: ReactNode }) {
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
const [notebooksRefreshKey, setNotebooksRefreshKey] = useState(0)
|
const [notebooksRefreshKey, setNotebooksRefreshKey] = useState(0)
|
||||||
|
|
||||||
@@ -32,6 +44,10 @@ export function NoteRefreshProvider({ children }: { children: React.ReactNode })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `useRefresh()` from `@/lib/use-refresh` instead.
|
||||||
|
* This hook is kept for backward compatibility during migration.
|
||||||
|
*/
|
||||||
export function useNoteRefresh() {
|
export function useNoteRefresh() {
|
||||||
const context = useContext(NoteRefreshContext)
|
const context = useContext(NoteRefreshContext)
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -40,6 +56,10 @@ export function useNoteRefresh() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `useRefresh()` from `@/lib/use-refresh` instead.
|
||||||
|
* This hook is kept for backward compatibility during migration.
|
||||||
|
*/
|
||||||
export function useNoteRefreshOptional(): NoteRefreshContextType {
|
export function useNoteRefreshOptional(): NoteRefreshContextType {
|
||||||
const context = useContext(NoteRefreshContext)
|
const context = useContext(NoteRefreshContext)
|
||||||
return context ?? { refreshKey: 0, triggerRefresh: () => {}, notebooksRefreshKey: 0, triggerNotebooksRefresh: () => {} }
|
return context ?? { refreshKey: 0, triggerRefresh: () => {}, notebooksRefreshKey: 0, triggerNotebooksRefresh: () => {} }
|
||||||
|
|||||||
74
memento-note/context/editor-ui-context.tsx
Normal file
74
memento-note/context/editor-ui-context.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type HomeUiControls = {
|
||||||
|
isTabsMode: boolean
|
||||||
|
openNoteComposer: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorUIContextValue {
|
||||||
|
// HomeView controls
|
||||||
|
controls: HomeUiControls | null
|
||||||
|
setControls: (c: HomeUiControls | null) => void
|
||||||
|
|
||||||
|
// NotebookDrag controls
|
||||||
|
draggedNoteId: string | null
|
||||||
|
dragOverNotebookId: string | null
|
||||||
|
startDrag: (noteId: string) => void
|
||||||
|
endDrag: () => void
|
||||||
|
dragOver: (notebookId: string | null) => void
|
||||||
|
isDragging: boolean
|
||||||
|
isDragOver: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorUIContext = createContext<EditorUIContextValue | null>(null)
|
||||||
|
|
||||||
|
export function EditorUIProvider({ children }: { children: ReactNode }) {
|
||||||
|
// HomeView state
|
||||||
|
const [controls, setControls] = useState<HomeUiControls | null>(null)
|
||||||
|
|
||||||
|
// NotebookDrag state
|
||||||
|
const [draggedNoteId, setDraggedNoteId] = useState<string | null>(null)
|
||||||
|
const [dragOverNotebookId, setDragOverNotebookId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const startDrag = useCallback((noteId: string) => {
|
||||||
|
setDraggedNoteId(noteId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const endDrag = useCallback(() => {
|
||||||
|
setDraggedNoteId(null)
|
||||||
|
setDragOverNotebookId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dragOver = useCallback((notebookId: string | null) => {
|
||||||
|
setDragOverNotebookId(notebookId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isDragging = draggedNoteId !== null
|
||||||
|
const isDragOver = dragOverNotebookId !== null
|
||||||
|
|
||||||
|
const value = useMemo<EditorUIContextValue>(() => ({
|
||||||
|
controls,
|
||||||
|
setControls,
|
||||||
|
draggedNoteId,
|
||||||
|
dragOverNotebookId,
|
||||||
|
startDrag,
|
||||||
|
endDrag,
|
||||||
|
dragOver,
|
||||||
|
isDragging,
|
||||||
|
isDragOver,
|
||||||
|
}), [controls, draggedNoteId, dragOverNotebookId, startDrag, endDrag, dragOver, isDragging, isDragOver])
|
||||||
|
|
||||||
|
return <EditorUIContext.Provider value={value}>{children}</EditorUIContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorUI() {
|
||||||
|
const ctx = useContext(EditorUIContext)
|
||||||
|
if (!ctx) throw new Error('useEditorUI must be used within EditorUIProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorUIOptional(): EditorUIContextValue | null {
|
||||||
|
return useContext(EditorUIContext)
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
|
|
||||||
|
|
||||||
export type HomeUiControls = {
|
|
||||||
isTabsMode: boolean
|
|
||||||
openNoteComposer: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type Ctx = {
|
|
||||||
controls: HomeUiControls | null
|
|
||||||
setControls: (c: HomeUiControls | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const HomeViewContext = createContext<Ctx | null>(null)
|
|
||||||
|
|
||||||
export function HomeViewProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [controls, setControls] = useState<HomeUiControls | null>(null)
|
|
||||||
const value = useMemo(() => ({ controls, setControls }), [controls])
|
|
||||||
|
|
||||||
return <HomeViewContext.Provider value={value}>{children}</HomeViewContext.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enregistré par la page d’accueil ; la sidebar lit `controls` */
|
|
||||||
export function useHomeView() {
|
|
||||||
const ctx = useContext(HomeViewContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useHomeView must be used within HomeViewProvider')
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sidebar / shells : ne pas planter si hors provider */
|
|
||||||
export function useHomeViewOptional(): Ctx | null {
|
|
||||||
return useContext(HomeViewContext)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface NotebookDragContextValue {
|
|
||||||
draggedNoteId: string | null
|
|
||||||
dragOverNotebookId: string | null
|
|
||||||
startDrag: (noteId: string) => void
|
|
||||||
endDrag: () => void
|
|
||||||
dragOver: (notebookId: string | null) => void
|
|
||||||
isDragging: boolean
|
|
||||||
isDragOver: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotebookDragContext = createContext<NotebookDragContextValue | null>(null)
|
|
||||||
|
|
||||||
export function useNotebookDrag() {
|
|
||||||
const context = useContext(NotebookDragContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useNotebookDrag must be used within NotebookDragProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotebookDragProviderProps {
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotebookDragProvider({ children }: NotebookDragProviderProps) {
|
|
||||||
const [draggedNoteId, setDraggedNoteId] = useState<string | null>(null)
|
|
||||||
const [dragOverNotebookId, setDragOverNotebookId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const startDrag = useCallback((noteId: string) => {
|
|
||||||
setDraggedNoteId(noteId)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const endDrag = useCallback(() => {
|
|
||||||
setDraggedNoteId(null)
|
|
||||||
setDragOverNotebookId(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const dragOver = useCallback((notebookId: string | null) => {
|
|
||||||
setDragOverNotebookId(notebookId)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const isDragging = draggedNoteId !== null
|
|
||||||
const isDragOver = dragOverNotebookId !== null
|
|
||||||
|
|
||||||
const value = useMemo(() => ({
|
|
||||||
draggedNoteId,
|
|
||||||
dragOverNotebookId,
|
|
||||||
startDrag,
|
|
||||||
endDrag,
|
|
||||||
dragOver,
|
|
||||||
isDragging,
|
|
||||||
isDragOver,
|
|
||||||
}), [draggedNoteId, dragOverNotebookId, startDrag, endDrag, dragOver, isDragging, isDragOver])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NotebookDragContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</NotebookDragContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'
|
import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import type { Notebook, Label, Note } from '@/lib/types'
|
import type { Notebook, Label, Note } from '@/lib/types'
|
||||||
|
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
|
||||||
|
import { getHashColor } from '@/lib/utils'
|
||||||
import { useNoteRefresh } from './NoteRefreshContext'
|
import { useNoteRefresh } from './NoteRefreshContext'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { queryKeys } from '@/lib/query-keys'
|
||||||
|
|
||||||
// ===== INPUT TYPES =====
|
// ===== INPUT TYPES =====
|
||||||
export interface CreateNotebookInput {
|
export interface CreateNotebookInput {
|
||||||
@@ -39,6 +43,15 @@ export interface NotebooksContextValue {
|
|||||||
isMovingNote: boolean
|
isMovingNote: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
|
||||||
|
// Labels from /api/labels (merged from LabelContext)
|
||||||
|
labels: Label[]
|
||||||
|
loading: boolean
|
||||||
|
notebookId: string | null
|
||||||
|
setNotebookId: (notebookId: string | null) => void
|
||||||
|
addLabel: (name: string, color?: LabelColorName, labelNotebookId?: string | null) => Promise<void>
|
||||||
|
refreshLabels: () => Promise<void>
|
||||||
|
getLabelColor: (name: string) => LabelColorName
|
||||||
|
|
||||||
// Actions: Notebooks
|
// Actions: Notebooks
|
||||||
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<void>
|
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<void>
|
||||||
updateNotebook: (notebookId: string, data: UpdateNotebookInput) => Promise<void>
|
updateNotebook: (notebookId: string, data: UpdateNotebookInput) => Promise<void>
|
||||||
@@ -79,6 +92,7 @@ interface NotebooksProviderProps {
|
|||||||
|
|
||||||
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
|
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
|
||||||
// ===== BASE STATE =====
|
// ===== BASE STATE =====
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
|
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
|
||||||
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
|
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -86,6 +100,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
|
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
|
||||||
|
|
||||||
|
// ===== LABEL STATE (merged from LabelContext) =====
|
||||||
|
const [labels, setLabels] = useState<Label[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [notebookId, setNotebookId] = useState<string | null>(null)
|
||||||
|
|
||||||
// ===== DERIVED STATE =====
|
// ===== DERIVED STATE =====
|
||||||
const currentLabels = useMemo(() => {
|
const currentLabels = useMemo(() => {
|
||||||
if (!currentNotebook) return []
|
if (!currentNotebook) return []
|
||||||
@@ -119,6 +138,34 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
if (notebooksRefreshKey > 0) loadNotebooks()
|
if (notebooksRefreshKey > 0) loadNotebooks()
|
||||||
}, [notebooksRefreshKey, loadNotebooks])
|
}, [notebooksRefreshKey, loadNotebooks])
|
||||||
|
|
||||||
|
// ===== LABEL FETCHING (merged from LabelContext) =====
|
||||||
|
const fetchLabels = useCallback(async (nbId: string | null) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const url = new URL('/api/labels', window.location.origin)
|
||||||
|
if (nbId) {
|
||||||
|
url.searchParams.set('notebookId', nbId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.data) {
|
||||||
|
setLabels(data.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch labels:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLabels(notebookId)
|
||||||
|
}, [notebookId, fetchLabels])
|
||||||
|
|
||||||
// ===== ACTIONS: NOTEBOOKS =====
|
// ===== ACTIONS: NOTEBOOKS =====
|
||||||
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
||||||
const response = await fetch('/api/notebooks', {
|
const response = await fetch('/api/notebooks', {
|
||||||
@@ -131,11 +178,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
throw new Error('Failed to create notebook')
|
throw new Error('Failed to create notebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload notebooks from server to update sidebar state
|
// Invalidate notebooks cache — React Query will re-fetch in bg
|
||||||
await loadNotebooks()
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
||||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||||
@@ -148,10 +195,10 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
throw new Error('Failed to update notebook')
|
throw new Error('Failed to update notebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const deleteNotebook = useCallback(async (notebookId: string) => {
|
const deleteNotebook = useCallback(async (notebookId: string) => {
|
||||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||||
@@ -162,10 +209,10 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
throw new Error('Failed to delete notebook')
|
throw new Error('Failed to delete notebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
||||||
const response = await fetch('/api/notebooks/reorder', {
|
const response = await fetch('/api/notebooks/reorder', {
|
||||||
@@ -178,12 +225,47 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
throw new Error('Failed to update notebook order')
|
throw new Error('Failed to update notebook order')
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
// ===== ACTIONS: LABELS =====
|
// ===== LABEL ACTIONS (merged from LabelContext) =====
|
||||||
|
const addLabel = useCallback(async (name: string, color?: LabelColorName, labelNotebookId?: string | null) => {
|
||||||
|
try {
|
||||||
|
const labelColor = color || getHashColor(name);
|
||||||
|
const finalNotebookId = labelNotebookId || notebookId
|
||||||
|
|
||||||
|
const response = await fetch('/api/labels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, color: labelColor, notebookId: finalNotebookId }),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.data) {
|
||||||
|
setLabels(prev => [...prev, data.data])
|
||||||
|
// Invalidate labels cache
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.labels(finalNotebookId) })
|
||||||
|
triggerRefresh()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add label:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, [notebookId, queryClient, triggerRefresh])
|
||||||
|
|
||||||
|
const getLabelColor = useCallback((name: string): LabelColorName => {
|
||||||
|
const label = labels.find(l => l.name.toLowerCase() === name.toLowerCase())
|
||||||
|
return label?.color || 'gray'
|
||||||
|
}, [labels])
|
||||||
|
|
||||||
|
const refreshLabels = useCallback(async () => {
|
||||||
|
await fetchLabels(notebookId)
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
|
||||||
|
triggerRefresh()
|
||||||
|
}, [fetchLabels, notebookId, queryClient, triggerRefresh])
|
||||||
|
|
||||||
|
// ===== ACTIONS: LABELS (keep existing API-based ones for compatibility) =====
|
||||||
const createLabel = useCallback(async (data: CreateLabelInput) => {
|
const createLabel = useCallback(async (data: CreateLabelInput) => {
|
||||||
const response = await fetch('/api/labels', {
|
const response = await fetch('/api/labels', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -196,8 +278,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
// Invalidate labels cache for this notebook
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.labels(data.notebookId) })
|
||||||
|
triggerRefresh()
|
||||||
return result
|
return result
|
||||||
}, [])
|
}, [queryClient, triggerRefresh])
|
||||||
|
|
||||||
const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
|
const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
|
||||||
const response = await fetch(`/api/labels/${labelId}`, {
|
const response = await fetch(`/api/labels/${labelId}`, {
|
||||||
@@ -209,7 +294,17 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to update label')
|
throw new Error('Failed to update label')
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setLabels(prev => prev.map(label =>
|
||||||
|
label.id === labelId ? { ...label, ...result.data } : label
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// Invalidate labels cache
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
|
||||||
|
triggerRefresh()
|
||||||
|
}, [notebookId, queryClient, triggerRefresh])
|
||||||
|
|
||||||
const deleteLabel = useCallback(async (labelId: string) => {
|
const deleteLabel = useCallback(async (labelId: string) => {
|
||||||
const response = await fetch(`/api/labels/${labelId}`, {
|
const response = await fetch(`/api/labels/${labelId}`, {
|
||||||
@@ -219,23 +314,30 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete label')
|
throw new Error('Failed to delete label')
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
setLabels(prev => prev.filter(label => label.id !== labelId))
|
||||||
|
// Invalidate labels cache
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
|
||||||
|
triggerRefresh()
|
||||||
|
}, [notebookId, queryClient, triggerRefresh])
|
||||||
|
|
||||||
// ===== ACTIONS: NOTES =====
|
// ===== ACTIONS: NOTES =====
|
||||||
const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, notebookId: string | null) => {
|
const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, targetNotebookId: string | null) => {
|
||||||
setIsMovingNote(true)
|
setIsMovingNote(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/notes/${noteId}/move`, {
|
const response = await fetch(`/api/notes/${noteId}/move`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ notebookId }),
|
body: JSON.stringify({ notebookId: targetNotebookId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to move note')
|
throw new Error('Failed to move note')
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
// Invalidate notebooks and notes cache
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.notes(null) }) // notes list
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -244,7 +346,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
} finally {
|
} finally {
|
||||||
setIsMovingNote(false)
|
setIsMovingNote(false)
|
||||||
}
|
}
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [queryClient, triggerRefresh, triggerNotebooksRefresh])
|
||||||
|
|
||||||
// ===== ACTIONS: AI (STUBS) =====
|
// ===== ACTIONS: AI (STUBS) =====
|
||||||
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
|
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
|
||||||
@@ -265,6 +367,13 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
isLoading,
|
isLoading,
|
||||||
isMovingNote,
|
isMovingNote,
|
||||||
error,
|
error,
|
||||||
|
labels,
|
||||||
|
loading,
|
||||||
|
notebookId,
|
||||||
|
setNotebookId,
|
||||||
|
addLabel,
|
||||||
|
refreshLabels,
|
||||||
|
getLabelColor,
|
||||||
createNotebookOptimistic,
|
createNotebookOptimistic,
|
||||||
updateNotebook,
|
updateNotebook,
|
||||||
deleteNotebook,
|
deleteNotebook,
|
||||||
@@ -284,6 +393,12 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
isLoading,
|
isLoading,
|
||||||
isMovingNote,
|
isMovingNote,
|
||||||
error,
|
error,
|
||||||
|
labels,
|
||||||
|
loading,
|
||||||
|
notebookId,
|
||||||
|
addLabel,
|
||||||
|
refreshLabels,
|
||||||
|
getLabelColor,
|
||||||
createNotebookOptimistic,
|
createNotebookOptimistic,
|
||||||
updateNotebook,
|
updateNotebook,
|
||||||
deleteNotebook,
|
deleteNotebook,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user