Compare commits
55 Commits
0f48df114a
...
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 | ||
|
|
db899b0da2 | ||
|
|
7bc63158bf | ||
|
|
ccbd1b5abc | ||
|
|
df943878a0 | ||
|
|
e458b63115 | ||
|
|
0d8252aec0 | ||
|
|
33ad874e5d | ||
|
|
3b72e4afbb | ||
|
|
ccc94a4b35 | ||
|
|
fb6823e25e | ||
|
|
7326cfc98f | ||
|
|
4f950740eb | ||
|
|
db200bbc9f | ||
|
|
08a49a04ce | ||
|
|
ab914f0587 | ||
|
|
b55f558a62 | ||
|
|
19d0b2759a | ||
|
|
34a977b5c4 | ||
|
|
75b08ef53b | ||
|
|
98e246e257 | ||
|
|
ff0b56f805 | ||
|
|
d1e08f64c8 | ||
|
|
e7f28abccc | ||
|
|
129d5541e6 | ||
|
|
21fb56de3f | ||
|
|
0ebf10344d | ||
|
|
0311c97a35 | ||
|
|
1ed7839334 | ||
|
|
718f4c6900 | ||
|
|
aee4b17306 | ||
|
|
b611ec874d | ||
| 635e516616 | |||
| a7c3251b49 | |||
| 5375f874cd |
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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ jobs:
|
|||||||
|
|
||||||
echo "=== Git pull ==="
|
echo "=== Git pull ==="
|
||||||
git config --global --add safe.directory /opt/memento
|
git config --global --add safe.directory /opt/memento
|
||||||
git pull origin main
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
|
||||||
echo "=== Building ==="
|
echo "=== Building ==="
|
||||||
docker compose build memento-note
|
docker compose build memento-note
|
||||||
|
|||||||
9
architectural-grid (2)/.env.example
Normal file
9
architectural-grid (2)/.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 (2)/.gitignore
vendored
Normal file
8
architectural-grid (2)/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid (2)/README.md
Normal file
20
architectural-grid (2)/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 (2)/index.html
Normal file
13
architectural-grid (2)/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 (2)/metadata.json
Normal file
6
architectural-grid (2)/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 (2)/package.json
Normal file
34
architectural-grid (2)/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
579
architectural-grid (2)/src/App.tsx
Normal file
579
architectural-grid (2)/src/App.tsx
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Share2,
|
||||||
|
Archive,
|
||||||
|
Settings,
|
||||||
|
Lock,
|
||||||
|
ChevronRight,
|
||||||
|
MoreVertical,
|
||||||
|
ArrowLeft
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
id: string;
|
||||||
|
carnetId: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
imageUrl: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Carnet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
initial: string;
|
||||||
|
type: 'Private' | 'Project' | 'Shared';
|
||||||
|
isPrivate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock Data ---
|
||||||
|
|
||||||
|
const CARNETS: Carnet[] = [
|
||||||
|
{ id: '1', name: 'Daily Notes', initial: 'D', type: 'Private', isPrivate: true },
|
||||||
|
{ id: '2', name: 'Project: Neo', initial: 'P', type: 'Project' },
|
||||||
|
{ id: '3', name: 'Shared Docs', initial: 'S', type: 'Shared' },
|
||||||
|
{ id: '4', name: 'Architecture Research', initial: 'A', type: 'Project' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_NOTES: Note[] = [
|
||||||
|
{
|
||||||
|
id: 'n1',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Grid Systems',
|
||||||
|
date: 'Oct 26, 2024',
|
||||||
|
content: 'Grid Systems is streathen in ognitiacs clesign and simulhere desipmalt: complded structurer and manamateriai-s: ci arevenuatingly used, asiller straterty of insaee to the tmn and usaes of disrension, architecture of emiornabious tracious structures.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n2',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Materiality',
|
||||||
|
date: 'Oct 24, 2024',
|
||||||
|
content: 'Materiality is combinated by relliaitic structureirs measure of plastics, natural, materials and priotical structures. Materialed coasts erabiocera alann light spaces and octicm employed design on thodolen of materiality, and tohlite tersev/ used in the gridin structures en obain materials, coms pathetic structure.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n3',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Light & Space',
|
||||||
|
date: 'Oct 22, 2024',
|
||||||
|
content: 'Light & Space is a creaivity of light & Space inralicated in sizazant or dark crotrcning and netrescenations of avant trurme sivonpaltures for in inncr-en allimativefiting is cerriadating and sityle.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n4',
|
||||||
|
carnetId: '2',
|
||||||
|
title: 'Neo-Brutalism study',
|
||||||
|
date: 'Sep 12, 2024',
|
||||||
|
content: 'Exploring the raw aesthetic of neo-brutalism in urban environments. Focus on concrete textures and massive forms.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Components ---
|
||||||
|
|
||||||
|
interface NoteLinkProps {
|
||||||
|
note: Note;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg
|
||||||
|
${isActive ? 'bg-white/50 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
|
||||||
|
<span className="truncate">{note.title}</span>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SidebarItemProps {
|
||||||
|
carnet: Carnet;
|
||||||
|
isActive: boolean;
|
||||||
|
notes: Note[];
|
||||||
|
activeNoteId: string | null;
|
||||||
|
onCarnetClick: () => void;
|
||||||
|
onNoteClick: (noteId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||||
|
carnet,
|
||||||
|
isActive,
|
||||||
|
notes,
|
||||||
|
activeNoteId,
|
||||||
|
onCarnetClick,
|
||||||
|
onNoteClick
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ x: 4 }}
|
||||||
|
onClick={() => {
|
||||||
|
onCarnetClick();
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
|
||||||
|
${isActive ? 'active-nav-item' : 'hover:bg-white/40'}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isActive ? 90 : 0 }}
|
||||||
|
className="text-muted-ink"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</motion.div>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border
|
||||||
|
${isActive ? 'bg-ink text-paper border-ink' : 'bg-white/60 text-ink border-border'}`}>
|
||||||
|
{carnet.initial}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[13px] font-medium transition-colors ${isActive ? 'text-ink' : 'text-muted-ink'}`}>
|
||||||
|
{carnet.name}
|
||||||
|
</span>
|
||||||
|
{carnet.isPrivate && <Lock size={10} className="text-muted-ink" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className="overflow-hidden space-y-0.5"
|
||||||
|
>
|
||||||
|
{notes.map(note => (
|
||||||
|
<NoteLink
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
isActive={activeNoteId === note.id}
|
||||||
|
onClick={() => onNoteClick(note.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<p className="pl-12 text-[11px] text-muted-ink/50 py-2 italic font-light">No notes yet</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
|
||||||
|
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
|
||||||
|
const [activeCarnetId, setActiveCarnetId] = useState('4');
|
||||||
|
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal States
|
||||||
|
const [showNewCarnetModal, setShowNewCarnetModal] = useState(false);
|
||||||
|
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
|
||||||
|
|
||||||
|
// Form States
|
||||||
|
const [newCarnetName, setNewCarnetName] = useState('');
|
||||||
|
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||||
|
const [newNoteContent, setNewNoteContent] = useState('');
|
||||||
|
|
||||||
|
const filteredNotes = useMemo(() =>
|
||||||
|
notes.filter(n => n.carnetId === activeCarnetId),
|
||||||
|
[activeCarnetId, notes]);
|
||||||
|
|
||||||
|
const activeNote = useMemo(() =>
|
||||||
|
notes.find(n => n.id === activeNoteId),
|
||||||
|
[activeNoteId, notes]);
|
||||||
|
|
||||||
|
const activeCarnet = useMemo(() =>
|
||||||
|
carnets.find(c => c.id === activeCarnetId),
|
||||||
|
[activeCarnetId, carnets]);
|
||||||
|
|
||||||
|
const handleAddCarnet = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newCarnetName.trim()) return;
|
||||||
|
|
||||||
|
const newCarnet: Carnet = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: newCarnetName,
|
||||||
|
initial: newCarnetName.charAt(0).toUpperCase(),
|
||||||
|
type: 'Project'
|
||||||
|
};
|
||||||
|
|
||||||
|
setCarnets([...carnets, newCarnet]);
|
||||||
|
setNewCarnetName('');
|
||||||
|
setShowNewCarnetModal(false);
|
||||||
|
setActiveCarnetId(newCarnet.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNote = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newNoteTitle.trim() || !newNoteContent.trim()) return;
|
||||||
|
|
||||||
|
const newNote: Note = {
|
||||||
|
id: `n-${Date.now()}`,
|
||||||
|
carnetId: activeCarnetId,
|
||||||
|
title: newNoteTitle,
|
||||||
|
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
|
||||||
|
content: newNoteContent,
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
};
|
||||||
|
|
||||||
|
setNotes([newNote, ...notes]);
|
||||||
|
setNewNoteTitle('');
|
||||||
|
setNewNoteContent('');
|
||||||
|
setShowNewNoteModal(false);
|
||||||
|
setActiveNoteId(newNote.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 md:p-8 lg:p-12 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 z-0 bg-[#DEDEDE]" />
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-7xl h-[85vh] flex rounded-2xl overflow-hidden shadow-2xl sidebar-shadow bg-paper">
|
||||||
|
<div className="absolute -right-4 -bottom-4 w-full h-full bg-white/40 rounded-2xl -z-10 translate-x-2 translate-y-2 opacity-50" />
|
||||||
|
<div className="absolute -right-2 -bottom-2 w-full h-full bg-white/60 rounded-2xl -z-10 translate-x-1 translate-y-1 opacity-70" />
|
||||||
|
|
||||||
|
<aside className="w-80 bg-white/30 backdrop-blur-md border-right border-border p-6 flex flex-col z-20 shrink-0">
|
||||||
|
<div className="mb-10">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
|
||||||
|
Architecture Grid
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{carnets.map(carnet => (
|
||||||
|
<SidebarItem
|
||||||
|
key={carnet.id}
|
||||||
|
carnet={carnet}
|
||||||
|
isActive={activeCarnetId === carnet.id}
|
||||||
|
notes={notes.filter(n => n.carnetId === carnet.id)}
|
||||||
|
activeNoteId={activeNoteId}
|
||||||
|
onCarnetClick={() => {
|
||||||
|
setActiveCarnetId(carnet.id);
|
||||||
|
setActiveNoteId(null);
|
||||||
|
}}
|
||||||
|
onNoteClick={(id) => {
|
||||||
|
setActiveCarnetId(carnet.id);
|
||||||
|
setActiveNoteId(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewCarnetModal(true)}
|
||||||
|
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium rounded-lg hover:bg-white/40"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>New Carnet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-border space-y-4">
|
||||||
|
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
|
||||||
|
<Archive size={16} />
|
||||||
|
<span>Archive</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
|
||||||
|
<Settings size={16} />
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 paper-texture relative overflow-hidden z-10 flex flex-col h-full">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!activeNoteId ? (
|
||||||
|
<motion.div
|
||||||
|
key="notebook"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="h-full flex flex-col overflow-y-auto"
|
||||||
|
>
|
||||||
|
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
|
||||||
|
{activeCarnet?.name} — {filteredNotes[0]?.date || 'Oct 26'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewNoteModal(true)}
|
||||||
|
className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Add Note</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
||||||
|
<Search size={16} />
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
||||||
|
<Share2 size={16} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="px-12 flex-1 pb-20">
|
||||||
|
<div className="max-w-3xl space-y-16">
|
||||||
|
{filteredNotes.map((note, index) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 * index, duration: 0.8 }}
|
||||||
|
key={note.id}
|
||||||
|
className="space-y-4 group cursor-pointer"
|
||||||
|
onClick={() => setActiveNoteId(note.id)}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
|
||||||
|
{note.title}
|
||||||
|
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<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">
|
||||||
|
<img
|
||||||
|
src={note.imageUrl}
|
||||||
|
alt={note.title}
|
||||||
|
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"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
|
||||||
|
{note.content}
|
||||||
|
</p>
|
||||||
|
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
{filteredNotes.length === 0 && (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||||
|
<p className="font-serif text-xl italic text-muted-ink">This notebook is waiting for its first vision.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewNoteModal(true)}
|
||||||
|
className="px-6 py-2 border border-ink text-[13px] uppercase tracking-[0.2em] hover:bg-ink hover:text-paper transition-all"
|
||||||
|
>
|
||||||
|
Begin Drawing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
|
||||||
|
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
|
||||||
|
© 2024 Architectural Grid. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="focused-note"
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 1.02 }}
|
||||||
|
className="h-full flex flex-col overflow-y-auto bg-white"
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 backdrop-blur-sm z-40 border-b border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveNoteId(null)}
|
||||||
|
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
<span className="text-sm font-medium">Back to collection</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
|
||||||
|
<Share2 size={18} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
|
||||||
|
<MoreVertical size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
|
||||||
|
<span>{activeCarnet?.name}</span>
|
||||||
|
<ChevronRight size={10} />
|
||||||
|
<span>{activeNote?.date}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
|
||||||
|
{activeNote?.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-[16/9] w-full bg-slate-100 rounded-xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src={activeNote?.imageUrl}
|
||||||
|
alt={activeNote?.title}
|
||||||
|
className="w-full h-full object-cover grayscale contrast-110"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto space-y-8 pb-32">
|
||||||
|
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
|
||||||
|
{activeNote?.content.split('.')[0]}.
|
||||||
|
</p>
|
||||||
|
<div className="h-px bg-border w-32" />
|
||||||
|
<p className="text-lg leading-relaxed text-ink/80 font-light space-y-4 text-justify whitespace-pre-line">
|
||||||
|
{activeNote?.content}
|
||||||
|
{activeNote?.id.startsWith('n-') && (
|
||||||
|
<>
|
||||||
|
<br /><br />
|
||||||
|
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showNewCarnetModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShowNewCarnetModal(false)}
|
||||||
|
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="relative w-full max-w-md bg-paper border border-border shadow-2xl rounded-2xl p-8"
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-serif font-medium text-ink mb-6">Create New Carnet</h3>
|
||||||
|
<form onSubmit={handleAddCarnet} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Notebook Name</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={newCarnetName}
|
||||||
|
onChange={(e) => setNewCarnetName(e.target.value)}
|
||||||
|
placeholder="E.g., Sustainable Patterns"
|
||||||
|
className="w-full bg-white border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewCarnetModal(false)}
|
||||||
|
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 py-3 bg-ink text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Create Notebook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNewNoteModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShowNewNoteModal(false)}
|
||||||
|
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="relative w-full max-w-2xl bg-paper border border-border shadow-2xl rounded-2xl p-10"
|
||||||
|
>
|
||||||
|
<h3 className="text-3xl font-serif font-medium text-ink mb-8">Add Architectural Note</h3>
|
||||||
|
<form onSubmit={handleAddNote} className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Concept Title</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={newNoteTitle}
|
||||||
|
onChange={(e) => setNewNoteTitle(e.target.value)}
|
||||||
|
placeholder="Enter the title of your study..."
|
||||||
|
className="w-full bg-white border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Observations & Analysis</label>
|
||||||
|
<textarea
|
||||||
|
value={newNoteContent}
|
||||||
|
onChange={(e) => setNewNoteContent(e.target.value)}
|
||||||
|
placeholder="Describe the spatial logic, materiality, and light interactions..."
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-white border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewNoteModal(false)}
|
||||||
|
className="flex-1 py-4 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 py-4 bg-ink text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Save Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
52
architectural-grid (2)/src/index.css
Normal file
52
architectural-grid (2)/src/index.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@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.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(28, 28, 28, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 (2)/src/main.tsx
Normal file
10
architectural-grid (2)/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 (2)/tsconfig.json
Normal file
26
architectural-grid (2)/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 (2)/vite.config.ts
Normal file
24
architectural-grid (2)/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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
9
architectural-grid (3)/.env.example
Normal file
9
architectural-grid (3)/.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 (3)/.gitignore
vendored
Normal file
8
architectural-grid (3)/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid (3)/README.md
Normal file
20
architectural-grid (3)/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 (3)/index.html
Normal file
13
architectural-grid (3)/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 (3)/metadata.json
Normal file
6
architectural-grid (3)/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Architectural Grid",
|
||||||
|
"description": "A minimalist notebook for architectural research and conceptual sketches.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
0
architectural-grid (3)/package-lock.json
generated
Normal file
0
architectural-grid (3)/package-lock.json
generated
Normal file
34
architectural-grid (3)/package.json
Normal file
34
architectural-grid (3)/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
936
architectural-grid (3)/src/App.tsx
Normal file
936
architectural-grid (3)/src/App.tsx
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Share2,
|
||||||
|
Archive,
|
||||||
|
Settings,
|
||||||
|
Lock,
|
||||||
|
ChevronRight,
|
||||||
|
MoreVertical,
|
||||||
|
ArrowLeft,
|
||||||
|
Sparkles,
|
||||||
|
MessageSquare,
|
||||||
|
Wand2,
|
||||||
|
FileCode,
|
||||||
|
Globe,
|
||||||
|
Send,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
BookOpen,
|
||||||
|
Layout,
|
||||||
|
Scissors,
|
||||||
|
Zap,
|
||||||
|
Languages,
|
||||||
|
ArrowRightLeft,
|
||||||
|
History
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
type AITone = 'Professional' | 'Creative' | 'Academic' | 'Casual';
|
||||||
|
type AITab = 'discussion' | 'actions' | 'resources';
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
id: string;
|
||||||
|
carnetId: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
imageUrl: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Carnet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
initial: string;
|
||||||
|
type: 'Private' | 'Project' | 'Shared';
|
||||||
|
isPrivate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock Data ---
|
||||||
|
|
||||||
|
const CARNETS: Carnet[] = [
|
||||||
|
{ id: '1', name: 'Daily Notes', initial: 'D', type: 'Private', isPrivate: true },
|
||||||
|
{ id: '2', name: 'Project: Neo', initial: 'P', type: 'Project' },
|
||||||
|
{ id: '3', name: 'Shared Docs', initial: 'S', type: 'Shared' },
|
||||||
|
{ id: '4', name: 'Architecture Research', initial: 'A', type: 'Project' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_NOTES: Note[] = [
|
||||||
|
{
|
||||||
|
id: 'n1',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Grid Systems',
|
||||||
|
date: 'Oct 26, 2024',
|
||||||
|
content: 'Grid Systems is streathen in ognitiacs clesign and simulhere desipmalt: complded structurer and manamateriai-s: ci arevenuatingly used, asiller straterty of insaee to the tmn and usaes of disrension, architecture of emiornabious tracious structures.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n2',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Materiality',
|
||||||
|
date: 'Oct 24, 2024',
|
||||||
|
content: 'Materiality is combinated by relliaitic structureirs measure of plastics, natural, materials and priotical structures. Materialed coasts erabiocera alann light spaces and octicm employed design on thodolen of materiality, and tohlite tersev/ used in the gridin structures en obain materials, coms pathetic structure.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n3',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Light & Space',
|
||||||
|
date: 'Oct 22, 2024',
|
||||||
|
content: 'Light & Space is a creaivity of light & Space inralicated in sizazant or dark crotrcning and netrescenations of avant trurme sivonpaltures for in inncr-en allimativefiting is cerriadating and sityle.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n4',
|
||||||
|
carnetId: '2',
|
||||||
|
title: 'Neo-Brutalism study',
|
||||||
|
date: 'Sep 12, 2024',
|
||||||
|
content: 'Exploring the raw aesthetic of neo-brutalism in urban environments. Focus on concrete textures and massive forms.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Components ---
|
||||||
|
|
||||||
|
interface NoteLinkProps {
|
||||||
|
note: Note;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg
|
||||||
|
${isActive ? 'bg-white/50 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
|
||||||
|
<span className="truncate">{note.title}</span>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SidebarItemProps {
|
||||||
|
carnet: Carnet;
|
||||||
|
isActive: boolean;
|
||||||
|
notes: Note[];
|
||||||
|
activeNoteId: string | null;
|
||||||
|
onCarnetClick: () => void;
|
||||||
|
onNoteClick: (noteId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||||
|
carnet,
|
||||||
|
isActive,
|
||||||
|
notes,
|
||||||
|
activeNoteId,
|
||||||
|
onCarnetClick,
|
||||||
|
onNoteClick
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ x: 4 }}
|
||||||
|
onClick={() => {
|
||||||
|
onCarnetClick();
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
|
||||||
|
${isActive ? 'active-nav-item' : 'hover:bg-white/40'}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isActive ? 90 : 0 }}
|
||||||
|
className="text-muted-ink"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</motion.div>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border
|
||||||
|
${isActive ? 'bg-ink text-paper border-ink' : 'bg-white/60 text-ink border-border'}`}>
|
||||||
|
{carnet.initial}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[13px] font-medium transition-colors ${isActive ? 'text-ink' : 'text-muted-ink'}`}>
|
||||||
|
{carnet.name}
|
||||||
|
</span>
|
||||||
|
{carnet.isPrivate && <Lock size={10} className="text-muted-ink" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className="overflow-hidden space-y-0.5"
|
||||||
|
>
|
||||||
|
{notes.map(note => (
|
||||||
|
<NoteLink
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
isActive={activeNoteId === note.id}
|
||||||
|
onClick={() => onNoteClick(note.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<p className="pl-12 text-[11px] text-muted-ink/50 py-2 italic font-light">No notes yet</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
|
||||||
|
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
|
||||||
|
const [activeCarnetId, setActiveCarnetId] = useState('4');
|
||||||
|
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
|
||||||
|
const [isAISidebarOpen, setIsAISidebarOpen] = useState(false);
|
||||||
|
const [aiTab, setAiTab] = useState<AITab>('discussion');
|
||||||
|
const [selectedTone, setSelectedTone] = useState<AITone>('Professional');
|
||||||
|
|
||||||
|
// Modal States
|
||||||
|
const [showNewCarnetModal, setShowNewCarnetModal] = useState(false);
|
||||||
|
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
|
||||||
|
|
||||||
|
// Form States
|
||||||
|
const [newCarnetName, setNewCarnetName] = useState('');
|
||||||
|
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||||
|
const [newNoteContent, setNewNoteContent] = useState('');
|
||||||
|
|
||||||
|
const filteredNotes = useMemo(() =>
|
||||||
|
notes.filter(n => n.carnetId === activeCarnetId),
|
||||||
|
[activeCarnetId, notes]);
|
||||||
|
|
||||||
|
const activeNote = useMemo(() =>
|
||||||
|
notes.find(n => n.id === activeNoteId),
|
||||||
|
[activeNoteId, notes]);
|
||||||
|
|
||||||
|
const activeCarnet = useMemo(() =>
|
||||||
|
carnets.find(c => c.id === activeCarnetId),
|
||||||
|
[activeCarnetId, carnets]);
|
||||||
|
|
||||||
|
const handleAddCarnet = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newCarnetName.trim()) return;
|
||||||
|
|
||||||
|
const newCarnet: Carnet = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: newCarnetName,
|
||||||
|
initial: newCarnetName.charAt(0).toUpperCase(),
|
||||||
|
type: 'Project'
|
||||||
|
};
|
||||||
|
|
||||||
|
setCarnets([...carnets, newCarnet]);
|
||||||
|
setNewCarnetName('');
|
||||||
|
setShowNewCarnetModal(false);
|
||||||
|
setActiveCarnetId(newCarnet.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNote = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newNoteTitle.trim() || !newNoteContent.trim()) return;
|
||||||
|
|
||||||
|
const newNote: Note = {
|
||||||
|
id: `n-${Date.now()}`,
|
||||||
|
carnetId: activeCarnetId,
|
||||||
|
title: newNoteTitle,
|
||||||
|
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
|
||||||
|
content: newNoteContent,
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
};
|
||||||
|
|
||||||
|
setNotes([newNote, ...notes]);
|
||||||
|
setNewNoteTitle('');
|
||||||
|
setNewNoteContent('');
|
||||||
|
setShowNewNoteModal(false);
|
||||||
|
setActiveNoteId(newNote.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 md:p-8 lg:p-12 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 z-0 bg-[#DEDEDE]" />
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-7xl h-[85vh] flex rounded-2xl overflow-hidden shadow-2xl sidebar-shadow bg-paper">
|
||||||
|
<div className="absolute -right-4 -bottom-4 w-full h-full bg-white/40 rounded-2xl -z-10 translate-x-2 translate-y-2 opacity-50" />
|
||||||
|
<div className="absolute -right-2 -bottom-2 w-full h-full bg-white/60 rounded-2xl -z-10 translate-x-1 translate-y-1 opacity-70" />
|
||||||
|
|
||||||
|
<aside className="w-80 bg-white/30 backdrop-blur-md border-right border-border p-6 flex flex-col z-20 shrink-0">
|
||||||
|
<div className="mb-10">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
|
||||||
|
Architecture Grid
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{carnets.map(carnet => (
|
||||||
|
<SidebarItem
|
||||||
|
key={carnet.id}
|
||||||
|
carnet={carnet}
|
||||||
|
isActive={activeCarnetId === carnet.id}
|
||||||
|
notes={notes.filter(n => n.carnetId === carnet.id)}
|
||||||
|
activeNoteId={activeNoteId}
|
||||||
|
onCarnetClick={() => {
|
||||||
|
setActiveCarnetId(carnet.id);
|
||||||
|
setActiveNoteId(null);
|
||||||
|
}}
|
||||||
|
onNoteClick={(id) => {
|
||||||
|
setActiveCarnetId(carnet.id);
|
||||||
|
setActiveNoteId(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewCarnetModal(true)}
|
||||||
|
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium rounded-lg hover:bg-white/40"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>New Carnet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-border space-y-4">
|
||||||
|
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
|
||||||
|
<Archive size={16} />
|
||||||
|
<span>Archive</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
|
||||||
|
<Settings size={16} />
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 paper-texture relative overflow-hidden z-10 flex flex-col h-full">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!activeNoteId ? (
|
||||||
|
<motion.div
|
||||||
|
key="notebook"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="h-full flex flex-col overflow-y-auto"
|
||||||
|
>
|
||||||
|
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
|
||||||
|
{activeCarnet?.name} — {filteredNotes[0]?.date || 'Oct 26'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewNoteModal(true)}
|
||||||
|
className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Add Note</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
||||||
|
<Search size={16} />
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
||||||
|
<Share2 size={16} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="px-12 flex-1 pb-20">
|
||||||
|
<div className="max-w-3xl space-y-16">
|
||||||
|
{filteredNotes.map((note, index) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 * index, duration: 0.8 }}
|
||||||
|
key={note.id}
|
||||||
|
className="space-y-4 group cursor-pointer"
|
||||||
|
onClick={() => setActiveNoteId(note.id)}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
|
||||||
|
{note.title}
|
||||||
|
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<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">
|
||||||
|
<img
|
||||||
|
src={note.imageUrl}
|
||||||
|
alt={note.title}
|
||||||
|
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"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
|
||||||
|
{note.content}
|
||||||
|
</p>
|
||||||
|
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
{filteredNotes.length === 0 && (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||||
|
<p className="font-serif text-xl italic text-muted-ink">This notebook is waiting for its first vision.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewNoteModal(true)}
|
||||||
|
className="px-6 py-2 border border-ink text-[13px] uppercase tracking-[0.2em] hover:bg-ink hover:text-paper transition-all"
|
||||||
|
>
|
||||||
|
Begin Drawing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
|
||||||
|
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
|
||||||
|
© 2024 Architectural Grid. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="focused-note"
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 1.02 }}
|
||||||
|
className="h-full flex flex-col overflow-y-auto bg-white"
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex overflow-hidden transition-all duration-500">
|
||||||
|
<div className="flex-1 flex flex-col overflow-y-auto bg-white">
|
||||||
|
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 backdrop-blur-sm z-40 border-b border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveNoteId(null)}
|
||||||
|
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
<span className="text-sm font-medium">Back to collection</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAISidebarOpen(!isAISidebarOpen)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300
|
||||||
|
${isAISidebarOpen ? 'bg-ink text-paper border-ink' : 'border-border text-ink hover:bg-white/50'}`}
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
<span className="text-xs font-medium">AI Assistant</span>
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
|
||||||
|
<Share2 size={18} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
|
||||||
|
<MoreVertical size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
|
||||||
|
<span>{activeCarnet?.name}</span>
|
||||||
|
<ChevronRight size={10} />
|
||||||
|
<span>{activeNote?.date}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
|
||||||
|
{activeNote?.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-[16/9] w-full bg-slate-100 rounded-xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src={activeNote?.imageUrl}
|
||||||
|
alt={activeNote?.title}
|
||||||
|
className="w-full h-full object-cover grayscale contrast-110"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto space-y-8 pb-32">
|
||||||
|
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
|
||||||
|
{activeNote?.content.split('.')[0]}.
|
||||||
|
</p>
|
||||||
|
<div className="h-px bg-border w-32" />
|
||||||
|
<p className="text-lg leading-relaxed text-ink/80 font-light space-y-4 text-justify whitespace-pre-line">
|
||||||
|
{activeNote?.content}
|
||||||
|
{activeNote?.id.startsWith('n-') && (
|
||||||
|
<>
|
||||||
|
<br /><br />
|
||||||
|
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isAISidebarOpen && (
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: 400, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: 400, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
|
className="w-[400px] border-l border-border bg-white shadow-2xl flex flex-col z-50 shrink-0 relative"
|
||||||
|
>
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="p-6 border-b border-border space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
|
||||||
|
<Sparkles size={18} className="text-amber-500" />
|
||||||
|
IA Note
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAISidebarOpen(false)}
|
||||||
|
className="p-1 hover:bg-slate-100 rounded-full transition-colors text-muted-ink"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-ink uppercase tracking-wider font-medium opacity-60 truncate">
|
||||||
|
"{activeNote?.title}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Nav */}
|
||||||
|
<div className="flex border-b border-border px-2">
|
||||||
|
{(['discussion', 'actions', 'resources'] as AITab[]).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setAiTab(tab)}
|
||||||
|
className={`flex-1 py-3 text-[11px] uppercase tracking-widest font-bold transition-all relative
|
||||||
|
${aiTab === tab ? 'text-ink' : 'text-muted-ink hover:text-ink/60'}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
{aiTab === tab && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeTab"
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{aiTab === 'discussion' && (
|
||||||
|
<motion.div
|
||||||
|
key="discussion"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4 text-muted-ink/40">
|
||||||
|
<div className="w-16 h-16 rounded-full border border-dashed border-muted-ink/20 flex items-center justify-center">
|
||||||
|
<MessageSquare size={24} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-serif italic leading-relaxed px-8">Posez une question à l'Assistant pour commencer.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Contexte</label>
|
||||||
|
<div className="w-full p-3 bg-slate-50 border border-border rounded-lg text-xs flex items-center justify-between cursor-pointer hover:bg-slate-100 transition-colors">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode size={14} className="text-muted-ink" />
|
||||||
|
<span>Cette note</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={14} className="rotate-90 text-muted-ink" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Ton d'écriture</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(['Professional', 'Creative', 'Academic', 'Casual'] as AITone[]).map((tone) => (
|
||||||
|
<button
|
||||||
|
key={tone}
|
||||||
|
onClick={() => setSelectedTone(tone)}
|
||||||
|
className={`p-3 rounded-xl border text-[11px] font-medium transition-all
|
||||||
|
${selectedTone === tone ? 'bg-ink text-paper border-ink' : 'bg-white border-border text-muted-ink hover:border-ink/20'}`}
|
||||||
|
>
|
||||||
|
{tone.charAt(0).toUpperCase() + tone.slice(1, 3)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiTab === 'actions' && (
|
||||||
|
<motion.div
|
||||||
|
key="actions"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Transformations Section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
|
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Transformations</h4>
|
||||||
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ icon: <Sparkles size={14} />, label: 'Clarifier' },
|
||||||
|
{ icon: <Scissors size={14} />, label: 'Raccourcir' },
|
||||||
|
{ icon: <Zap size={14} />, label: 'Améliorer' },
|
||||||
|
{ icon: <Languages size={14} />, label: 'Traduire' },
|
||||||
|
].map((action, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="flex flex-col items-center gap-3 p-4 bg-white border border-border rounded-xl transition-all group hover:border-ink/20"
|
||||||
|
>
|
||||||
|
<div className="p-2 rounded-lg bg-slate-50 transition-colors group-hover:bg-ink group-hover:text-paper shadow-sm text-ink/60">
|
||||||
|
{action.icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-bold text-ink/80 uppercase tracking-wider">{action.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-white border border-border rounded-xl text-[11px] font-bold text-ink/80 hover:bg-slate-50 transition-colors hover:border-ink/20 uppercase tracking-widest">
|
||||||
|
<FileCode size={14} className="text-muted-ink" />
|
||||||
|
Convertir en Markdown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generation Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
|
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Generation Tools</h4>
|
||||||
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presentation Tool */}
|
||||||
|
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-ink/20 transition-all duration-500 overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||||
|
<Layout size={80} className="text-ink" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative space-y-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-slate-50 rounded-lg text-ink/70">
|
||||||
|
<Layout size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h5 className="text-sm font-bold text-ink leading-none">Présentation</h5>
|
||||||
|
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Convertir en slides interactives</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[9px] uppercase tracking-widest font-bold text-muted-ink/60 px-1">Thème</span>
|
||||||
|
<select className="w-full bg-slate-50 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-ink/10 transition-all cursor-pointer">
|
||||||
|
<option>Architectural Mono</option>
|
||||||
|
<option>Vibrant Tech</option>
|
||||||
|
<option>Minimal Silk</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[9px] uppercase tracking-widest font-bold text-muted-ink/60 px-1">Style</span>
|
||||||
|
<select className="w-full bg-slate-50 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-ink/10 transition-all cursor-pointer">
|
||||||
|
<option>Professional</option>
|
||||||
|
<option>Creative</option>
|
||||||
|
<option>Brutalist</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full py-3.5 bg-ink text-paper rounded-xl text-[12px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-ink/10 uppercase tracking-widest">
|
||||||
|
Générer
|
||||||
|
<ArrowRightLeft size={14} className="opacity-60" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagram Tool */}
|
||||||
|
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-ink/20 transition-all duration-500 overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||||
|
<BookOpen size={80} className="text-ink" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative space-y-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-slate-50 rounded-lg text-ink/70">
|
||||||
|
<BookOpen size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h5 className="text-sm font-bold text-ink leading-none">Diagramme</h5>
|
||||||
|
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Visualisation de structure</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[9px] uppercase tracking-widest font-bold text-muted-ink/60 px-1">Type</span>
|
||||||
|
<select className="w-full bg-slate-50 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-ink/10 transition-all cursor-pointer">
|
||||||
|
<option>Logic Flow</option>
|
||||||
|
<option>Mind Map</option>
|
||||||
|
<option>Hierarchy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[9px] uppercase tracking-widest font-bold text-muted-ink/60 px-1">Style</span>
|
||||||
|
<select className="w-full bg-slate-50 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-ink/10 transition-all cursor-pointer">
|
||||||
|
<option>Draft</option>
|
||||||
|
<option>Polished</option>
|
||||||
|
<option>Handwritten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full py-3.5 bg-ink text-paper rounded-xl text-[12px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-ink/10 uppercase tracking-widest">
|
||||||
|
Tracer
|
||||||
|
<ArrowRightLeft size={14} className="opacity-60" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity section placeholder */}
|
||||||
|
<div className="flex flex-col items-center gap-2 opacity-20 py-4">
|
||||||
|
<History size={16} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">Auto-Save Enabled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiTab === 'resources' && (
|
||||||
|
<motion.div
|
||||||
|
key="resources"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">URL (Optionnel)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input type="text" placeholder="https://..." className="w-full bg-slate-50 border border-border rounded-lg pl-3 pr-10 py-3 text-xs outline-none focus:border-ink transition-colors" />
|
||||||
|
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-ink/40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Texte de la ressource</label>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
placeholder="Collez votre texte ici (markdown, HTML, texte brut...)"
|
||||||
|
className="w-full bg-slate-50 border border-border rounded-lg p-4 text-xs outline-none focus:border-ink transition-colors resize-none leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Mode d'intégration</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ id: 'replace', label: 'Remplacer', sub: 'Direct, sans IA' },
|
||||||
|
{ id: 'append', label: 'Compléter', sub: 'Ajoute sans réécrire' },
|
||||||
|
{ id: 'merge', label: 'Fusionner', sub: 'Réécrit et intègre' },
|
||||||
|
].map((mode) => (
|
||||||
|
<button key={mode.id} className={`flex flex-col items-center justify-center p-3 rounded-lg border transition-all text-center ${mode.id === 'append' ? 'bg-emerald-50 border-emerald-500/30 ring-1 ring-emerald-500/10' : 'bg-white border-border hover:bg-slate-50'}`}>
|
||||||
|
<span className={`text-[11px] font-bold ${mode.id === 'append' ? 'text-emerald-700' : 'text-ink'}`}>{mode.label}</span>
|
||||||
|
<span className="text-[8px] text-muted-ink opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full py-4 bg-[#75B2D6] text-white rounded-xl text-sm font-bold flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-blue-200">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
Générer l'aperçu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Input (Sticky bottom for Discussion) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{aiTab === 'discussion' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
className="p-6 bg-white border-t border-border"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Posez une question sur cette note..."
|
||||||
|
className="w-full bg-slate-50 border border-border rounded-2xl p-4 pr-12 text-sm outline-none focus:border-ink transition-colors resize-none leading-relaxed font-light"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 bottom-3 flex gap-2">
|
||||||
|
<button className="p-2 text-muted-ink hover:text-ink rounded-lg transition-colors">
|
||||||
|
<Globe size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 bg-[#75B2D6] text-white rounded-lg transition-transform hover:scale-105 active:scale-95 shadow-sm">
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-muted-ink text-center mt-3 uppercase tracking-widest font-bold opacity-30 italic">Maj+Entrée = nouvelle ligne</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.aside>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showNewCarnetModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShowNewCarnetModal(false)}
|
||||||
|
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="relative w-full max-w-md bg-paper border border-border shadow-2xl rounded-2xl p-8"
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-serif font-medium text-ink mb-6">Create New Carnet</h3>
|
||||||
|
<form onSubmit={handleAddCarnet} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Notebook Name</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={newCarnetName}
|
||||||
|
onChange={(e) => setNewCarnetName(e.target.value)}
|
||||||
|
placeholder="E.g., Sustainable Patterns"
|
||||||
|
className="w-full bg-white border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewCarnetModal(false)}
|
||||||
|
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 py-3 bg-ink text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Create Notebook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNewNoteModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShowNewNoteModal(false)}
|
||||||
|
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="relative w-full max-w-2xl bg-paper border border-border shadow-2xl rounded-2xl p-10"
|
||||||
|
>
|
||||||
|
<h3 className="text-3xl font-serif font-medium text-ink mb-8">Add Architectural Note</h3>
|
||||||
|
<form onSubmit={handleAddNote} className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Concept Title</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={newNoteTitle}
|
||||||
|
onChange={(e) => setNewNoteTitle(e.target.value)}
|
||||||
|
placeholder="Enter the title of your study..."
|
||||||
|
className="w-full bg-white border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Observations & Analysis</label>
|
||||||
|
<textarea
|
||||||
|
value={newNoteContent}
|
||||||
|
onChange={(e) => setNewNoteContent(e.target.value)}
|
||||||
|
placeholder="Describe the spatial logic, materiality, and light interactions..."
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-white border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewNoteModal(false)}
|
||||||
|
className="flex-1 py-4 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 py-4 bg-ink text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Save Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
58
architectural-grid (3)/src/index.css
Normal file
58
architectural-grid (3)/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 (3)/src/main.tsx
Normal file
10
architectural-grid (3)/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 (3)/tsconfig.json
Normal file
26
architectural-grid (3)/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 (3)/vite.config.ts
Normal file
24
architectural-grid (3)/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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
9
architectural-grid (4)/.env.example
Normal file
9
architectural-grid (4)/.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 (4)/.gitignore
vendored
Normal file
8
architectural-grid (4)/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid (4)/README.md
Normal file
20
architectural-grid (4)/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 (4)/index.html
Normal file
13
architectural-grid (4)/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 (4)/metadata.json
Normal file
6
architectural-grid (4)/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 (4)/package.json
Normal file
34
architectural-grid (4)/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1305
architectural-grid (4)/src/App.tsx
Normal file
1305
architectural-grid (4)/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
architectural-grid (4)/src/index.css
Normal file
58
architectural-grid (4)/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 (4)/src/main.tsx
Normal file
10
architectural-grid (4)/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 (4)/tsconfig.json
Normal file
26
architectural-grid (4)/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 (4)/vite.config.ts
Normal file
24
architectural-grid (4)/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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
9
architectural-grid1/.env.example
Normal file
9
architectural-grid1/.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-grid1/.gitignore
vendored
Normal file
8
architectural-grid1/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid1/README.md
Normal file
20
architectural-grid1/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-grid1/index.html
Normal file
13
architectural-grid1/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-grid1/metadata.json
Normal file
6
architectural-grid1/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-grid1/package.json
Normal file
34
architectural-grid1/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
408
architectural-grid1/src/App.tsx
Normal file
408
architectural-grid1/src/App.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Share2,
|
||||||
|
Archive,
|
||||||
|
Settings,
|
||||||
|
Lock,
|
||||||
|
ChevronRight,
|
||||||
|
MoreVertical,
|
||||||
|
ArrowLeft
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
id: string;
|
||||||
|
carnetId: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
imageUrl: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Carnet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
initial: string;
|
||||||
|
type: 'Private' | 'Project' | 'Shared';
|
||||||
|
isPrivate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock Data ---
|
||||||
|
|
||||||
|
const CARNETS: Carnet[] = [
|
||||||
|
{ id: '1', name: 'Daily Notes', initial: 'D', type: 'Private', isPrivate: true },
|
||||||
|
{ id: '2', name: 'Project: Neo', initial: 'P', type: 'Project' },
|
||||||
|
{ id: '3', name: 'Shared Docs', initial: 'S', type: 'Shared' },
|
||||||
|
{ id: '4', name: 'Architecture Research', initial: 'A', type: 'Project' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_NOTES: Note[] = [
|
||||||
|
{
|
||||||
|
id: 'n1',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Grid Systems',
|
||||||
|
date: 'Oct 26, 2024',
|
||||||
|
content: 'Grid Systems is streathen in ognitiacs clesign and simulhere desipmalt: complded structurer and manamateriai-s: ci arevenuatingly used, asiller straterty of insaee to the tmn and usaes of disrension, architecture of emiornabious tracious structures.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n2',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Materiality',
|
||||||
|
date: 'Oct 24, 2024',
|
||||||
|
content: 'Materiality is combinated by relliaitic structureirs measure of plastics, natural, materials and priotical structures. Materialed coasts erabiocera alann light spaces and octicm employed design on thodolen of materiality, and tohlite tersev/ used in the gridin structures en obain materials, coms pathetic structure.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n3',
|
||||||
|
carnetId: '4',
|
||||||
|
title: 'Light & Space',
|
||||||
|
date: 'Oct 22, 2024',
|
||||||
|
content: 'Light & Space is a creaivity of light & Space inralicated in sizazant or dark crotrcning and netrescenations of avant trurme sivonpaltures for in inncr-en allimativefiting is cerriadating and sityle.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n4',
|
||||||
|
carnetId: '2',
|
||||||
|
title: 'Neo-Brutalism study',
|
||||||
|
date: 'Sep 12, 2024',
|
||||||
|
content: 'Exploring the raw aesthetic of neo-brutalism in urban environments. Focus on concrete textures and massive forms.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&q=80&w=800&h=600'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Components ---
|
||||||
|
|
||||||
|
interface NoteLinkProps {
|
||||||
|
note: Note;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg
|
||||||
|
${isActive ? 'bg-white/50 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
|
||||||
|
<span className="truncate">{note.title}</span>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SidebarItemProps {
|
||||||
|
carnet: Carnet;
|
||||||
|
isActive: boolean;
|
||||||
|
notes: Note[];
|
||||||
|
activeNoteId: string | null;
|
||||||
|
onCarnetClick: () => void;
|
||||||
|
onNoteClick: (noteId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||||
|
carnet,
|
||||||
|
isActive,
|
||||||
|
notes,
|
||||||
|
activeNoteId,
|
||||||
|
onCarnetClick,
|
||||||
|
onNoteClick
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(isActive);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isActive) setIsExpanded(true);
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ x: 4 }}
|
||||||
|
onClick={() => {
|
||||||
|
onCarnetClick();
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
|
||||||
|
${isActive ? 'active-nav-item' : 'hover:bg-white/40'}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isExpanded ? 90 : 0 }}
|
||||||
|
className="text-muted-ink"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</motion.div>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border
|
||||||
|
${isActive ? 'bg-ink text-paper border-ink' : 'bg-white/60 text-ink border-border'}`}>
|
||||||
|
{carnet.initial}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[13px] font-medium transition-colors ${isActive ? 'text-ink' : 'text-muted-ink'}`}>
|
||||||
|
{carnet.name}
|
||||||
|
</span>
|
||||||
|
{carnet.isPrivate && <Lock size={10} className="text-muted-ink" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden space-y-0.5"
|
||||||
|
>
|
||||||
|
{notes.map(note => (
|
||||||
|
<NoteLink
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
isActive={activeNoteId === note.id}
|
||||||
|
onClick={() => onNoteClick(note.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<p className="pl-12 text-[11px] text-muted-ink/50 py-2 italic font-light">No notes yet</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [activeCarnetId, setActiveCarnetId] = useState('4');
|
||||||
|
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filteredNotes = useMemo(() =>
|
||||||
|
ALL_NOTES.filter(n => n.carnetId === activeCarnetId),
|
||||||
|
[activeCarnetId]);
|
||||||
|
|
||||||
|
const activeNote = useMemo(() =>
|
||||||
|
ALL_NOTES.find(n => n.id === activeNoteId),
|
||||||
|
[activeNoteId]);
|
||||||
|
|
||||||
|
const activeCarnet = useMemo(() =>
|
||||||
|
CARNETS.find(c => c.id === activeCarnetId),
|
||||||
|
[activeCarnetId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 md:p-8 lg:p-12 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 z-0 bg-[#DEDEDE]" />
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-7xl h-[85vh] flex rounded-2xl overflow-hidden shadow-2xl sidebar-shadow bg-paper">
|
||||||
|
<div className="absolute -right-4 -bottom-4 w-full h-full bg-white/40 rounded-2xl -z-10 translate-x-2 translate-y-2 opacity-50" />
|
||||||
|
<div className="absolute -right-2 -bottom-2 w-full h-full bg-white/60 rounded-2xl -z-10 translate-x-1 translate-y-1 opacity-70" />
|
||||||
|
|
||||||
|
<aside className="w-80 bg-white/30 backdrop-blur-md border-right border-border p-6 flex flex-col z-20 shrink-0">
|
||||||
|
<div className="mb-10">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
|
||||||
|
Architecture Grid
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{CARNETS.map(carnet => (
|
||||||
|
<SidebarItem
|
||||||
|
key={carnet.id}
|
||||||
|
carnet={carnet}
|
||||||
|
isActive={activeCarnetId === carnet.id}
|
||||||
|
notes={ALL_NOTES.filter(n => n.carnetId === carnet.id)}
|
||||||
|
activeNoteId={activeNoteId}
|
||||||
|
onCarnetClick={() => {
|
||||||
|
setActiveCarnetId(carnet.id);
|
||||||
|
setActiveNoteId(null);
|
||||||
|
}}
|
||||||
|
onNoteClick={(id) => {
|
||||||
|
setActiveCarnetId(carnet.id);
|
||||||
|
setActiveNoteId(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>New Carnet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-border space-y-4">
|
||||||
|
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
|
||||||
|
<Archive size={16} />
|
||||||
|
<span>Archive</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
|
||||||
|
<Settings size={16} />
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 paper-texture relative overflow-hidden z-10 flex flex-col h-full">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!activeNoteId ? (
|
||||||
|
<motion.div
|
||||||
|
key="notebook"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="h-full flex flex-col overflow-y-auto"
|
||||||
|
>
|
||||||
|
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
|
||||||
|
{activeCarnet?.name} — {filteredNotes[0]?.date || 'Oct 26'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Add Note</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
||||||
|
<Search size={16} />
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
||||||
|
<Share2 size={16} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="px-12 flex-1 pb-20">
|
||||||
|
<div className="max-w-3xl space-y-16">
|
||||||
|
{filteredNotes.map((note, index) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 * index, duration: 0.8 }}
|
||||||
|
key={note.id}
|
||||||
|
className="space-y-4 group cursor-pointer"
|
||||||
|
onClick={() => setActiveNoteId(note.id)}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
|
||||||
|
{note.title}
|
||||||
|
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<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">
|
||||||
|
<img
|
||||||
|
src={note.imageUrl}
|
||||||
|
alt={note.title}
|
||||||
|
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"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
|
||||||
|
{note.content}
|
||||||
|
</p>
|
||||||
|
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
|
||||||
|
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
|
||||||
|
© 2024 Architectural Grid. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="focused-note"
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 1.02 }}
|
||||||
|
className="h-full flex flex-col overflow-y-auto bg-white"
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 backdrop-blur-sm z-40 border-b border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveNoteId(null)}
|
||||||
|
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
<span className="text-sm font-medium">Back to collection</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
|
||||||
|
<Share2 size={18} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
|
||||||
|
<MoreVertical size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
|
||||||
|
<span>{activeCarnet?.name}</span>
|
||||||
|
<ChevronRight size={10} />
|
||||||
|
<span>{activeNote?.date}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
|
||||||
|
{activeNote?.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-[16/9] w-full bg-slate-100 rounded-xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src={activeNote?.imageUrl}
|
||||||
|
alt={activeNote?.title}
|
||||||
|
className="w-full h-full object-cover grayscale contrast-110"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto space-y-8 pb-32">
|
||||||
|
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
|
||||||
|
{activeNote?.content.split('.')[0]}.
|
||||||
|
</p>
|
||||||
|
<div className="h-px bg-border w-32" />
|
||||||
|
<p className="text-lg leading-relaxed text-ink/80 font-light space-y-4 text-justify">
|
||||||
|
{activeNote?.content}
|
||||||
|
<br /><br />
|
||||||
|
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
|
||||||
|
<br /><br />
|
||||||
|
The integration of sustainable materials directly into the primary grid allows for a cohesive aesthetic that doesn't compromise on environmental performance. As we transition toward more modular designs, the grid becomes not just a tool for measurement, but a language for expression.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
architectural-grid1/src/index.css
Normal file
34
architectural-grid1/src/index.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
@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");
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-grid1/src/main.tsx
Normal file
10
architectural-grid1/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-grid1/tsconfig.json
Normal file
26
architectural-grid1/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-grid1/vite.config.ts
Normal file
24
architectural-grid1/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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -99,7 +99,7 @@ services:
|
|||||||
cpus: '0.25'
|
cpus: '0.25'
|
||||||
memory: 128M
|
memory: 128M
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --spider -q http://localhost:3001/ || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/health || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
# Configuration N8N - Memento MCP Server
|
# N8N Configuration — Memento MCP Server
|
||||||
|
|
||||||
## Configuration MCP Client dans N8N
|
## MCP Client Setup in N8N
|
||||||
|
|
||||||
Le serveur MCP utilise le transport **Streamable HTTP** (remplace l'ancien SSE).
|
The MCP server uses **Streamable HTTP** transport (replaces the legacy SSE transport).
|
||||||
|
|
||||||
### 1. Generer une cle API
|
### 1. Generate an API Key
|
||||||
|
|
||||||
Dans Memento : **Parametres > MCP > Generer une cle**
|
In Memento: **Settings > MCP > Generate a key**
|
||||||
|
|
||||||
La cle a le format `mcp_sk_...` et est associee a votre compte utilisateur. Seules vos notes seront accessibles.
|
The key has the format `mcp_sk_...` and is scoped to your user account. Only your notes will be accessible.
|
||||||
|
|
||||||
### 2. Configurer le noeud MCP Client dans N8N
|
### 2. Configure the MCP Client Node in N8N
|
||||||
|
|
||||||
1. Ajouter un noeud **MCP Client** dans votre workflow
|
1. Add an **MCP Client** node to your workflow
|
||||||
2. **Server Transport**: `Streamable HTTP`
|
2. **Server Transport**: `Streamable HTTP`
|
||||||
3. **MCP Endpoint URL** : `http://memento-mcp:3001/mcp` (Docker) ou `http://VOTRE_IP:3001/mcp`
|
3. **MCP Endpoint URL**: `http://memento-mcp:3001/mcp` (Docker) or `http://YOUR_IP:3001/mcp`
|
||||||
4. **Authentication**: Header Auth
|
4. **Authentication**: Header Auth
|
||||||
- Header Name: `x-api-key`
|
- Header Name: `x-api-key`
|
||||||
- Header Value : votre cle API (`mcp_sk_...`)
|
- Header Value: your API key (`mcp_sk_...`)
|
||||||
|
|
||||||
### Alternative: curl
|
### Alternative: curl
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Health check
|
# Health check
|
||||||
curl -H "x-api-key: mcp_sk_votrecle" http://localhost:3001/
|
curl -H "x-api-key: mcp_sk_yourkey" http://localhost:3001/
|
||||||
|
|
||||||
# Lister les outils
|
# List available tools
|
||||||
curl -X POST http://localhost:3001/mcp \
|
curl -X POST http://localhost:3001/mcp \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "x-api-key: mcp_sk_votrecle" \
|
-H "x-api-key: mcp_sk_yourkey" \
|
||||||
-d '{
|
-d '{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -36,10 +36,10 @@ curl -X POST http://localhost:3001/mcp \
|
|||||||
"params": {}
|
"params": {}
|
||||||
}'
|
}'
|
||||||
|
|
||||||
# Creer une note
|
# Create a note
|
||||||
curl -X POST http://localhost:3001/mcp \
|
curl -X POST http://localhost:3001/mcp \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "x-api-key: mcp_sk_votrecle" \
|
-H "x-api-key: mcp_sk_yourkey" \
|
||||||
-d '{
|
-d '{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 2,
|
"id": 2,
|
||||||
@@ -47,72 +47,81 @@ curl -X POST http://localhost:3001/mcp \
|
|||||||
"params": {
|
"params": {
|
||||||
"name": "create_note",
|
"name": "create_note",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"title": "Ma note",
|
"title": "My note",
|
||||||
"content": "Contenu de la note"
|
"content": "Note content here"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Outils disponibles (22)
|
## Available Tools (22)
|
||||||
|
|
||||||
### Notes (11)
|
### Notes (11)
|
||||||
|
|
||||||
| Outil | Description |
|
| Tool | Description |
|
||||||
|-------|-------------|
|
|------|-------------|
|
||||||
| `create_note` | Creer une note |
|
| `create_note` | Create a note |
|
||||||
| `get_notes` | Lister les notes |
|
| `get_notes` | List notes |
|
||||||
| `get_note` | Recuperer une note par ID |
|
| `get_note` | Get a note by ID |
|
||||||
| `update_note` | Modifier une note |
|
| `update_note` | Update a note |
|
||||||
| `delete_note` | Supprimer une note |
|
| `delete_note` | Delete a note |
|
||||||
| `search_notes` | Rechercher par mot-cle |
|
| `search_notes` | Search by keyword |
|
||||||
| `move_note` | Deplacer vers un notebook |
|
| `move_note` | Move to a notebook |
|
||||||
| `toggle_pin` | Epingler/Depingler |
|
| `toggle_pin` | Pin / unpin |
|
||||||
| `toggle_archive` | Archiver/Desarchiver |
|
| `toggle_archive` | Archive / unarchive |
|
||||||
| `export_notes` | Exporter en JSON |
|
| `export_notes` | Export as JSON |
|
||||||
| `import_notes` | Importer depuis JSON |
|
| `import_notes` | Import from JSON |
|
||||||
|
|
||||||
|
#### Note Types
|
||||||
|
|
||||||
|
| `type` value | Description |
|
||||||
|
|--------------|-------------|
|
||||||
|
| `richtext` | Rich text editor — **default** |
|
||||||
|
| `markdown` | Markdown rendered (use instead of `isMarkdown`) |
|
||||||
|
| `text` | Plain text |
|
||||||
|
| `checklist` | Interactive checklist with `checkItems` |
|
||||||
|
|
||||||
### Notebooks (6)
|
### Notebooks (6)
|
||||||
|
|
||||||
| Outil | Description |
|
| Tool | Description |
|
||||||
|-------|-------------|
|
|------|-------------|
|
||||||
| `create_notebook` | Creer un notebook |
|
| `create_notebook` | Create a notebook |
|
||||||
| `get_notebooks` | Lister les notebooks |
|
| `get_notebooks` | List all notebooks |
|
||||||
| `get_notebook` | Details d'un notebook |
|
| `get_notebook` | Get notebook details |
|
||||||
| `update_notebook` | Modifier un notebook |
|
| `update_notebook` | Update a notebook |
|
||||||
| `delete_notebook` | Supprimer un notebook |
|
| `delete_notebook` | Delete a notebook |
|
||||||
| `reorder_notebooks` | Reordonner |
|
| `reorder_notebooks` | Reorder notebooks |
|
||||||
|
|
||||||
### Labels (4)
|
### Labels (4)
|
||||||
|
|
||||||
| Outil | Description |
|
| Tool | Description |
|
||||||
|-------|-------------|
|
|------|-------------|
|
||||||
| `create_label` | Creer un label |
|
| `create_label` | Create a label |
|
||||||
| `get_labels` | Lister les labels |
|
| `get_labels` | List labels |
|
||||||
| `update_label` | Modifier un label |
|
| `update_label` | Update a label |
|
||||||
| `delete_label` | Supprimer un label |
|
| `delete_label` | Delete a label |
|
||||||
|
|
||||||
### Rappels (1)
|
### Reminders (1)
|
||||||
|
|
||||||
| Outil | Description |
|
| Tool | Description |
|
||||||
|-------|-------------|
|
|------|-------------|
|
||||||
| `get_due_reminders` | Recuperer les rappels dus |
|
| `get_due_reminders` | Get due reminders |
|
||||||
|
|
||||||
## Endpoints HTTP
|
## HTTP Endpoints
|
||||||
|
|
||||||
| Endpoint | Methode | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|---------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/` | GET | Health check |
|
| `/` | GET | Health check |
|
||||||
| `/mcp` | GET/POST | Endpoint MCP principal |
|
| `/mcp` | GET/POST | Main MCP endpoint |
|
||||||
| `/sse` | GET/POST | Legacy (redirige vers `/mcp`) |
|
| `/sse` | GET/POST | Legacy (redirects to `/mcp`) |
|
||||||
| `/sessions` | GET | Sessions actives |
|
| `/sessions` | GET | Active sessions |
|
||||||
|
|
||||||
## Securite
|
## Security
|
||||||
|
|
||||||
- **Authentification obligatoire** en production (`MCP_REQUIRE_AUTH=true` dans Docker)
|
- **Authentication required** in production (`MCP_REQUIRE_AUTH=true` in Docker)
|
||||||
- Les cles API sont gerees depuis **Parametres > MCP** dans Memento
|
- API keys are managed from **Settings > MCP** in Memento
|
||||||
- Chaque cle est scopee a un utilisateur : seules ses notes sont accessibles
|
- Each key is scoped to a single user: only their notes are accessible
|
||||||
- Les cles sont hashees en base (SHA256), seul le raw key est montre a la creation
|
- Keys are hashed in the database (SHA256); the raw key is only shown once at creation
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
|
|||||||
571
mcp-server/N8N-EXAMPLES.md
Normal file
571
mcp-server/N8N-EXAMPLES.md
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
# N8N Examples — Memento MCP Server
|
||||||
|
|
||||||
|
Practical, copy-paste-ready N8N workflow snippets for every Memento MCP tool.
|
||||||
|
All examples assume an MCP Client node configured with Streamable HTTP and `x-api-key` authentication.
|
||||||
|
|
||||||
|
## Note Types
|
||||||
|
|
||||||
|
| `type` value | Description |
|
||||||
|
|--------------|-------------|
|
||||||
|
| `richtext` | Rich text editor — **default** |
|
||||||
|
| `markdown` | Markdown rendered (preferred over deprecated `isMarkdown`) |
|
||||||
|
| `text` | Plain text |
|
||||||
|
| `checklist` | Interactive checklist with `checkItems` |
|
||||||
|
|
||||||
|
> **Note**: `isMarkdown: true` is deprecated. Use `"type": "markdown"` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Create a simple text note
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_note",
|
||||||
|
"arguments": {
|
||||||
|
"title": "Quick idea",
|
||||||
|
"content": "Explore serverless architecture for the new API gateway.",
|
||||||
|
"color": "yellow",
|
||||||
|
"labels": ["idea", "architecture"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a pinned markdown note
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_note",
|
||||||
|
"arguments": {
|
||||||
|
"title": "Project README",
|
||||||
|
"content": "# My Project\n\nThis is the main documentation.\n\n## Getting Started\n\n```bash\nnpm install\nnpm run dev\n```",
|
||||||
|
"isMarkdown": true,
|
||||||
|
"isPinned": true,
|
||||||
|
"color": "blue",
|
||||||
|
"size": "large"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a checklist note
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_note",
|
||||||
|
"arguments": {
|
||||||
|
"title": "Weekly tasks",
|
||||||
|
"content": "Tasks for this week",
|
||||||
|
"type": "checklist",
|
||||||
|
"checkItems": [
|
||||||
|
{ "id": "1", "text": "Review pull requests", "checked": false },
|
||||||
|
{ "id": "2", "text": "Update deployment docs", "checked": false },
|
||||||
|
{ "id": "3", "text": "Team standup notes", "checked": true }
|
||||||
|
],
|
||||||
|
"color": "teal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a note with a reminder
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_note",
|
||||||
|
"arguments": {
|
||||||
|
"title": "Dentist appointment",
|
||||||
|
"content": "Dr. Martin — 10:30 AM. Bring insurance card.",
|
||||||
|
"reminder": "2025-06-15T10:00:00.000Z",
|
||||||
|
"reminderRecurrence": "yearly",
|
||||||
|
"color": "red",
|
||||||
|
"labels": ["health", "personal"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a note in a specific notebook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_note",
|
||||||
|
"arguments": {
|
||||||
|
"title": "Sprint planning notes",
|
||||||
|
"content": "**Goal**: Ship user authentication by Friday.\n\n- Design review at 2PM\n- Backend API ready by Wednesday",
|
||||||
|
"isMarkdown": true,
|
||||||
|
"notebookId": "{{ $json.notebookId }}",
|
||||||
|
"labels": ["sprint", "planning"],
|
||||||
|
"color": "purple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a note with external links
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_note",
|
||||||
|
"arguments": {
|
||||||
|
"title": "Research resources",
|
||||||
|
"content": "Curated links for the ML paper review.",
|
||||||
|
"links": [
|
||||||
|
"https://arxiv.org/abs/2304.15004",
|
||||||
|
"https://huggingface.co/papers",
|
||||||
|
"https://paperswithcode.com"
|
||||||
|
],
|
||||||
|
"labels": ["research", "ml"],
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get all notes (lightweight)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_notes",
|
||||||
|
"arguments": {
|
||||||
|
"limit": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get notes from a specific notebook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_notes",
|
||||||
|
"arguments": {
|
||||||
|
"notebookId": "{{ $json.notebookId }}",
|
||||||
|
"limit": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get unfiled notes (Inbox)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_notes",
|
||||||
|
"arguments": {
|
||||||
|
"notebookId": "inbox",
|
||||||
|
"limit": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get all notes including archived
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_notes",
|
||||||
|
"arguments": {
|
||||||
|
"includeArchived": true,
|
||||||
|
"limit": 200,
|
||||||
|
"fullDetails": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get a single note by ID
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_note",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search notes by keyword
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "search_notes",
|
||||||
|
"arguments": {
|
||||||
|
"query": "deployment kubernetes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search in a specific notebook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "search_notes",
|
||||||
|
"arguments": {
|
||||||
|
"query": "{{ $json.searchTerm }}",
|
||||||
|
"notebookId": "{{ $json.notebookId }}",
|
||||||
|
"includeArchived": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a note's content
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "update_note",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}",
|
||||||
|
"content": "{{ $json.newContent }}",
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mark a checklist item as done
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "update_note",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}",
|
||||||
|
"checkItems": [
|
||||||
|
{ "id": "1", "text": "Review pull requests", "checked": true },
|
||||||
|
{ "id": "2", "text": "Update deployment docs", "checked": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add labels to an existing note
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "update_note",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}",
|
||||||
|
"labels": ["ai-generated", "reviewed", "{{ $json.category }}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mark a reminder as done
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "update_note",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}",
|
||||||
|
"isReminderDone": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move a note to a notebook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "move_note",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}",
|
||||||
|
"notebookId": "{{ $json.targetNotebookId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move a note to Inbox (no notebook)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "move_note",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}",
|
||||||
|
"notebookId": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pin a note
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "toggle_pin",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archive a note
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "toggle_archive",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a note permanently
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "delete_note",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.noteId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export all notes as JSON backup
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "export_notes",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import notes from a JSON backup
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "import_notes",
|
||||||
|
"arguments": {
|
||||||
|
"data": "{{ $json.exportPayload }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notebooks
|
||||||
|
|
||||||
|
### Create a notebook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_notebook",
|
||||||
|
"arguments": {
|
||||||
|
"name": "Work Projects",
|
||||||
|
"icon": "💼",
|
||||||
|
"color": "#3B82F6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a notebook for a team/topic
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_notebook",
|
||||||
|
"arguments": {
|
||||||
|
"name": "Machine Learning Research",
|
||||||
|
"icon": "🤖",
|
||||||
|
"color": "#8B5CF6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List all notebooks
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_notebooks",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get a notebook with its notes
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_notebook",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.notebookId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rename a notebook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "update_notebook",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.notebookId }}",
|
||||||
|
"name": "Q3 2025 Projects",
|
||||||
|
"color": "#10B981"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reorder notebooks
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "reorder_notebooks",
|
||||||
|
"arguments": {
|
||||||
|
"notebookIds": [
|
||||||
|
"{{ $json.ids[0] }}",
|
||||||
|
"{{ $json.ids[1] }}",
|
||||||
|
"{{ $json.ids[2] }}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a notebook (notes go to Inbox)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "delete_notebook",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.notebookId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
### Create a label inside a notebook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_label",
|
||||||
|
"arguments": {
|
||||||
|
"name": "urgent",
|
||||||
|
"color": "red",
|
||||||
|
"notebookId": "{{ $json.notebookId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create common project labels
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_label",
|
||||||
|
"arguments": {
|
||||||
|
"name": "in-progress",
|
||||||
|
"color": "orange",
|
||||||
|
"notebookId": "{{ $json.notebookId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_label",
|
||||||
|
"arguments": {
|
||||||
|
"name": "done",
|
||||||
|
"color": "green",
|
||||||
|
"notebookId": "{{ $json.notebookId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List labels in a notebook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_labels",
|
||||||
|
"arguments": {
|
||||||
|
"notebookId": "{{ $json.notebookId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List all labels (global)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_labels",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rename a label
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "update_label",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.labelId }}",
|
||||||
|
"name": "high-priority",
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a label
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "delete_label",
|
||||||
|
"arguments": {
|
||||||
|
"id": "{{ $json.labelId }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reminders
|
||||||
|
|
||||||
|
### Get all due reminders (for cron automation)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_due_reminders",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip**: Schedule this every 30 minutes with a cron trigger (`0 */30 * * * *`), then loop over results and send notifications via Slack, Telegram, or email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Workflow Patterns
|
||||||
|
|
||||||
|
### Pattern: Email → Note
|
||||||
|
1. **Email Trigger (IMAP)** — fires on new email
|
||||||
|
2. **MCP Client** → `create_note`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "{{ $json.subject }}",
|
||||||
|
"content": "**From**: {{ $json.from }}\n\n{{ $json.text }}",
|
||||||
|
"labels": ["email"],
|
||||||
|
"color": "blue",
|
||||||
|
"isMarkdown": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern: Reminder Notifications
|
||||||
|
1. **Schedule Trigger** — every 30 minutes
|
||||||
|
2. **MCP Client** → `get_due_reminders`
|
||||||
|
3. **IF** — results not empty
|
||||||
|
4. **Loop over items** → send Slack/Telegram message per reminder
|
||||||
|
5. **MCP Client** → `update_note` with `isReminderDone: true`
|
||||||
|
|
||||||
|
### Pattern: AI Summarization
|
||||||
|
1. **MCP Client** → `search_notes` with a topic query
|
||||||
|
2. **OpenAI / Anthropic node** — summarize the returned notes
|
||||||
|
3. **MCP Client** → `create_note` with the summary as content, labeled `ai-summary`
|
||||||
|
|
||||||
|
### Pattern: Daily Digest
|
||||||
|
1. **Schedule Trigger** — every day at 8 AM
|
||||||
|
2. **MCP Client** → `get_notes` with `limit: 20` (most recent)
|
||||||
|
3. **AI node** — generate a brief digest
|
||||||
|
4. **Email / Slack node** — send the digest
|
||||||
|
5. *(Optional)* **MCP Client** → `create_note` to archive the digest
|
||||||
|
|
||||||
|
### Pattern: Webhook → Structured Note
|
||||||
|
1. **Webhook Trigger** — receives JSON payload (e.g., from a form or CI/CD pipeline)
|
||||||
|
2. **Code node** — format the payload into markdown
|
||||||
|
3. **MCP Client** → `create_note`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Build #{{ $json.buildNumber }} — {{ $json.status }}",
|
||||||
|
"content": "{{ $node['Code'].json.markdown }}",
|
||||||
|
"isMarkdown": true,
|
||||||
|
"color": "{{ $json.status === 'success' ? 'green' : 'red' }}",
|
||||||
|
"labels": ["ci-cd", "{{ $json.repo }}"]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
# Workflows N8N pour Memento MCP
|
# N8N Workflows for Memento MCP
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Exemples de workflows N8N utilisant le serveur MCP de Memento via le transport **Streamable HTTP**.
|
Example N8N workflows using the Memento MCP server via **Streamable HTTP** transport.
|
||||||
|
|
||||||
**Prerequis** : une cle API generee depuis **Parametres > MCP** dans Memento.
|
**Prerequisite**: an API key generated from **Settings > MCP** in Memento.
|
||||||
|
|
||||||
## Configuration du noeud MCP Client
|
## MCP Client Node Configuration
|
||||||
|
|
||||||
Dans chaque workflow, le noeud MCP Client doit etre configure ainsi :
|
In every workflow, the MCP Client node must be configured as follows:
|
||||||
|
|
||||||
- **Server Transport**: `Streamable HTTP`
|
- **Server Transport**: `Streamable HTTP`
|
||||||
- **MCP Endpoint URL** : `http://memento-mcp:3001/mcp` (Docker) ou `http://VOTRE_IP:3001/mcp`
|
- **MCP Endpoint URL**: `http://memento-mcp:3001/mcp` (Docker) or `http://YOUR_IP:3001/mcp`
|
||||||
- **Authentication** : Header Auth avec `x-api-key` = votre cle API
|
- **Authentication**: Header Auth with `x-api-key` = your API key
|
||||||
|
|
||||||
## Workflows disponibles
|
## Available Workflows
|
||||||
|
|
||||||
### 1. Create Note (`n8n-workflow-create-note.json`)
|
### 1. Create Note (`n8n-workflow-create-note.json`)
|
||||||
|
|
||||||
Cree des notes dans Memento avec classification par IA.
|
Creates notes in Memento with AI-based classification.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -34,7 +34,7 @@ Cree des notes dans Memento avec classification par IA.
|
|||||||
|
|
||||||
### 2. Search & Summary (`n8n-workflow-search-summary.json`)
|
### 2. Search & Summary (`n8n-workflow-search-summary.json`)
|
||||||
|
|
||||||
Recherche des notes et genere un resume.
|
Searches notes and generates a summary.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -47,7 +47,7 @@ Recherche des notes et genere un resume.
|
|||||||
|
|
||||||
### 3. Notebook Manager (`n8n-workflow-notebook-management.json`)
|
### 3. Notebook Manager (`n8n-workflow-notebook-management.json`)
|
||||||
|
|
||||||
CRUD complet des notebooks.
|
Full CRUD for notebooks.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -62,14 +62,14 @@ CRUD complet des notebooks.
|
|||||||
|
|
||||||
### 4. Reminder Notifications (`n8n-workflow-reminder-notifications.json`)
|
### 4. Reminder Notifications (`n8n-workflow-reminder-notifications.json`)
|
||||||
|
|
||||||
Automatisation des rappels avec notifications.
|
Automates reminders with notifications.
|
||||||
|
|
||||||
- Declencheur : Schedule (cron: `0 */30 * * * *`)
|
- Trigger: Schedule (cron: `0 */30 * * * *`)
|
||||||
- Appelle `get_due_reminders` et envoie les notifications
|
- Calls `get_due_reminders` and sends notifications
|
||||||
|
|
||||||
### 5. Label Manager (`n8n-workflow-label-management.json`)
|
### 5. Label Manager (`n8n-workflow-label-management.json`)
|
||||||
|
|
||||||
Gestion des labels.
|
Label management.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -84,20 +84,20 @@ Gestion des labels.
|
|||||||
|
|
||||||
### 6. Email to Note (`n8n-workflow-email-integration.json`)
|
### 6. Email to Note (`n8n-workflow-email-integration.json`)
|
||||||
|
|
||||||
Conversion automatique d'emails en notes.
|
Automatically converts emails into notes.
|
||||||
|
|
||||||
- Declencheur : Email Trigger (IMAP)
|
- Trigger: Email Trigger (IMAP)
|
||||||
- Cree une note avec le contenu de l'email
|
- Creates a note from the email content
|
||||||
|
|
||||||
## Importation
|
## Import Instructions
|
||||||
|
|
||||||
1. Ouvrir N8N
|
1. Open N8N
|
||||||
2. **Import from File** -> selectionner le fichier JSON
|
2. **Import from File** → select the JSON file
|
||||||
3. Configurer le noeud MCP Client (URL + cle API)
|
3. Configure the MCP Client node (URL + API key)
|
||||||
4. Activer le workflow
|
4. Activate the workflow
|
||||||
|
|
||||||
## Securite
|
## Security
|
||||||
|
|
||||||
- Toujours utiliser une cle API dediee par workflow
|
- Always use a dedicated API key per workflow
|
||||||
- Ne jamais exposer la cle dans les logs
|
- Never expose the key in logs
|
||||||
- Restreindre l'acces reseau au port 3001 si possible
|
- Restrict network access to port 3001 when possible
|
||||||
|
|||||||
@@ -96,33 +96,26 @@ export async function validateApiKey(prisma, rawKey) {
|
|||||||
|
|
||||||
const keyHash = hashKey(rawKey);
|
const keyHash = hashKey(rawKey);
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = getCachedKey(keyHash);
|
const cached = getCachedKey(keyHash);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimized: Use startsWith to leverage index, then filter by hash
|
const shortId = rawKey.substring(7, 15);
|
||||||
// This is much faster than loading all keys
|
const configKey = `${KEY_PREFIX}${shortId}`;
|
||||||
const shortIdFromKey = rawKey.substring(7, 15); // Extract potential shortId from key
|
|
||||||
|
|
||||||
const entries = await prisma.systemConfig.findMany({
|
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } });
|
||||||
where: {
|
if (!entry) return null;
|
||||||
key: { startsWith: KEY_PREFIX },
|
|
||||||
},
|
|
||||||
take: 100, // Limit to prevent loading too many keys
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
try {
|
try {
|
||||||
const info = JSON.parse(entry.value);
|
const info = JSON.parse(entry.value);
|
||||||
if (info.keyHash === keyHash && info.active) {
|
if (info.keyHash !== keyHash || !info.active) return null;
|
||||||
// Update lastUsedAt (fire and forget - don't wait)
|
|
||||||
info.lastUsedAt = new Date().toISOString();
|
info.lastUsedAt = new Date().toISOString();
|
||||||
prisma.systemConfig.update({
|
prisma.systemConfig.update({
|
||||||
where: { key: entry.key },
|
where: { key: configKey },
|
||||||
data: { value: JSON.stringify(info) },
|
data: { value: JSON.stringify(info) },
|
||||||
}).catch(() => {}); // Ignore errors
|
}).catch(() => {});
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
apiKeyId: info.shortId,
|
apiKeyId: info.shortId,
|
||||||
@@ -131,18 +124,12 @@ export async function validateApiKey(prisma, rawKey) {
|
|||||||
userName: info.userName,
|
userName: info.userName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
setCachedKey(keyHash, result);
|
setCachedKey(keyHash, result);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid JSON, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all API keys (without revealing hashes).
|
* List all API keys (without revealing hashes).
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Memento MCP Server - Streamable HTTP Transport (Optimized)
|
* Memento MCP Server - Streamable HTTP Transport (Fast)
|
||||||
*
|
*
|
||||||
* Performance improvements:
|
|
||||||
* - Prisma connection pooling
|
* - Prisma connection pooling
|
||||||
* - Request timeout handling
|
* - Compact JSON output
|
||||||
* - Response compression
|
* - Bounded session cache
|
||||||
* - Connection keep-alive
|
* - Proper keep-alive & timeouts
|
||||||
* - Request batching support
|
* - O(1) API key validation
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment:
|
||||||
* PORT - Server port (default: 3001)
|
* PORT Server port (default: 3001)
|
||||||
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
|
* DATABASE_URL Prisma database URL
|
||||||
* USER_ID - Optional user ID to filter data
|
* USER_ID Optional user ID filter
|
||||||
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
|
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
|
||||||
* MCP_REQUIRE_AUTH - Set to 'true' to require x-api-key or x-user-id header
|
* MCP_REQUIRE_AUTH Set 'true' to require authentication
|
||||||
* MCP_API_KEY - Static API key for authentication (when MCP_REQUIRE_AUTH=true)
|
* MCP_API_KEY Static fallback API key
|
||||||
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
|
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
|
||||||
* MCP_REQUEST_TIMEOUT - Request timeout in ms (default: 30000)
|
* MCP_REQUEST_TIMEOUT Timeout in ms (default: 30000)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
@@ -32,13 +29,12 @@ import { registerTools } from './tools.js';
|
|||||||
import { validateApiKey, resolveUser } from './auth.js';
|
import { validateApiKey, resolveUser } from './auth.js';
|
||||||
import { requestContext } from './request-context.js';
|
import { requestContext } from './request-context.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
||||||
const REQUEST_TIMEOUT = parseInt(process.env.MCP_REQUEST_TIMEOUT, 10) || 30000;
|
const REQUEST_TIMEOUT = parseInt(process.env.MCP_REQUEST_TIMEOUT, 10) || 30000;
|
||||||
|
const MAX_SESSIONS = 500;
|
||||||
|
const SESSION_TTL = 3600000;
|
||||||
|
|
||||||
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
||||||
|
|
||||||
@@ -48,51 +44,61 @@ function log(level, ...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
|
||||||
|
|
||||||
// Database - requires DATABASE_URL environment variable
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error('ERROR: DATABASE_URL environment variable is required');
|
console.error('ERROR: DATABASE_URL is required');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Prisma client with connection pooling
|
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
datasources: {
|
datasources: { db: { url: databaseUrl } },
|
||||||
db: { url: databaseUrl },
|
...(isPostgres ? { datasources: { db: { url: `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` } } } : {}),
|
||||||
},
|
|
||||||
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
// ── Auth Middleware ──────────────────────────────────────────────────────────
|
// ── Bounded Session Cache ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const userSessions = {};
|
const sessions = new Map();
|
||||||
const SESSION_TIMEOUT = 3600000; // 1 hour
|
|
||||||
|
|
||||||
// Cleanup old sessions periodically
|
function cleanupSessions() {
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let cleaned = 0;
|
let cleaned = 0;
|
||||||
for (const [key, session] of Object.entries(userSessions)) {
|
for (const [key, s] of sessions) {
|
||||||
if (now - new Date(session.lastSeen).getTime() > SESSION_TIMEOUT) {
|
if (now - s._lastSeen > SESSION_TTL) {
|
||||||
delete userSessions[key];
|
sessions.delete(key);
|
||||||
cleaned++;
|
cleaned++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cleaned > 0) {
|
if (cleaned > 0) log('debug', `Cleaned ${cleaned} sessions`);
|
||||||
log('debug', `Cleaned up ${cleaned} expired sessions`);
|
|
||||||
}
|
}
|
||||||
}, 600000); // Every 10 minutes
|
|
||||||
|
function pruneIfFull() {
|
||||||
|
if (sessions.size < MAX_SESSIONS) return;
|
||||||
|
const entries = [...sessions.entries()].sort((a, b) => a[1]._lastSeen - b[1]._lastSeen);
|
||||||
|
for (let i = 0; i < Math.floor(MAX_SESSIONS / 4); i++) {
|
||||||
|
sessions.delete(entries[i][0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(cleanupSessions, 600000);
|
||||||
|
|
||||||
|
// ── Express ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
// ── Health (before auth middleware — used by Docker healthcheck) ────────────
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ ok: true, uptime: process.uptime() }));
|
||||||
|
|
||||||
|
// ── Auth Middleware ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
// Dev mode: no auth required
|
|
||||||
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
|
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
|
||||||
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
|
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
|
||||||
return next();
|
return next();
|
||||||
@@ -102,189 +108,128 @@ app.use(async (req, res, next) => {
|
|||||||
const headerUserId = req.headers['x-user-id'];
|
const headerUserId = req.headers['x-user-id'];
|
||||||
|
|
||||||
if (!apiKey && !headerUserId) {
|
if (!apiKey && !headerUserId) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({ error: 'Authentication required', message: 'Provide x-api-key or x-user-id header' });
|
||||||
error: 'Authentication required',
|
|
||||||
message: 'Provide x-api-key header (recommended) or x-user-id header',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Method 1: API Key (recommended) ──────────────────────────────
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const keyUser = await validateApiKey(prisma, apiKey);
|
const keyUser = await validateApiKey(prisma, apiKey);
|
||||||
if (keyUser) {
|
if (keyUser) {
|
||||||
const sessionKey = `key:${keyUser.apiKeyId}`;
|
req.userSession = getOrCreateSession(`key:${keyUser.apiKeyId}`, {
|
||||||
if (userSessions[sessionKey]) {
|
|
||||||
req.userSession = userSessions[sessionKey];
|
|
||||||
req.userSession.lastSeen = new Date().toISOString();
|
|
||||||
} else {
|
|
||||||
req.userSession = {
|
|
||||||
id: randomUUID(),
|
|
||||||
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
|
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
|
||||||
userId: keyUser.userId,
|
userId: keyUser.userId,
|
||||||
userName: keyUser.userName,
|
userName: keyUser.userName,
|
||||||
apiKeyId: keyUser.apiKeyId,
|
apiKeyId: keyUser.apiKeyId,
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'api-key',
|
authMethod: 'api-key',
|
||||||
};
|
});
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: static env var key
|
|
||||||
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
|
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
|
||||||
const sessionKey = `static:${apiKey.substring(0, 8)}`;
|
req.userSession = getOrCreateSession(`static:${apiKey.substring(0, 8)}`, {
|
||||||
if (userSessions[sessionKey]) {
|
|
||||||
req.userSession = userSessions[sessionKey];
|
|
||||||
req.userSession.lastSeen = new Date().toISOString();
|
|
||||||
} else {
|
|
||||||
req.userSession = {
|
|
||||||
id: randomUUID(),
|
|
||||||
name: 'Static API Key User',
|
name: 'Static API Key User',
|
||||||
userId: process.env.USER_ID || null,
|
userId: process.env.USER_ID || null,
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'static-key',
|
authMethod: 'static-key',
|
||||||
};
|
});
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'Invalid API key' });
|
return res.status(401).json({ error: 'Invalid API key' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Method 2: User ID header (validate against DB) ──────────────
|
|
||||||
if (headerUserId) {
|
if (headerUserId) {
|
||||||
const user = await resolveUser(prisma, headerUserId);
|
const user = await resolveUser(prisma, headerUserId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'User not found', message: `No user matching: ${headerUserId}` });
|
return res.status(401).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
req.userSession = getOrCreateSession(`user:${user.id}`, {
|
||||||
const sessionKey = `user:${user.id}`;
|
|
||||||
if (userSessions[sessionKey]) {
|
|
||||||
req.userSession = userSessions[sessionKey];
|
|
||||||
req.userSession.lastSeen = new Date().toISOString();
|
|
||||||
} else {
|
|
||||||
req.userSession = {
|
|
||||||
id: randomUUID(),
|
|
||||||
name: user.name,
|
name: user.name,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userName: user.name,
|
userName: user.name,
|
||||||
userEmail: user.email,
|
userEmail: user.email,
|
||||||
userRole: user.role,
|
userRole: user.role,
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'user-id',
|
authMethod: 'user-id',
|
||||||
};
|
});
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'Authentication failed' });
|
return res.status(401).json({ error: 'Authentication failed' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Request Logging ─────────────────────────────────────────────────────────
|
function getOrCreateSession(key, base) {
|
||||||
|
const existing = sessions.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing._lastSeen = Date.now();
|
||||||
|
existing.requestCount = (existing.requestCount || 0) + 1;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
pruneIfFull();
|
||||||
|
const s = {
|
||||||
|
id: randomUUID(),
|
||||||
|
...base,
|
||||||
|
connectedAt: new Date().toISOString(),
|
||||||
|
requestCount: 1,
|
||||||
|
isAuth: true,
|
||||||
|
_lastSeen: Date.now(),
|
||||||
|
};
|
||||||
|
sessions.set(key, s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logging ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
if (req.userSession) {
|
|
||||||
req.userSession.requestCount = (req.userSession.requestCount || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - start;
|
const ms = Date.now() - start;
|
||||||
const sessionId = req.userSession?.id?.substring(0, 8) || 'anon';
|
const sid = req.userSession?.id?.substring(0, 8) || 'anon';
|
||||||
log('debug', `[${sessionId}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
|
log('debug', `[${sid}] ${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
|
||||||
});
|
});
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Request Timeout Middleware ──────────────────────────────────────────────
|
// ── Timeout ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
req.setTimeout(REQUEST_TIMEOUT);
|
||||||
res.setTimeout(REQUEST_TIMEOUT, () => {
|
res.setTimeout(REQUEST_TIMEOUT, () => {
|
||||||
log('warn', `Request timeout: ${req.method} ${req.path}`);
|
if (!res.headersSent) {
|
||||||
res.status(504).json({ error: 'Gateway Timeout', message: 'Request took too long' });
|
res.status(504).json({ error: 'Gateway Timeout' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── MCP Server Setup ────────────────────────────────────────────────────────
|
// ── MCP Server ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{ name: 'memento-mcp-server', version: '3.2.0' },
|
||||||
name: 'memento-mcp-server',
|
{ capabilities: { tools: {} } },
|
||||||
version: '3.1.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
registerTools(server, prisma);
|
registerTools(server, prisma);
|
||||||
|
|
||||||
// ── HTTP Endpoints ──────────────────────────────────────────────────────────
|
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const transports = {};
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
name: 'Memento MCP Server',
|
name: 'Memento MCP Server',
|
||||||
version: '3.1.0',
|
version: '3.2.0',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
endpoints: { mcp: '/mcp', health: '/', sessions: '/sessions' },
|
endpoints: { mcp: '/mcp', health: '/health', sessions: '/sessions' },
|
||||||
auth: {
|
auth: { enabled: process.env.MCP_REQUIRE_AUTH === 'true' },
|
||||||
enabled: process.env.MCP_REQUIRE_AUTH === 'true',
|
tools: 22,
|
||||||
method: 'x-api-key or x-user-id header',
|
|
||||||
},
|
|
||||||
tools: {
|
|
||||||
notes: 11,
|
|
||||||
notebooks: 6,
|
|
||||||
labels: 4,
|
|
||||||
reminders: 1,
|
|
||||||
total: 22,
|
|
||||||
},
|
|
||||||
performance: {
|
|
||||||
optimizations: [
|
|
||||||
'Connection pooling',
|
|
||||||
'Batch operations',
|
|
||||||
'API key caching',
|
|
||||||
'Request timeout handling',
|
|
||||||
'Parallel query execution',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session status
|
|
||||||
app.get('/sessions', (req, res) => {
|
app.get('/sessions', (req, res) => {
|
||||||
const sessions = Object.values(userSessions).map((s) => ({
|
const list = [...sessions.values()].map(s => ({
|
||||||
id: s.id,
|
id: s.id, name: s.name, connectedAt: s.connectedAt,
|
||||||
name: s.name,
|
requestCount: s.requestCount || 0, authMethod: s.authMethod,
|
||||||
connectedAt: s.connectedAt,
|
|
||||||
lastSeen: s.lastSeen,
|
|
||||||
requestCount: s.requestCount || 0,
|
|
||||||
}));
|
}));
|
||||||
res.json({
|
res.json({ activeUsers: list.length, sessions: list, uptime: process.uptime() });
|
||||||
activeUsers: sessions.length,
|
|
||||||
sessions,
|
|
||||||
uptime: process.uptime(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// MCP endpoint - Streamable HTTP
|
// MCP endpoint — Streamable HTTP
|
||||||
app.all('/mcp', async (req, res) => {
|
app.all('/mcp', async (req, res) => {
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = req.headers['mcp-session-id'];
|
||||||
let transport;
|
let transport;
|
||||||
@@ -295,15 +240,15 @@ app.all('/mcp', async (req, res) => {
|
|||||||
transport = new StreamableHTTPServerTransport({
|
transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => randomUUID(),
|
sessionIdGenerator: () => randomUUID(),
|
||||||
onsessioninitialized: (id) => {
|
onsessioninitialized: (id) => {
|
||||||
log('debug', `Session initialized: ${id}`);
|
log('debug', `Session init: ${id}`);
|
||||||
transports[id] = transport;
|
transports[id] = transport;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
const sid = transport.sessionId;
|
const sid = transport.sessionId;
|
||||||
if (sid && transports[sid]) {
|
if (sid) {
|
||||||
log('debug', `Session closed: ${sid}`);
|
log('debug', `Session close: ${sid}`);
|
||||||
delete transports[sid];
|
delete transports[sid];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -311,79 +256,45 @@ app.all('/mcp', async (req, res) => {
|
|||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass authenticated userId to tool handlers via AsyncLocalStorage
|
|
||||||
const ctx = { userId: req.userSession?.userId || null };
|
const ctx = { userId: req.userSession?.userId || null };
|
||||||
await requestContext.run(ctx, async () => {
|
await requestContext.run(ctx, async () => {
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legacy /sse redirect for backward compat
|
// Legacy /sse → /mcp redirect
|
||||||
app.all('/sse', async (req, res) => {
|
app.all('/sse', (req, res) => {
|
||||||
// Redirect to /mcp
|
res.redirect(307, '/mcp');
|
||||||
req.url = '/mcp';
|
|
||||||
return app._router.handle(req, res, () => {
|
|
||||||
res.status(404).json({ error: 'Not found' });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Start Server ────────────────────────────────────────────────────────────
|
const transports = {};
|
||||||
|
|
||||||
|
// ── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`
|
console.log(`
|
||||||
╔═══════════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════╗
|
||||||
║ Memento MCP Server v3.1.0 (Streamable HTTP) - Optimized ║
|
║ Memento MCP Server v3.2.0 (Streamable HTTP) ║
|
||||||
╚═══════════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
Server: http://localhost:${PORT}
|
Server: http://localhost:${PORT}
|
||||||
MCP: http://localhost:${PORT}/mcp
|
MCP: http://localhost:${PORT}/mcp
|
||||||
Health: http://localhost:${PORT}/
|
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev)'}
|
||||||
Sessions: http://localhost:${PORT}/sessions
|
|
||||||
|
|
||||||
Database: ${databaseUrl}
|
|
||||||
App URL: ${appBaseUrl}
|
|
||||||
User filter: per-request (from auth)
|
|
||||||
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev mode)'}
|
|
||||||
Timeout: ${REQUEST_TIMEOUT}ms
|
Timeout: ${REQUEST_TIMEOUT}ms
|
||||||
|
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||||
Performance Optimizations:
|
Tools: 22
|
||||||
✅ Connection pooling
|
|
||||||
✅ Batch operations
|
|
||||||
✅ API key caching (60s TTL)
|
|
||||||
✅ Parallel query execution
|
|
||||||
✅ Request timeout handling
|
|
||||||
✅ Session cleanup
|
|
||||||
|
|
||||||
Tools (22 total):
|
|
||||||
Notes (11):
|
|
||||||
create_note, get_notes, get_note, update_note, delete_note,
|
|
||||||
search_notes, move_note, toggle_pin, toggle_archive,
|
|
||||||
export_notes, import_notes
|
|
||||||
|
|
||||||
Notebooks (6):
|
|
||||||
create_notebook, get_notebooks, get_notebook, update_notebook,
|
|
||||||
delete_notebook, reorder_notebooks
|
|
||||||
|
|
||||||
Labels (4):
|
|
||||||
create_label, get_labels, update_label, delete_label
|
|
||||||
|
|
||||||
Reminders (1):
|
|
||||||
get_due_reminders
|
|
||||||
|
|
||||||
N8N config: SSE endpoint http://YOUR_IP:${PORT}/mcp
|
|
||||||
Headers: x-api-key or x-user-id
|
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// ── Shutdown ─────────────────────────────────────────────────────────────────
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
log('info', '\nShutting down MCP server...');
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
async function shutdown() {
|
||||||
log('info', '\nShutting down MCP server...');
|
log('info', 'Shutting down...');
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
|
||||||
|
process.on('unhandledRejection', (reason) => log('error', 'Unhandled rejection:', reason));
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Memento MCP Server - Stdio Transport (Optimized)
|
* Memento MCP Server - Stdio Transport
|
||||||
*
|
*
|
||||||
* Performance improvements:
|
* Environment:
|
||||||
* - Prisma connection pooling
|
* DATABASE_URL Prisma database URL
|
||||||
* - Prepared statements caching
|
* USER_ID Optional user ID filter
|
||||||
* - Optimized JSON serialization
|
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
|
||||||
* - Lazy user resolution
|
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
|
||||||
*
|
|
||||||
* Environment variables:
|
|
||||||
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
|
|
||||||
* USER_ID - Optional user ID to filter data
|
|
||||||
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
|
|
||||||
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { registerTools } from './tools.js';
|
import { registerTools } from './tools.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
||||||
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
||||||
@@ -36,44 +24,24 @@ function log(level, ...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database - requires DATABASE_URL environment variable
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error('ERROR: DATABASE_URL environment variable is required');
|
console.error('ERROR: DATABASE_URL is required');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Prisma client with connection pooling and prepared statements
|
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
datasources: {
|
datasources: {
|
||||||
db: { url: databaseUrl },
|
db: { url: isPostgres ? `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` : databaseUrl },
|
||||||
},
|
},
|
||||||
// SQLite optimizations
|
|
||||||
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connection health check
|
|
||||||
let isConnected = false;
|
|
||||||
async function checkConnection() {
|
|
||||||
try {
|
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
|
||||||
isConnected = true;
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
isConnected = false;
|
|
||||||
log('error', 'Database connection failed:', error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{ name: 'memento-mcp-server', version: '3.2.0' },
|
||||||
name: 'memento-mcp-server',
|
{ capabilities: { tools: {} } },
|
||||||
version: '3.1.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||||
@@ -84,21 +52,19 @@ registerTools(server, prisma, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Verify database connection on startup
|
try {
|
||||||
const connected = await checkConnection();
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
if (!connected) {
|
} catch (error) {
|
||||||
console.error('FATAL: Could not connect to database');
|
console.error('FATAL: Database connection failed:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|
||||||
log('info', `Memento MCP Server v3.1.0 (stdio) - Optimized`);
|
log('info', `Memento MCP Server v3.2.0 (stdio)`);
|
||||||
log('info', `Database: ${databaseUrl}`);
|
log('info', `Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}`);
|
||||||
log('info', `App URL: ${appBaseUrl}`);
|
log('info', `User filter: ${process.env.USER_ID || 'none'}`);
|
||||||
log('info', `User filter: ${process.env.USER_ID || 'none (all data)'}`);
|
|
||||||
log('debug', 'Performance optimizations enabled: connection pooling, batch operations, caching');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
@@ -106,24 +72,13 @@ main().catch((error) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
async function shutdown() {
|
||||||
process.on('SIGINT', async () => {
|
log('info', 'Shutting down...');
|
||||||
log('info', 'Shutting down gracefully...');
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on('SIGINT', shutdown);
|
||||||
log('info', 'Shutting down gracefully...');
|
process.on('SIGTERM', shutdown);
|
||||||
await prisma.$disconnect();
|
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
|
||||||
process.exit(0);
|
process.on('unhandledRejection', (reason) => log('error', 'Unhandled:', reason));
|
||||||
});
|
|
||||||
|
|
||||||
// Handle uncaught errors
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
log('error', 'Uncaught exception:', error.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
|
||||||
log('error', 'Unhandled rejection:', reason);
|
|
||||||
});
|
|
||||||
|
|||||||
137
mcp-server/n8n-workflow-mcp-daily-digest.json
Normal file
137
mcp-server/n8n-workflow-mcp-daily-digest.json
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"name": "Memento MCP — Daily Digest",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "cronExpression",
|
||||||
|
"expression": "0 0 8 * * 1-5"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "schedule-trigger",
|
||||||
|
"name": "Daily 8AM (Mon-Fri)",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [240, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://memento-mcp:3001/mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Content-Type", "value": "application/json" },
|
||||||
|
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"get_notes\",\n \"arguments\": {\n \"limit\": 30,\n \"includeArchived\": false\n }\n }\n}"
|
||||||
|
},
|
||||||
|
"id": "mcp-get-notes",
|
||||||
|
"name": "MCP — Get Recent Notes",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [480, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://memento-mcp:3001/mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Content-Type", "value": "application/json" },
|
||||||
|
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "{\n \"jsonrpc\": \"2.0\",\n \"id\": 2,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"get_due_reminders\",\n \"arguments\": {}\n }\n}"
|
||||||
|
},
|
||||||
|
"id": "mcp-get-reminders",
|
||||||
|
"name": "MCP — Get Due Reminders",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [480, 480]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const notesResponse = $('MCP — Get Recent Notes').item.json;\nconst remindersResponse = $('MCP — Get Due Reminders').item.json;\n\nconst notes = JSON.parse(notesResponse.result?.content?.[0]?.text || '[]');\nconst reminders = JSON.parse(remindersResponse.result?.content?.[0]?.text || '[]');\n\nconst today = new Date().toLocaleDateString('en-GB', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });\n\nconst pinnedNotes = notes.filter(n => n.isPinned).slice(0, 5);\nconst recentNotes = notes.filter(n => !n.isPinned).slice(0, 10);\n\nlet digest = `# 📋 Daily Digest — ${today}\\n\\n`;\n\nif (reminders.length > 0) {\n digest += `## ⏰ Reminders Due (${reminders.length})\\n\\n`;\n reminders.forEach(r => {\n const time = new Date(r.reminder).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });\n digest += `- **${r.title || 'Untitled'}** @ ${time}\\n`;\n });\n digest += '\\n';\n}\n\nif (pinnedNotes.length > 0) {\n digest += `## 📌 Pinned Notes (${pinnedNotes.length})\\n\\n`;\n pinnedNotes.forEach(n => {\n digest += `- **${n.title || 'Untitled'}**\\n`;\n });\n digest += '\\n';\n}\n\ndigest += `## 📝 Recent Notes (${recentNotes.length})\\n\\n`;\nrecentNotes.forEach(n => {\n const date = new Date(n.updatedAt).toLocaleDateString('en-GB');\n digest += `- ${n.title || 'Untitled'} *(${date})*\\n`;\n});\n\nreturn [{ json: { digest, noteCount: notes.length, reminderCount: reminders.length, today } }];"
|
||||||
|
},
|
||||||
|
"id": "build-digest",
|
||||||
|
"name": "Build Digest",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [720, 390]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://memento-mcp:3001/mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Content-Type", "value": "application/json" },
|
||||||
|
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": 3,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"create_note\",\n \"arguments\": {\n \"title\": \"Daily Digest — {{ $json.today }}\",\n \"content\": {{ JSON.stringify($json.digest) }},\n \"isMarkdown\": true,\n \"color\": \"teal\",\n \"isPinned\": false,\n \"labels\": [\"digest\", \"auto\"]\n }\n }\n}"
|
||||||
|
},
|
||||||
|
"id": "mcp-save-digest",
|
||||||
|
"name": "MCP — Save Digest as Note",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [960, 390]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"channel": "general",
|
||||||
|
"text": "📋 *Daily Digest ready!*\n\n📝 {{ $('Build Digest').item.json.noteCount }} notes · ⏰ {{ $('Build Digest').item.json.reminderCount }} reminders\n\nView in Memento: http://memento:3000",
|
||||||
|
"additionalFields": { "parse_mode": "Markdown" }
|
||||||
|
},
|
||||||
|
"id": "slack-digest",
|
||||||
|
"name": "Slack — Share Digest",
|
||||||
|
"type": "n8n-nodes-base.slack",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [1200, 390],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Daily 8AM (Mon-Fri)": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{ "node": "MCP — Get Recent Notes", "type": "main", "index": 0 },
|
||||||
|
{ "node": "MCP — Get Due Reminders", "type": "main", "index": 0 }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MCP — Get Recent Notes": {
|
||||||
|
"main": [[{ "node": "Build Digest", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"MCP — Get Due Reminders": {
|
||||||
|
"main": [[{ "node": "Build Digest", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Build Digest": {
|
||||||
|
"main": [[{ "node": "MCP — Save Digest as Note", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"MCP — Save Digest as Note": {
|
||||||
|
"main": [[{ "node": "Slack — Share Digest", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"settings": { "executionOrder": "v1" },
|
||||||
|
"staticData": null,
|
||||||
|
"tags": [{ "id": "memento-mcp", "name": "Memento MCP" }],
|
||||||
|
"triggerCount": 1,
|
||||||
|
"updatedAt": "2026-05-03T00:00:00.000Z",
|
||||||
|
"versionId": "1"
|
||||||
|
}
|
||||||
109
mcp-server/n8n-workflow-mcp-email-to-note.json
Normal file
109
mcp-server/n8n-workflow-mcp-email-to-note.json
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"name": "Memento MCP — Email to Note",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"pollTimes": {
|
||||||
|
"item": [{ "mode": "everyMinute" }]
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"hasReadStatus": true,
|
||||||
|
"readStatus": "unread"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "email-trigger",
|
||||||
|
"name": "Email Trigger (IMAP)",
|
||||||
|
"type": "n8n-nodes-base.emailTrigger",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [240, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const email = $input.item.json;\nconst subject = email.subject || 'Email (no subject)';\nconst from = email.from?.value?.[0]?.address || email.from || 'unknown';\nconst body = email.text || email.html?.replace(/<[^>]+>/g, '') || '';\nconst date = email.date ? new Date(email.date).toISOString() : new Date().toISOString();\n\nconst isUrgent = /(urgent|asap|important|deadline|critical)/i.test(subject + body);\n\nreturn [{\n json: {\n title: `📧 ${subject}`,\n content: `**From:** ${from}\\n**Date:** ${new Date(date).toLocaleString('en-GB')}\\n\\n---\\n\\n${body.trim().substring(0, 5000)}`,\n isMarkdown: true,\n color: isUrgent ? 'red' : 'blue',\n isPinned: isUrgent,\n labels: isUrgent ? ['email', 'urgent'] : ['email'],\n isUrgent\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "format-email",
|
||||||
|
"name": "Format Email as Note",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [480, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://memento-mcp:3001/mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Content-Type", "value": "application/json" },
|
||||||
|
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"create_note\",\n \"arguments\": {\n \"title\": \"{{ $json.title }}\",\n \"content\": \"{{ $json.content }}\",\n \"isMarkdown\": {{ $json.isMarkdown }},\n \"color\": \"{{ $json.color }}\",\n \"isPinned\": {{ $json.isPinned }},\n \"labels\": {{ JSON.stringify($json.labels) }}\n }\n }\n}"
|
||||||
|
},
|
||||||
|
"id": "mcp-create-note",
|
||||||
|
"name": "MCP — Create Note",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [720, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "urgent",
|
||||||
|
"leftValue": "={{ $('Format Email as Note').item.json.isUrgent }}",
|
||||||
|
"rightValue": true,
|
||||||
|
"operator": { "type": "boolean", "operation": "equals" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "if-urgent",
|
||||||
|
"name": "Urgent?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [960, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"channel": "alerts",
|
||||||
|
"text": "🚨 *Urgent email saved to Memento!*\n\n{{ $('Format Email as Note').item.json.title }}\n\nOpen: http://memento:3000",
|
||||||
|
"additionalFields": { "parse_mode": "Markdown" }
|
||||||
|
},
|
||||||
|
"id": "slack-alert",
|
||||||
|
"name": "Slack Alert",
|
||||||
|
"type": "n8n-nodes-base.slack",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [1200, 200],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Email Trigger (IMAP)": {
|
||||||
|
"main": [[{ "node": "Format Email as Note", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Email as Note": {
|
||||||
|
"main": [[{ "node": "MCP — Create Note", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"MCP — Create Note": {
|
||||||
|
"main": [[{ "node": "Urgent?", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Urgent?": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Slack Alert", "type": "main", "index": 0 }],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"settings": { "executionOrder": "v1" },
|
||||||
|
"staticData": null,
|
||||||
|
"tags": [{ "id": "memento-mcp", "name": "Memento MCP" }],
|
||||||
|
"triggerCount": 1,
|
||||||
|
"updatedAt": "2026-05-03T00:00:00.000Z",
|
||||||
|
"versionId": "1"
|
||||||
|
}
|
||||||
155
mcp-server/n8n-workflow-mcp-reminder-bot.json
Normal file
155
mcp-server/n8n-workflow-mcp-reminder-bot.json
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"name": "Memento MCP — Reminder Bot",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "cronExpression",
|
||||||
|
"expression": "0 */30 * * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "schedule-trigger",
|
||||||
|
"name": "Every 30 Minutes",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [240, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://memento-mcp:3001/mcp",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "x-api-key",
|
||||||
|
"value": "={{ $vars.MEMENTO_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"get_due_reminders\",\n \"arguments\": {}\n }\n}"
|
||||||
|
},
|
||||||
|
"id": "mcp-get-reminders",
|
||||||
|
"name": "MCP — Get Due Reminders",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [480, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Parse MCP response\nconst response = $input.item.json;\nconst result = JSON.parse(response.result?.content?.[0]?.text || '[]');\n\nif (!Array.isArray(result) || result.length === 0) {\n return [];\n}\n\nreturn result.map(note => ({\n json: {\n noteId: note.id,\n title: note.title || 'Untitled reminder',\n reminder: note.reminder,\n reminderFormatted: new Date(note.reminder).toLocaleString('en-GB'),\n content: (note.content || '').substring(0, 200),\n labels: Array.isArray(note.labels) ? note.labels.join(', ') : ''\n }\n}));"
|
||||||
|
},
|
||||||
|
"id": "parse-reminders",
|
||||||
|
"name": "Parse Reminders",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [720, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": { "caseSensitive": true },
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "has-items",
|
||||||
|
"leftValue": "={{ $items().length }}",
|
||||||
|
"rightValue": 0,
|
||||||
|
"operator": {
|
||||||
|
"type": "number",
|
||||||
|
"operation": "gt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "check-has-reminders",
|
||||||
|
"name": "Has Reminders?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [960, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"chatId": "={{ $vars.TELEGRAM_CHAT_ID }}",
|
||||||
|
"text": "⏰ *Reminder — Memento*\n\n📌 *{{ $json.title }}*\n🕐 {{ $json.reminderFormatted }}\n\n{{ $json.content }}{{ $json.labels ? '\n🏷️ ' + $json.labels : '' }}",
|
||||||
|
"additionalFields": {
|
||||||
|
"parse_mode": "Markdown"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "send-telegram",
|
||||||
|
"name": "Send Telegram Notification",
|
||||||
|
"type": "n8n-nodes-base.telegram",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [1200, 200],
|
||||||
|
"continueOnFail": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://memento-mcp:3001/mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "x-api-key",
|
||||||
|
"value": "={{ $vars.MEMENTO_API_KEY }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": 2,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"update_note\",\n \"arguments\": {\n \"id\": \"{{ $json.noteId }}\",\n \"isReminderDone\": true\n }\n }\n}"
|
||||||
|
},
|
||||||
|
"id": "mcp-mark-done",
|
||||||
|
"name": "MCP — Mark Reminder Done",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [1440, 200],
|
||||||
|
"continueOnFail": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every 30 Minutes": {
|
||||||
|
"main": [[{ "node": "MCP — Get Due Reminders", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"MCP — Get Due Reminders": {
|
||||||
|
"main": [[{ "node": "Parse Reminders", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Parse Reminders": {
|
||||||
|
"main": [[{ "node": "Has Reminders?", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Has Reminders?": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Send Telegram Notification", "type": "main", "index": 0 }],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Send Telegram Notification": {
|
||||||
|
"main": [[{ "node": "MCP — Mark Reminder Done", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"settings": { "executionOrder": "v1" },
|
||||||
|
"staticData": null,
|
||||||
|
"tags": [{ "id": "memento-mcp", "name": "Memento MCP" }],
|
||||||
|
"triggerCount": 1,
|
||||||
|
"updatedAt": "2026-05-03T00:00:00.000Z",
|
||||||
|
"versionId": "1"
|
||||||
|
}
|
||||||
92
mcp-server/n8n-workflow-mcp-webhook-to-note.json
Normal file
92
mcp-server/n8n-workflow-mcp-webhook-to-note.json
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"name": "Memento MCP — Webhook to Note",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "memento-note",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "webhook-trigger",
|
||||||
|
"name": "Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [240, 300],
|
||||||
|
"webhookId": "memento-create-note"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Flexible input: accept anything and build a note from it\nconst body = $input.item.json.body || $input.item.json;\n\nconst title = body.title || body.subject || body.name || null;\nconst content = body.content || body.text || body.message || body.description || JSON.stringify(body, null, 2);\nconst color = body.color || 'default';\nconst labels = Array.isArray(body.labels) ? body.labels : body.labels ? [body.labels] : [];\nconst notebookId = body.notebookId || body.notebook_id || null;\nconst isPinned = body.isPinned === true || body.pinned === true;\nconst isMarkdown = body.isMarkdown === true || body.markdown === true;\nconst reminder = body.reminder || body.dueDate || body.due_date || null;\nconst color_valid = ['default','red','orange','yellow','green','teal','blue','purple','pink','gray'].includes(color) ? color : 'default';\n\nreturn [{ json: { title, content, color: color_valid, labels, notebookId, isPinned, isMarkdown, reminder } }];"
|
||||||
|
},
|
||||||
|
"id": "prepare-note",
|
||||||
|
"name": "Prepare Note Data",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [480, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://memento-mcp:3001/mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "Content-Type", "value": "application/json" },
|
||||||
|
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"create_note\",\n \"arguments\": {\n \"title\": {{ $json.title ? JSON.stringify($json.title) : 'null' }},\n \"content\": {{ JSON.stringify($json.content) }},\n \"color\": \"{{ $json.color }}\",\n \"labels\": {{ JSON.stringify($json.labels) }},\n \"isPinned\": {{ $json.isPinned }},\n \"isMarkdown\": {{ $json.isMarkdown }}\n {{ $json.notebookId ? ', \"notebookId\": \"' + $json.notebookId + '\"' : '' }}\n {{ $json.reminder ? ', \"reminder\": \"' + $json.reminder + '\"' : '' }}\n }\n }\n}"
|
||||||
|
},
|
||||||
|
"id": "mcp-create-note",
|
||||||
|
"name": "MCP — Create Note",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [720, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const response = $input.item.json;\nconst result = JSON.parse(response.result?.content?.[0]?.text || '{}');\n\nreturn [{\n json: {\n success: true,\n noteId: result.id,\n title: result.title,\n createdAt: result.createdAt\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "format-response",
|
||||||
|
"name": "Format Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [960, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}"
|
||||||
|
},
|
||||||
|
"id": "webhook-response",
|
||||||
|
"name": "Respond to Webhook",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [1200, 300]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Webhook": {
|
||||||
|
"main": [[{ "node": "Prepare Note Data", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Prepare Note Data": {
|
||||||
|
"main": [[{ "node": "MCP — Create Note", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"MCP — Create Note": {
|
||||||
|
"main": [[{ "node": "Format Response", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Format Response": {
|
||||||
|
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"settings": { "executionOrder": "v1" },
|
||||||
|
"staticData": null,
|
||||||
|
"tags": [{ "id": "memento-mcp", "name": "Memento MCP" }],
|
||||||
|
"triggerCount": 1,
|
||||||
|
"updatedAt": "2026-05-03T00:00:00.000Z",
|
||||||
|
"versionId": "1"
|
||||||
|
}
|
||||||
14
mcp-server/node_modules/.package-lock.json
generated
vendored
14
mcp-server/node_modules/.package-lock.json
generated
vendored
@@ -848,20 +848,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|||||||
158
mcp-server/node_modules/.prisma/client/edge.js
generated
vendored
158
mcp-server/node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
130
mcp-server/node_modules/.prisma/client/index-browser.js
generated
vendored
130
mcp-server/node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -116,40 +116,26 @@ Prisma.NullTypes = {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||||
|
ReadUncommitted: 'ReadUncommitted',
|
||||||
|
ReadCommitted: 'ReadCommitted',
|
||||||
|
RepeatableRead: 'RepeatableRead',
|
||||||
Serializable: 'Serializable'
|
Serializable: 'Serializable'
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.Prisma.NoteScalarFieldEnum = {
|
exports.Prisma.UserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
name: 'name',
|
||||||
content: 'content',
|
email: 'email',
|
||||||
color: 'color',
|
emailVerified: 'emailVerified',
|
||||||
isPinned: 'isPinned',
|
password: 'password',
|
||||||
isArchived: 'isArchived',
|
role: 'role',
|
||||||
type: 'type',
|
image: 'image',
|
||||||
checkItems: 'checkItems',
|
theme: 'theme',
|
||||||
labels: 'labels',
|
cardSizeMode: 'cardSizeMode',
|
||||||
images: 'images',
|
resetToken: 'resetToken',
|
||||||
links: 'links',
|
resetTokenExpiry: 'resetTokenExpiry',
|
||||||
reminder: 'reminder',
|
|
||||||
isReminderDone: 'isReminderDone',
|
|
||||||
reminderRecurrence: 'reminderRecurrence',
|
|
||||||
reminderLocation: 'reminderLocation',
|
|
||||||
isMarkdown: 'isMarkdown',
|
|
||||||
size: 'size',
|
|
||||||
embedding: 'embedding',
|
|
||||||
sharedWith: 'sharedWith',
|
|
||||||
userId: 'userId',
|
|
||||||
order: 'order',
|
|
||||||
notebookId: 'notebookId',
|
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt'
|
||||||
autoGenerated: 'autoGenerated',
|
|
||||||
aiProvider: 'aiProvider',
|
|
||||||
aiConfidence: 'aiConfidence',
|
|
||||||
language: 'language',
|
|
||||||
languageConfidence: 'languageConfidence',
|
|
||||||
lastAiAnalysis: 'lastAiAnalysis'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NotebookScalarFieldEnum = {
|
exports.Prisma.NotebookScalarFieldEnum = {
|
||||||
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.UserScalarFieldEnum = {
|
exports.Prisma.NoteScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name',
|
title: 'title',
|
||||||
email: 'email',
|
content: 'content',
|
||||||
emailVerified: 'emailVerified',
|
color: 'color',
|
||||||
password: 'password',
|
isPinned: 'isPinned',
|
||||||
role: 'role',
|
isArchived: 'isArchived',
|
||||||
image: 'image',
|
trashedAt: 'trashedAt',
|
||||||
theme: 'theme',
|
type: 'type',
|
||||||
resetToken: 'resetToken',
|
dismissedFromRecent: 'dismissedFromRecent',
|
||||||
resetTokenExpiry: 'resetTokenExpiry',
|
checkItems: 'checkItems',
|
||||||
|
labels: 'labels',
|
||||||
|
images: 'images',
|
||||||
|
links: 'links',
|
||||||
|
reminder: 'reminder',
|
||||||
|
isReminderDone: 'isReminderDone',
|
||||||
|
reminderRecurrence: 'reminderRecurrence',
|
||||||
|
reminderLocation: 'reminderLocation',
|
||||||
|
isMarkdown: 'isMarkdown',
|
||||||
|
size: 'size',
|
||||||
|
sharedWith: 'sharedWith',
|
||||||
|
userId: 'userId',
|
||||||
|
order: 'order',
|
||||||
|
notebookId: 'notebookId',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt',
|
||||||
|
contentUpdatedAt: 'contentUpdatedAt',
|
||||||
|
autoGenerated: 'autoGenerated',
|
||||||
|
aiProvider: 'aiProvider',
|
||||||
|
aiConfidence: 'aiConfidence',
|
||||||
|
language: 'language',
|
||||||
|
languageConfidence: 'languageConfidence',
|
||||||
|
lastAiAnalysis: 'lastAiAnalysis'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NoteEmbeddingScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
noteId: 'noteId',
|
||||||
|
embedding: 'embedding',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NoteShareScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
noteId: 'noteId',
|
||||||
|
userId: 'userId',
|
||||||
|
sharedBy: 'sharedBy',
|
||||||
|
status: 'status',
|
||||||
|
permission: 'permission',
|
||||||
|
notifiedAt: 'notifiedAt',
|
||||||
|
respondedAt: 'respondedAt',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
|
|||||||
expires: 'expires'
|
expires: 'expires'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NoteShareScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
noteId: 'noteId',
|
|
||||||
userId: 'userId',
|
|
||||||
sharedBy: 'sharedBy',
|
|
||||||
status: 'status',
|
|
||||||
permission: 'permission',
|
|
||||||
notifiedAt: 'notifiedAt',
|
|
||||||
respondedAt: 'respondedAt',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.Prisma.SystemConfigScalarFieldEnum = {
|
exports.Prisma.SystemConfigScalarFieldEnum = {
|
||||||
key: 'key',
|
key: 'key',
|
||||||
value: 'value'
|
value: 'value'
|
||||||
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
|
|||||||
fontSize: 'fontSize',
|
fontSize: 'fontSize',
|
||||||
demoMode: 'demoMode',
|
demoMode: 'demoMode',
|
||||||
showRecentNotes: 'showRecentNotes',
|
showRecentNotes: 'showRecentNotes',
|
||||||
|
notesViewMode: 'notesViewMode',
|
||||||
emailNotifications: 'emailNotifications',
|
emailNotifications: 'emailNotifications',
|
||||||
desktopNotifications: 'desktopNotifications',
|
desktopNotifications: 'desktopNotifications',
|
||||||
anonymousAnalytics: 'anonymousAnalytics'
|
anonymousAnalytics: 'anonymousAnalytics'
|
||||||
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
|
|||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.QueryMode = {
|
||||||
|
default: 'default',
|
||||||
|
insensitive: 'insensitive'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.NullsOrder = {
|
exports.Prisma.NullsOrder = {
|
||||||
first: 'first',
|
first: 'first',
|
||||||
last: 'last'
|
last: 'last'
|
||||||
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
|
|||||||
|
|
||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Note: 'Note',
|
User: 'User',
|
||||||
Notebook: 'Notebook',
|
Notebook: 'Notebook',
|
||||||
Label: 'Label',
|
Label: 'Label',
|
||||||
User: 'User',
|
Note: 'Note',
|
||||||
|
NoteEmbedding: 'NoteEmbedding',
|
||||||
|
NoteShare: 'NoteShare',
|
||||||
Account: 'Account',
|
Account: 'Account',
|
||||||
Session: 'Session',
|
Session: 'Session',
|
||||||
VerificationToken: 'VerificationToken',
|
VerificationToken: 'VerificationToken',
|
||||||
NoteShare: 'NoteShare',
|
|
||||||
SystemConfig: 'SystemConfig',
|
SystemConfig: 'SystemConfig',
|
||||||
AiFeedback: 'AiFeedback',
|
AiFeedback: 'AiFeedback',
|
||||||
MemoryEchoInsight: 'MemoryEchoInsight',
|
MemoryEchoInsight: 'MemoryEchoInsight',
|
||||||
|
|||||||
15128
mcp-server/node_modules/.prisma/client/index.d.ts
generated
vendored
15128
mcp-server/node_modules/.prisma/client/index.d.ts
generated
vendored
File diff suppressed because it is too large
Load Diff
162
mcp-server/node_modules/.prisma/client/index.js
generated
vendored
162
mcp-server/node_modules/.prisma/client/index.js
generated
vendored
File diff suppressed because one or more lines are too long
2
mcp-server/node_modules/.prisma/client/package.json
generated
vendored
2
mcp-server/node_modules/.prisma/client/package.json
generated
vendored
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-07b35a59db17a461d4c7b787cc433edb9e7b79a627ae71660fd00cce5311cf75",
|
"name": "prisma-client-8c3c28a242bf05b03713c0c3d78783f929261d76a15352bcfc52a1cfa1e7f92a",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
201
mcp-server/node_modules/.prisma/client/schema.prisma
generated
vendored
201
mcp-server/node_modules/.prisma/client/schema.prisma
generated
vendored
@@ -1,44 +1,46 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// MCP Server Schema — exact copy from memento-note (source of truth)
|
||||||
|
// Only includes models used by MCP tools.
|
||||||
|
// Do NOT modify independently — always sync with memento-note/prisma/schema.prisma
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../node_modules/.prisma/client"
|
output = "../node_modules/.prisma/client"
|
||||||
|
binaryTargets = ["linux-musl-openssl-3.0.x", "native"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
url = "file:../../memento-note/prisma/dev.db"
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Note {
|
// ── Core models (used by MCP tools) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String?
|
name String?
|
||||||
content String
|
email String @unique
|
||||||
color String @default("default")
|
emailVerified DateTime?
|
||||||
isPinned Boolean @default(false)
|
password String?
|
||||||
isArchived Boolean @default(false)
|
role String @default("USER")
|
||||||
type String @default("text")
|
image String?
|
||||||
checkItems String?
|
theme String @default("light")
|
||||||
labels String?
|
cardSizeMode String @default("variable")
|
||||||
images String?
|
resetToken String? @unique
|
||||||
links String?
|
resetTokenExpiry DateTime?
|
||||||
reminder DateTime?
|
|
||||||
isReminderDone Boolean @default(false)
|
|
||||||
reminderRecurrence String?
|
|
||||||
reminderLocation String?
|
|
||||||
isMarkdown Boolean @default(false)
|
|
||||||
size String @default("small")
|
|
||||||
embedding String?
|
|
||||||
sharedWith String?
|
|
||||||
userId String?
|
|
||||||
order Int @default(0)
|
|
||||||
notebookId String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
autoGenerated Boolean?
|
accounts Account[]
|
||||||
aiProvider String?
|
aiFeedback AiFeedback[]
|
||||||
aiConfidence Int?
|
labels Label[]
|
||||||
language String?
|
memoryEchoInsights MemoryEchoInsight[]
|
||||||
languageConfidence Float?
|
notes Note[]
|
||||||
lastAiAnalysis DateTime?
|
sentShares NoteShare[] @relation("SentShares")
|
||||||
|
receivedShares NoteShare[] @relation("ReceivedShares")
|
||||||
|
notebooks Notebook[]
|
||||||
|
sessions Session[]
|
||||||
|
aiSettings UserAISettings?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Notebook {
|
model Notebook {
|
||||||
@@ -50,6 +52,12 @@ model Notebook {
|
|||||||
userId String
|
userId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
labels Label[]
|
||||||
|
notes Note[]
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, order])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Label {
|
model Label {
|
||||||
@@ -60,23 +68,99 @@ model Label {
|
|||||||
userId String?
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
||||||
|
notes Note[] @relation("LabelToNote")
|
||||||
|
|
||||||
|
@@unique([notebookId, name])
|
||||||
|
@@index([notebookId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model Note {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
title String?
|
||||||
email String @unique
|
content String
|
||||||
emailVerified DateTime?
|
color String @default("default")
|
||||||
password String?
|
isPinned Boolean @default(false)
|
||||||
role String @default("USER")
|
isArchived Boolean @default(false)
|
||||||
image String?
|
trashedAt DateTime?
|
||||||
theme String @default("light")
|
type String @default("text")
|
||||||
resetToken String? @unique
|
dismissedFromRecent Boolean @default(false)
|
||||||
resetTokenExpiry DateTime?
|
checkItems String?
|
||||||
|
labels String?
|
||||||
|
images String?
|
||||||
|
links String?
|
||||||
|
reminder DateTime?
|
||||||
|
isReminderDone Boolean @default(false)
|
||||||
|
reminderRecurrence String?
|
||||||
|
reminderLocation String?
|
||||||
|
isMarkdown Boolean @default(false)
|
||||||
|
size String @default("small")
|
||||||
|
sharedWith String?
|
||||||
|
userId String?
|
||||||
|
order Int @default(0)
|
||||||
|
notebookId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
contentUpdatedAt DateTime @default(now())
|
||||||
|
autoGenerated Boolean?
|
||||||
|
aiProvider String?
|
||||||
|
aiConfidence Int?
|
||||||
|
language String?
|
||||||
|
languageConfidence Float?
|
||||||
|
lastAiAnalysis DateTime?
|
||||||
|
aiFeedback AiFeedback[]
|
||||||
|
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
|
||||||
|
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
|
||||||
|
notebook Notebook? @relation(fields: [notebookId], references: [id])
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
shares NoteShare[]
|
||||||
|
labelRelations Label[] @relation("LabelToNote")
|
||||||
|
noteEmbedding NoteEmbedding?
|
||||||
|
|
||||||
|
@@index([isPinned])
|
||||||
|
@@index([isArchived])
|
||||||
|
@@index([trashedAt])
|
||||||
|
@@index([order])
|
||||||
|
@@index([reminder])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([userId, notebookId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NoteEmbedding {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String @unique
|
||||||
|
embedding String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([noteId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model NoteShare {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
userId String
|
||||||
|
sharedBy String
|
||||||
|
status String @default("pending")
|
||||||
|
permission String @default("view")
|
||||||
|
notifiedAt DateTime?
|
||||||
|
respondedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([noteId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([sharedBy])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Supporting models (used for auth, AI features, config) ──────────────────
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
userId String
|
userId String
|
||||||
type String
|
type String
|
||||||
@@ -91,6 +175,7 @@ model Account {
|
|||||||
session_state String?
|
session_state String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([provider, providerAccountId])
|
@@id([provider, providerAccountId])
|
||||||
}
|
}
|
||||||
@@ -101,6 +186,7 @@ model Session {
|
|||||||
expires DateTime
|
expires DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model VerificationToken {
|
model VerificationToken {
|
||||||
@@ -111,21 +197,6 @@ model VerificationToken {
|
|||||||
@@id([identifier, token])
|
@@id([identifier, token])
|
||||||
}
|
}
|
||||||
|
|
||||||
model NoteShare {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
noteId String
|
|
||||||
userId String
|
|
||||||
sharedBy String
|
|
||||||
status String @default("pending")
|
|
||||||
permission String @default("view")
|
|
||||||
notifiedAt DateTime?
|
|
||||||
respondedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([noteId, userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
key String @id
|
key String @id
|
||||||
value String
|
value String
|
||||||
@@ -141,6 +212,12 @@ model AiFeedback {
|
|||||||
correctedContent String?
|
correctedContent String?
|
||||||
metadata String?
|
metadata String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([noteId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([feature])
|
||||||
}
|
}
|
||||||
|
|
||||||
model MemoryEchoInsight {
|
model MemoryEchoInsight {
|
||||||
@@ -154,8 +231,13 @@ model MemoryEchoInsight {
|
|||||||
viewed Boolean @default(false)
|
viewed Boolean @default(false)
|
||||||
feedback String?
|
feedback String?
|
||||||
dismissed Boolean @default(false)
|
dismissed Boolean @default(false)
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
|
||||||
|
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, insightDate])
|
@@unique([userId, insightDate])
|
||||||
|
@@index([userId, insightDate])
|
||||||
|
@@index([userId, dismissed])
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserAISettings {
|
model UserAISettings {
|
||||||
@@ -169,8 +251,15 @@ model UserAISettings {
|
|||||||
preferredLanguage String @default("auto")
|
preferredLanguage String @default("auto")
|
||||||
fontSize String @default("medium")
|
fontSize String @default("medium")
|
||||||
demoMode Boolean @default(false)
|
demoMode Boolean @default(false)
|
||||||
showRecentNotes Boolean @default(false)
|
showRecentNotes Boolean @default(true)
|
||||||
|
notesViewMode String @default("masonry")
|
||||||
emailNotifications Boolean @default(false)
|
emailNotifications Boolean @default(false)
|
||||||
desktopNotifications Boolean @default(false)
|
desktopNotifications Boolean @default(false)
|
||||||
anonymousAnalytics Boolean @default(false)
|
anonymousAnalytics Boolean @default(false)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([memoryEcho])
|
||||||
|
@@index([aiProvider])
|
||||||
|
@@index([memoryEchoFrequency])
|
||||||
|
@@index([preferredLanguage])
|
||||||
}
|
}
|
||||||
|
|||||||
130
mcp-server/node_modules/.prisma/client/wasm.js
generated
vendored
130
mcp-server/node_modules/.prisma/client/wasm.js
generated
vendored
@@ -116,40 +116,26 @@ Prisma.NullTypes = {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||||
|
ReadUncommitted: 'ReadUncommitted',
|
||||||
|
ReadCommitted: 'ReadCommitted',
|
||||||
|
RepeatableRead: 'RepeatableRead',
|
||||||
Serializable: 'Serializable'
|
Serializable: 'Serializable'
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.Prisma.NoteScalarFieldEnum = {
|
exports.Prisma.UserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
name: 'name',
|
||||||
content: 'content',
|
email: 'email',
|
||||||
color: 'color',
|
emailVerified: 'emailVerified',
|
||||||
isPinned: 'isPinned',
|
password: 'password',
|
||||||
isArchived: 'isArchived',
|
role: 'role',
|
||||||
type: 'type',
|
image: 'image',
|
||||||
checkItems: 'checkItems',
|
theme: 'theme',
|
||||||
labels: 'labels',
|
cardSizeMode: 'cardSizeMode',
|
||||||
images: 'images',
|
resetToken: 'resetToken',
|
||||||
links: 'links',
|
resetTokenExpiry: 'resetTokenExpiry',
|
||||||
reminder: 'reminder',
|
|
||||||
isReminderDone: 'isReminderDone',
|
|
||||||
reminderRecurrence: 'reminderRecurrence',
|
|
||||||
reminderLocation: 'reminderLocation',
|
|
||||||
isMarkdown: 'isMarkdown',
|
|
||||||
size: 'size',
|
|
||||||
embedding: 'embedding',
|
|
||||||
sharedWith: 'sharedWith',
|
|
||||||
userId: 'userId',
|
|
||||||
order: 'order',
|
|
||||||
notebookId: 'notebookId',
|
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt'
|
||||||
autoGenerated: 'autoGenerated',
|
|
||||||
aiProvider: 'aiProvider',
|
|
||||||
aiConfidence: 'aiConfidence',
|
|
||||||
language: 'language',
|
|
||||||
languageConfidence: 'languageConfidence',
|
|
||||||
lastAiAnalysis: 'lastAiAnalysis'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NotebookScalarFieldEnum = {
|
exports.Prisma.NotebookScalarFieldEnum = {
|
||||||
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.UserScalarFieldEnum = {
|
exports.Prisma.NoteScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name',
|
title: 'title',
|
||||||
email: 'email',
|
content: 'content',
|
||||||
emailVerified: 'emailVerified',
|
color: 'color',
|
||||||
password: 'password',
|
isPinned: 'isPinned',
|
||||||
role: 'role',
|
isArchived: 'isArchived',
|
||||||
image: 'image',
|
trashedAt: 'trashedAt',
|
||||||
theme: 'theme',
|
type: 'type',
|
||||||
resetToken: 'resetToken',
|
dismissedFromRecent: 'dismissedFromRecent',
|
||||||
resetTokenExpiry: 'resetTokenExpiry',
|
checkItems: 'checkItems',
|
||||||
|
labels: 'labels',
|
||||||
|
images: 'images',
|
||||||
|
links: 'links',
|
||||||
|
reminder: 'reminder',
|
||||||
|
isReminderDone: 'isReminderDone',
|
||||||
|
reminderRecurrence: 'reminderRecurrence',
|
||||||
|
reminderLocation: 'reminderLocation',
|
||||||
|
isMarkdown: 'isMarkdown',
|
||||||
|
size: 'size',
|
||||||
|
sharedWith: 'sharedWith',
|
||||||
|
userId: 'userId',
|
||||||
|
order: 'order',
|
||||||
|
notebookId: 'notebookId',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt',
|
||||||
|
contentUpdatedAt: 'contentUpdatedAt',
|
||||||
|
autoGenerated: 'autoGenerated',
|
||||||
|
aiProvider: 'aiProvider',
|
||||||
|
aiConfidence: 'aiConfidence',
|
||||||
|
language: 'language',
|
||||||
|
languageConfidence: 'languageConfidence',
|
||||||
|
lastAiAnalysis: 'lastAiAnalysis'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NoteEmbeddingScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
noteId: 'noteId',
|
||||||
|
embedding: 'embedding',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NoteShareScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
noteId: 'noteId',
|
||||||
|
userId: 'userId',
|
||||||
|
sharedBy: 'sharedBy',
|
||||||
|
status: 'status',
|
||||||
|
permission: 'permission',
|
||||||
|
notifiedAt: 'notifiedAt',
|
||||||
|
respondedAt: 'respondedAt',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
|
|||||||
expires: 'expires'
|
expires: 'expires'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NoteShareScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
noteId: 'noteId',
|
|
||||||
userId: 'userId',
|
|
||||||
sharedBy: 'sharedBy',
|
|
||||||
status: 'status',
|
|
||||||
permission: 'permission',
|
|
||||||
notifiedAt: 'notifiedAt',
|
|
||||||
respondedAt: 'respondedAt',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.Prisma.SystemConfigScalarFieldEnum = {
|
exports.Prisma.SystemConfigScalarFieldEnum = {
|
||||||
key: 'key',
|
key: 'key',
|
||||||
value: 'value'
|
value: 'value'
|
||||||
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
|
|||||||
fontSize: 'fontSize',
|
fontSize: 'fontSize',
|
||||||
demoMode: 'demoMode',
|
demoMode: 'demoMode',
|
||||||
showRecentNotes: 'showRecentNotes',
|
showRecentNotes: 'showRecentNotes',
|
||||||
|
notesViewMode: 'notesViewMode',
|
||||||
emailNotifications: 'emailNotifications',
|
emailNotifications: 'emailNotifications',
|
||||||
desktopNotifications: 'desktopNotifications',
|
desktopNotifications: 'desktopNotifications',
|
||||||
anonymousAnalytics: 'anonymousAnalytics'
|
anonymousAnalytics: 'anonymousAnalytics'
|
||||||
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
|
|||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.QueryMode = {
|
||||||
|
default: 'default',
|
||||||
|
insensitive: 'insensitive'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.NullsOrder = {
|
exports.Prisma.NullsOrder = {
|
||||||
first: 'first',
|
first: 'first',
|
||||||
last: 'last'
|
last: 'last'
|
||||||
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
|
|||||||
|
|
||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Note: 'Note',
|
User: 'User',
|
||||||
Notebook: 'Notebook',
|
Notebook: 'Notebook',
|
||||||
Label: 'Label',
|
Label: 'Label',
|
||||||
User: 'User',
|
Note: 'Note',
|
||||||
|
NoteEmbedding: 'NoteEmbedding',
|
||||||
|
NoteShare: 'NoteShare',
|
||||||
Account: 'Account',
|
Account: 'Account',
|
||||||
Session: 'Session',
|
Session: 'Session',
|
||||||
VerificationToken: 'VerificationToken',
|
VerificationToken: 'VerificationToken',
|
||||||
NoteShare: 'NoteShare',
|
|
||||||
SystemConfig: 'SystemConfig',
|
SystemConfig: 'SystemConfig',
|
||||||
AiFeedback: 'AiFeedback',
|
AiFeedback: 'AiFeedback',
|
||||||
MemoryEchoInsight: 'MemoryEchoInsight',
|
MemoryEchoInsight: 'MemoryEchoInsight',
|
||||||
|
|||||||
1
mcp-server/package-lock.json
generated
1
mcp-server/package-lock.json
generated
@@ -870,6 +870,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Memento MCP Server - Optimized Tool Definitions & Handlers
|
* Memento MCP Server - Tool Definitions & Handlers
|
||||||
*
|
*
|
||||||
* Performance optimizations:
|
* Fast, minimal overhead. All queries filter trashed notes.
|
||||||
* - O(1) API key lookup with caching
|
* Compact JSON output. Direct DB lookups.
|
||||||
* - Batch operations for imports
|
|
||||||
* - Parallel promise execution
|
|
||||||
* - HTTP timeout wrapper
|
|
||||||
* - N+1 query fixes
|
|
||||||
* - Connection pooling
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,12 +13,12 @@ import {
|
|||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { requestContext } from './request-context.js';
|
import { requestContext } from './request-context.js';
|
||||||
|
|
||||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_SEARCH_LIMIT = 50;
|
const DEFAULT_SEARCH_LIMIT = 50;
|
||||||
const DEFAULT_NOTES_LIMIT = 100;
|
const DEFAULT_NOTES_LIMIT = 100;
|
||||||
|
const MAX_NOTES_LIMIT = 500;
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||||
|
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||||
|
|
||||||
export function parseNote(dbNote) {
|
export function parseNote(dbNote) {
|
||||||
if (!dbNote) return null;
|
if (!dbNote) return null;
|
||||||
@@ -66,65 +61,65 @@ export function parseNoteLightweight(dbNote) {
|
|||||||
|
|
||||||
function textResult(data) {
|
function textResult(data) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
content: [{ type: 'text', text: JSON.stringify(data) }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tool Schemas ───────────────────────────────────────────────────────────
|
// ─── Tool Definitions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NOTE_COLORS = ['default', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
|
||||||
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
|
||||||
|
|
||||||
const toolDefinitions = [
|
const toolDefinitions = [
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ NOTES ═══
|
||||||
// NOTE TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_note',
|
name: 'create_note',
|
||||||
description: 'Create a new note. Supports text and checklist types, colors, labels, images, links, reminders, markdown, and notebook assignment.',
|
description: 'Create a new note. Set content (required), optional title, color, labels, notebook assignment, reminder, or checklist items.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: 'string', description: 'Note title (optional)' },
|
title: { type: 'string', description: 'Note title' },
|
||||||
content: { type: 'string', description: 'Note content (required)' },
|
content: { type: 'string', description: 'Note body text (required)' },
|
||||||
color: { type: 'string', description: `Note color: ${NOTE_COLORS.join(', ')}`, default: 'default' },
|
color: { type: 'string', description: `Color: ${NOTE_COLORS}`, default: 'default' },
|
||||||
type: { type: 'string', enum: ['text', 'checklist'], description: 'Note type', default: 'text' },
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['text', 'markdown', 'richtext', 'checklist'],
|
||||||
|
description: 'Note type. "text" = plain text, "markdown" = Markdown rendered, "richtext" = rich text editor (default), "checklist" = interactive checklist',
|
||||||
|
default: 'richtext',
|
||||||
|
},
|
||||||
checkItems: {
|
checkItems: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Checklist items (when type is checklist)',
|
description: 'Checklist items (when type=checklist)',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
|
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
|
||||||
required: ['id', 'text', 'checked'],
|
required: ['id', 'text', 'checked'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
labels: { type: 'array', description: 'Note labels/tags', items: { type: 'string' } },
|
labels: { type: 'array', description: 'Tags/labels', items: { type: 'string' } },
|
||||||
isPinned: { type: 'boolean', description: 'Pin the note', default: false },
|
isPinned: { type: 'boolean', description: 'Pin to top', default: false },
|
||||||
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
|
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
|
||||||
images: { type: 'array', description: 'Image URLs or base64 strings', items: { type: 'string' } },
|
images: { type: 'array', description: 'Image URLs', items: { type: 'string' } },
|
||||||
links: { type: 'array', description: 'URLs attached to the note', items: { type: 'string' } },
|
links: { type: 'array', description: 'Attached URLs', items: { type: 'string' } },
|
||||||
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
|
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
|
||||||
isReminderDone: { type: 'boolean', default: false },
|
isReminderDone: { type: 'boolean', default: false },
|
||||||
reminderRecurrence: { type: 'string', description: 'Recurrence: daily, weekly, monthly, yearly' },
|
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly' },
|
||||||
reminderLocation: { type: 'string', description: 'Location-based reminder' },
|
reminderLocation: { type: 'string', description: 'Location string' },
|
||||||
isMarkdown: { type: 'boolean', description: 'Enable markdown rendering', default: false },
|
isMarkdown: { type: 'boolean', description: '(Deprecated — use type="markdown" instead) Render as markdown', default: false },
|
||||||
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
|
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
|
||||||
notebookId: { type: 'string', description: 'Notebook to assign the note to' },
|
notebookId: { type: 'string', description: 'Assign to notebook' },
|
||||||
},
|
},
|
||||||
required: ['content'],
|
required: ['content'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notes',
|
name: 'get_notes',
|
||||||
description: 'Get notes with optional filters. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for complete data.',
|
description: 'List notes. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for full payloads.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
|
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
|
||||||
search: { type: 'string', description: 'Filter by keyword in title/content' },
|
search: { type: 'string', description: 'Keyword filter on title/content' },
|
||||||
notebookId: { type: 'string', description: 'Filter by notebook ID. Use "inbox" for notes without a notebook' },
|
notebookId: { type: 'string', description: 'Filter by notebook. Use "inbox" for unfiled notes.' },
|
||||||
fullDetails: { type: 'boolean', description: 'Return full details including images (large payload)', default: false },
|
fullDetails: { type: 'boolean', description: 'Full payload including images', default: false },
|
||||||
limit: { type: 'number', description: `Max notes to return (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
|
limit: { type: 'number', description: `Max results (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -139,15 +134,19 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_note',
|
name: 'update_note',
|
||||||
description: 'Update an existing note. Only include fields you want to change.',
|
description: 'Update a note. Only fields you include will be changed.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', description: 'Note ID' },
|
id: { type: 'string', description: 'Note ID' },
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
content: { type: 'string' },
|
content: { type: 'string' },
|
||||||
color: { type: 'string', description: `One of: ${NOTE_COLORS.join(', ')}` },
|
color: { type: 'string', description: `One of: ${NOTE_COLORS}` },
|
||||||
type: { type: 'string', enum: ['text', 'checklist'] },
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['text', 'markdown', 'richtext', 'checklist'],
|
||||||
|
description: 'Note type. "text" = plain text, "markdown" = Markdown rendered, "richtext" = rich text editor, "checklist" = interactive checklist',
|
||||||
|
},
|
||||||
checkItems: {
|
checkItems: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
@@ -164,7 +163,7 @@ const toolDefinitions = [
|
|||||||
isReminderDone: { type: 'boolean' },
|
isReminderDone: { type: 'boolean' },
|
||||||
reminderRecurrence: { type: 'string' },
|
reminderRecurrence: { type: 'string' },
|
||||||
reminderLocation: { type: 'string' },
|
reminderLocation: { type: 'string' },
|
||||||
isMarkdown: { type: 'boolean' },
|
isMarkdown: { type: 'boolean', description: '(Deprecated — use type="markdown" instead)' },
|
||||||
size: { type: 'string', enum: ['small', 'medium', 'large'] },
|
size: { type: 'string', enum: ['small', 'medium', 'large'] },
|
||||||
notebookId: { type: 'string' },
|
notebookId: { type: 'string' },
|
||||||
},
|
},
|
||||||
@@ -173,7 +172,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_note',
|
name: 'delete_note',
|
||||||
description: 'Delete a note by ID.',
|
description: 'Permanently delete a note.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Note ID' } },
|
properties: { id: { type: 'string', description: 'Note ID' } },
|
||||||
@@ -187,7 +186,7 @@ const toolDefinitions = [
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: 'string', description: 'Search query' },
|
query: { type: 'string', description: 'Search query' },
|
||||||
notebookId: { type: 'string', description: 'Limit search to a notebook' },
|
notebookId: { type: 'string', description: 'Limit to a notebook' },
|
||||||
includeArchived: { type: 'boolean', default: false },
|
includeArchived: { type: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
@@ -195,12 +194,12 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'move_note',
|
name: 'move_note',
|
||||||
description: 'Move a note to a different notebook. Pass null for notebookId to move to Inbox.',
|
description: 'Move a note to a notebook. Set notebookId to null to move to Inbox.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', description: 'Note ID' },
|
id: { type: 'string', description: 'Note ID' },
|
||||||
notebookId: { type: 'string', description: 'Target notebook ID, or null/empty for Inbox' },
|
notebookId: { type: 'string', description: 'Target notebook ID, or null for Inbox' },
|
||||||
},
|
},
|
||||||
required: ['id'],
|
required: ['id'],
|
||||||
},
|
},
|
||||||
@@ -225,18 +224,18 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'export_notes',
|
name: 'export_notes',
|
||||||
description: 'Export all notes, labels, and notebooks as a JSON object.',
|
description: 'Export all notes, notebooks, and labels as JSON.',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'import_notes',
|
name: 'import_notes',
|
||||||
description: 'Import notes from a previously exported JSON object. Skips duplicates by name.',
|
description: 'Import notes, notebooks, and labels from a previous export. Duplicates are skipped.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'The exported JSON data (from export_notes)',
|
description: 'Export JSON (from export_notes)',
|
||||||
properties: {
|
properties: {
|
||||||
version: { type: 'string' },
|
version: { type: 'string' },
|
||||||
data: {
|
data: {
|
||||||
@@ -254,31 +253,29 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ NOTEBOOKS ═══
|
||||||
// NOTEBOOK TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_notebook',
|
name: 'create_notebook',
|
||||||
description: 'Create a new notebook.',
|
description: 'Create a notebook with a name, icon, and color.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', description: 'Notebook name' },
|
name: { type: 'string', description: 'Notebook name' },
|
||||||
icon: { type: 'string', description: 'Notebook icon (emoji)', default: '📁' },
|
icon: { type: 'string', description: 'Icon (emoji)', default: '📁' },
|
||||||
color: { type: 'string', description: 'Hex color code', default: '#3B82F6' },
|
color: { type: 'string', description: 'Hex color', default: '#3B82F6' },
|
||||||
order: { type: 'number', description: 'Sort position (auto-assigned if omitted)' },
|
order: { type: 'number', description: 'Sort position (auto if omitted)' },
|
||||||
},
|
},
|
||||||
required: ['name'],
|
required: ['name'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notebooks',
|
name: 'get_notebooks',
|
||||||
description: 'Get all notebooks with label and note counts.',
|
description: 'List all notebooks with label and note counts.',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notebook',
|
name: 'get_notebook',
|
||||||
description: 'Get a notebook by ID, including its notes.',
|
description: 'Get a notebook by ID including its notes.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
||||||
@@ -287,7 +284,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_notebook',
|
name: 'update_notebook',
|
||||||
description: 'Update a notebook\'s name, icon, color, or order.',
|
description: 'Update a notebook name, icon, color, or order.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -302,7 +299,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_notebook',
|
name: 'delete_notebook',
|
||||||
description: 'Delete a notebook. Notes inside will be moved to Inbox.',
|
description: 'Delete a notebook. Notes inside are moved to Inbox.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
||||||
@@ -311,13 +308,13 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'reorder_notebooks',
|
name: 'reorder_notebooks',
|
||||||
description: 'Reorder notebooks. Pass an ordered array of notebook IDs.',
|
description: 'Set the order of all notebooks by passing their IDs in sequence.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
notebookIds: {
|
notebookIds: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Notebook IDs in the desired order',
|
description: 'Notebook IDs in desired order',
|
||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -325,35 +322,33 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ LABELS ═══
|
||||||
// LABEL TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_label',
|
name: 'create_label',
|
||||||
description: 'Create a label in a notebook.',
|
description: 'Create a label inside a notebook.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', description: 'Label name' },
|
name: { type: 'string', description: 'Label name' },
|
||||||
color: { type: 'string', description: `Label color: ${LABEL_COLORS.join(', ')}` },
|
color: { type: 'string', description: `Color: ${LABEL_COLORS}` },
|
||||||
notebookId: { type: 'string', description: 'Notebook to attach the label to' },
|
notebookId: { type: 'string', description: 'Parent notebook ID' },
|
||||||
},
|
},
|
||||||
required: ['name', 'notebookId'],
|
required: ['name', 'notebookId'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_labels',
|
name: 'get_labels',
|
||||||
description: 'Get all labels, optionally filtered by notebook.',
|
description: 'List labels, optionally filtered by notebook.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
notebookId: { type: 'string', description: 'Filter labels by notebook ID' },
|
notebookId: { type: 'string', description: 'Filter by notebook' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_label',
|
name: 'update_label',
|
||||||
description: 'Update a label\'s name or color.',
|
description: 'Update a label name or color.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -366,7 +361,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_label',
|
name: 'delete_label',
|
||||||
description: 'Delete a label by ID.',
|
description: 'Delete a label.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Label ID' } },
|
properties: { id: { type: 'string', description: 'Label ID' } },
|
||||||
@@ -374,33 +369,23 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ REMINDERS ═══
|
||||||
// REMINDER TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'get_due_reminders',
|
name: 'get_due_reminders',
|
||||||
description: 'Get all notes with due reminders that haven\'t been processed yet. Designed for cron/automation use.',
|
description: 'Get notes with due reminders. Designed for cron/automation.',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Register all tools and handlers on an MCP Server instance.
|
|
||||||
*
|
|
||||||
* @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
|
|
||||||
* @param {import('@prisma/client').PrismaClient} prisma
|
|
||||||
*/
|
|
||||||
export function registerTools(server, prisma) {
|
export function registerTools(server, prisma) {
|
||||||
|
|
||||||
// Resolve userId per-request from AsyncLocalStorage (set by auth middleware)
|
|
||||||
const getResolvedUserId = () => {
|
const getResolvedUserId = () => {
|
||||||
const store = requestContext.getStore();
|
const store = requestContext.getStore();
|
||||||
return store?.userId || null;
|
return store?.userId || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback: auto-detect first user when no auth context
|
|
||||||
let fallbackUserId = null;
|
let fallbackUserId = null;
|
||||||
let fallbackPromise = null;
|
let fallbackPromise = null;
|
||||||
|
|
||||||
@@ -418,22 +403,24 @@ export function registerTools(server, prisma) {
|
|||||||
return fallbackPromise;
|
return fallbackPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── List Tools ────────────────────────────────────────────────────────────
|
const noteWhere = (resolvedUserId, extra = {}) => ({
|
||||||
|
trashedAt: null,
|
||||||
|
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
return { tools: toolDefinitions };
|
return { tools: toolDefinitions };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Call Tools ────────────────────────────────────────────────────────────
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
const resolvedUserId = getResolvedUserId();
|
const uid = getResolvedUserId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ NOTES ═══
|
||||||
// NOTES
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_note': {
|
case 'create_note': {
|
||||||
const data = {
|
const data = {
|
||||||
title: args.title || null,
|
title: args.title || null,
|
||||||
@@ -453,31 +440,29 @@ export function registerTools(server, prisma) {
|
|||||||
isMarkdown: args.isMarkdown || false,
|
isMarkdown: args.isMarkdown || false,
|
||||||
size: args.size || 'small',
|
size: args.size || 'small',
|
||||||
notebookId: args.notebookId || null,
|
notebookId: args.notebookId || null,
|
||||||
|
userId: uid || await ensureUserId(),
|
||||||
};
|
};
|
||||||
if (resolvedUserId) data.userId = resolvedUserId;
|
|
||||||
else data.userId = await ensureUserId();
|
|
||||||
|
|
||||||
const note = await prisma.note.create({ data });
|
const note = await prisma.note.create({ data });
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notes': {
|
case 'get_notes': {
|
||||||
const where = {};
|
const extra = {};
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
if (!args?.includeArchived) extra.isArchived = false;
|
||||||
if (!args?.includeArchived) where.isArchived = false;
|
|
||||||
if (args?.search) {
|
if (args?.search) {
|
||||||
where.OR = [
|
extra.OR = [
|
||||||
{ title: { contains: args.search } },
|
{ title: { contains: args.search } },
|
||||||
{ content: { contains: args.search } },
|
{ content: { contains: args.search } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (args?.notebookId) {
|
if (args?.notebookId) {
|
||||||
where.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, 500); // Max 500
|
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, MAX_NOTES_LIMIT);
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where,
|
where: noteWhere(uid, extra),
|
||||||
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
|
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
@@ -487,50 +472,50 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_note': {
|
case 'get_note': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_note': {
|
case 'update_note': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
const fields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
|
const simpleFields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
|
||||||
for (const f of fields) {
|
for (const f of simpleFields) {
|
||||||
if (f in args) updateData[f] = args[f];
|
if (f in args) d[f] = args[f];
|
||||||
}
|
}
|
||||||
if ('content' in args) updateData.content = args.content;
|
if ('content' in args) d.content = args.content;
|
||||||
if ('checkItems' in args) updateData.checkItems = args.checkItems ?? null;
|
if ('checkItems' in args) d.checkItems = args.checkItems ?? null;
|
||||||
if ('labels' in args) updateData.labels = args.labels ?? null;
|
if ('labels' in args) d.labels = args.labels ?? null;
|
||||||
if ('images' in args) updateData.images = args.images ?? null;
|
if ('images' in args) d.images = args.images ?? null;
|
||||||
if ('links' in args) updateData.links = args.links ?? null;
|
if ('links' in args) d.links = args.links ?? null;
|
||||||
if ('reminder' in args) updateData.reminder = args.reminder ? new Date(args.reminder) : null;
|
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||||
updateData.updatedAt = new Date();
|
d.updatedAt = new Date();
|
||||||
|
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.update({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.update({ where, data: updateData });
|
data: d,
|
||||||
|
});
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_note': {
|
case 'delete_note': {
|
||||||
const where = { id: args.id };
|
await prisma.note.delete({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
await prisma.note.delete({ where });
|
});
|
||||||
return textResult({ success: true, message: 'Note deleted' });
|
return textResult({ success: true, deleted: args.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'search_notes': {
|
case 'search_notes': {
|
||||||
const where = {
|
const where = noteWhere(uid, {
|
||||||
isArchived: args.includeArchived || false,
|
isArchived: args.includeArchived || false,
|
||||||
OR: [
|
OR: [
|
||||||
{ title: { contains: args.query } },
|
{ title: { contains: args.query } },
|
||||||
{ content: { contains: args.query } },
|
{ content: { contains: args.query } },
|
||||||
],
|
],
|
||||||
};
|
...(args.notebookId ? { notebookId: args.notebookId } : {}),
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
});
|
||||||
if (args.notebookId) where.notebookId = args.notebookId;
|
|
||||||
|
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -541,79 +526,61 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'move_note': {
|
case 'move_note': {
|
||||||
const noteWhere = { id: args.id };
|
const targetId = args.notebookId || null;
|
||||||
if (resolvedUserId) noteWhere.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const targetNotebookId = args.notebookId || null;
|
|
||||||
|
|
||||||
// Optimized: Parallel execution
|
|
||||||
const [note, notebook] = await Promise.all([
|
const [note, notebook] = await Promise.all([
|
||||||
prisma.note.update({
|
prisma.note.update({
|
||||||
where: noteWhere,
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
data: { notebookId: targetNotebookId, updatedAt: new Date() },
|
data: { notebookId: targetId, updatedAt: new Date() },
|
||||||
}),
|
}),
|
||||||
targetNotebookId
|
targetId
|
||||||
? prisma.notebook.findUnique({ where: { id: targetNotebookId }, select: { name: true } })
|
? prisma.notebook.findUnique({ where: { id: targetId }, select: { name: true } })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const notebookName = notebook?.name || 'Inbox';
|
|
||||||
|
|
||||||
return textResult({
|
return textResult({
|
||||||
success: true,
|
success: true,
|
||||||
data: { id: note.id, notebookId: note.notebookId, notebook: { name: notebookName } },
|
id: note.id,
|
||||||
message: `Note moved to ${notebookName}`,
|
notebookId: note.notebookId,
|
||||||
|
notebookName: notebook?.name || 'Inbox',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'toggle_pin': {
|
case 'toggle_pin': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
const updated = await prisma.note.update({
|
const updated = await prisma.note.update({
|
||||||
where,
|
where: { id: args.id },
|
||||||
data: { isPinned: !note.isPinned },
|
data: { isPinned: !note.isPinned },
|
||||||
});
|
});
|
||||||
return textResult(parseNote(updated));
|
return textResult(parseNote(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'toggle_archive': {
|
case 'toggle_archive': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
const updated = await prisma.note.update({
|
const updated = await prisma.note.update({
|
||||||
where,
|
where: { id: args.id },
|
||||||
data: { isArchived: !note.isArchived },
|
data: { isArchived: !note.isArchived },
|
||||||
});
|
});
|
||||||
return textResult(parseNote(updated));
|
return textResult(parseNote(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'export_notes': {
|
case 'export_notes': {
|
||||||
const noteWhere = {};
|
const nbWhere = uid ? { userId: uid } : {};
|
||||||
const nbWhere = {};
|
|
||||||
if (resolvedUserId) { noteWhere.userId = resolvedUserId; nbWhere.userId = resolvedUserId; }
|
|
||||||
|
|
||||||
// Optimized: Parallel queries
|
|
||||||
const [notes, notebooks, labels] = await Promise.all([
|
const [notes, notebooks, labels] = await Promise.all([
|
||||||
prisma.note.findMany({
|
prisma.note.findMany({
|
||||||
where: noteWhere,
|
where: noteWhere(uid),
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true, title: true, content: true, color: true, type: true,
|
||||||
title: true,
|
isPinned: true, isArchived: true, isMarkdown: true, size: true,
|
||||||
content: true,
|
labels: true, notebookId: true, createdAt: true, updatedAt: true,
|
||||||
color: true,
|
|
||||||
type: true,
|
|
||||||
isPinned: true,
|
|
||||||
isArchived: true,
|
|
||||||
isMarkdown: true,
|
|
||||||
size: true,
|
|
||||||
labels: true,
|
|
||||||
notebookId: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.notebook.findMany({
|
prisma.notebook.findMany({
|
||||||
@@ -626,9 +593,8 @@ export function registerTools(server, prisma) {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter labels by userId in memory (faster than multiple queries)
|
const filteredLabels = uid
|
||||||
const filteredLabels = resolvedUserId
|
? labels.filter(l => l.notebook?.userId === uid)
|
||||||
? labels.filter(l => l.notebook?.userId === resolvedUserId)
|
|
||||||
: labels;
|
: labels;
|
||||||
|
|
||||||
return textResult({
|
return textResult({
|
||||||
@@ -636,32 +602,16 @@ export function registerTools(server, prisma) {
|
|||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
data: {
|
data: {
|
||||||
notes: notes.map(n => ({
|
notes: notes.map(n => ({
|
||||||
id: n.id,
|
id: n.id, title: n.title, content: n.content, color: n.color, type: n.type,
|
||||||
title: n.title,
|
isPinned: n.isPinned, isArchived: n.isArchived, isMarkdown: n.isMarkdown,
|
||||||
content: n.content,
|
size: n.size, labels: Array.isArray(n.labels) ? n.labels : [],
|
||||||
color: n.color,
|
notebookId: n.notebookId, createdAt: n.createdAt, updatedAt: n.updatedAt,
|
||||||
type: n.type,
|
|
||||||
isPinned: n.isPinned,
|
|
||||||
isArchived: n.isArchived,
|
|
||||||
isMarkdown: n.isMarkdown,
|
|
||||||
size: n.size,
|
|
||||||
labels: Array.isArray(n.labels) ? n.labels : [],
|
|
||||||
notebookId: n.notebookId,
|
|
||||||
createdAt: n.createdAt,
|
|
||||||
updatedAt: n.updatedAt,
|
|
||||||
})),
|
})),
|
||||||
notebooks: notebooks.map(nb => ({
|
notebooks: notebooks.map(nb => ({
|
||||||
id: nb.id,
|
id: nb.id, name: nb.name, icon: nb.icon, color: nb.color, noteCount: nb._count.notes,
|
||||||
name: nb.name,
|
|
||||||
icon: nb.icon,
|
|
||||||
color: nb.color,
|
|
||||||
noteCount: nb._count.notes,
|
|
||||||
})),
|
})),
|
||||||
labels: filteredLabels.map(l => ({
|
labels: filteredLabels.map(l => ({
|
||||||
id: l.id,
|
id: l.id, name: l.name, color: l.color, notebookId: l.notebookId,
|
||||||
name: l.name,
|
|
||||||
color: l.color,
|
|
||||||
notebookId: l.notebookId,
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -671,60 +621,51 @@ export function registerTools(server, prisma) {
|
|||||||
const importData = args.data;
|
const importData = args.data;
|
||||||
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
|
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
|
||||||
|
|
||||||
// OPTIMIZED: Batch operations with Promise.all for notebooks
|
|
||||||
if (importData.data?.notebooks?.length > 0) {
|
if (importData.data?.notebooks?.length > 0) {
|
||||||
const existingNotebooks = await prisma.notebook.findMany({
|
const existing = await prisma.notebook.findMany({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
});
|
});
|
||||||
const existingNames = new Set(existingNotebooks.map(nb => nb.name));
|
const existingNames = new Set(existing.map(nb => nb.name));
|
||||||
|
|
||||||
const notebooksToCreate = importData.data.notebooks
|
const toCreate = importData.data.notebooks
|
||||||
.filter(nb => !existingNames.has(nb.name))
|
.filter(nb => !existingNames.has(nb.name))
|
||||||
.map(nb => prisma.notebook.create({
|
.map(nb => ({
|
||||||
data: {
|
|
||||||
name: nb.name,
|
name: nb.name,
|
||||||
icon: nb.icon || '📁',
|
icon: nb.icon || '📁',
|
||||||
color: nb.color || '#3B82F6',
|
color: nb.color || '#3B82F6',
|
||||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
...(uid ? { userId: uid } : {}),
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await Promise.all(notebooksToCreate);
|
if (toCreate.length > 0) {
|
||||||
importedNotebooks = notebooksToCreate.length;
|
await prisma.notebook.createMany({ data: toCreate, skipDuplicates: true });
|
||||||
|
importedNotebooks = toCreate.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Batch labels
|
|
||||||
if (importData.data?.labels?.length > 0) {
|
if (importData.data?.labels?.length > 0) {
|
||||||
const notebooks = await prisma.notebook.findMany({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const notebookIds = new Set(notebooks.map(nb => nb.id));
|
const nbIds = new Set(notebooks.map(nb => nb.id));
|
||||||
|
|
||||||
const existingLabels = await prisma.label.findMany({
|
const existing = await prisma.label.findMany({
|
||||||
where: { notebookId: { in: Array.from(notebookIds) } },
|
where: { notebookId: { in: Array.from(nbIds) } },
|
||||||
select: { name: true, notebookId: true },
|
select: { name: true, notebookId: true },
|
||||||
});
|
});
|
||||||
const existingLabelKeys = new Set(existingLabels.map(l => `${l.notebookId}:${l.name}`));
|
const existingKeys = new Set(existing.map(l => `${l.notebookId}:${l.name}`));
|
||||||
|
|
||||||
const labelsToCreate = [];
|
const toCreate = importData.data.labels
|
||||||
for (const label of importData.data.labels) {
|
.filter(l => l.notebookId && nbIds.has(l.notebookId) && !existingKeys.has(`${l.notebookId}:${l.name}`))
|
||||||
if (label.notebookId && notebookIds.has(label.notebookId)) {
|
.map(l => ({ name: l.name, color: l.color, notebookId: l.notebookId }));
|
||||||
const key = `${label.notebookId}:${label.name}`;
|
|
||||||
if (!existingLabelKeys.has(key)) {
|
if (toCreate.length > 0) {
|
||||||
labelsToCreate.push(prisma.label.create({
|
await prisma.label.createMany({ data: toCreate, skipDuplicates: true });
|
||||||
data: { name: label.name, color: label.color, notebookId: label.notebookId },
|
importedLabels = toCreate.length;
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(labelsToCreate);
|
|
||||||
importedLabels = labelsToCreate.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// OPTIMIZED: Batch notes with createMany if available, else Promise.all
|
|
||||||
if (importData.data?.notes?.length > 0) {
|
if (importData.data?.notes?.length > 0) {
|
||||||
const notesData = importData.data.notes.map(note => ({
|
const notesData = importData.data.notes.map(note => ({
|
||||||
title: note.title,
|
title: note.title,
|
||||||
@@ -737,22 +678,14 @@ export function registerTools(server, prisma) {
|
|||||||
size: note.size || 'small',
|
size: note.size || 'small',
|
||||||
labels: note.labels ?? null,
|
labels: note.labels ?? null,
|
||||||
notebookId: note.notebookId || null,
|
notebookId: note.notebookId || null,
|
||||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
...(uid ? { userId: uid } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Try createMany first (faster), fall back to Promise.all
|
|
||||||
try {
|
try {
|
||||||
const result = await prisma.note.createMany({
|
const result = await prisma.note.createMany({ data: notesData, skipDuplicates: true });
|
||||||
data: notesData,
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
importedNotes = result.count;
|
importedNotes = result.count;
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to individual creates
|
const results = await Promise.all(notesData.map(d => prisma.note.create({ data: d }).catch(() => null)));
|
||||||
const creates = notesData.map(data =>
|
|
||||||
prisma.note.create({ data }).catch(() => null)
|
|
||||||
);
|
|
||||||
const results = await Promise.all(creates);
|
|
||||||
importedNotes = results.filter(r => r !== null).length;
|
importedNotes = results.filter(r => r !== null).length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -763,27 +696,23 @@ export function registerTools(server, prisma) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ NOTEBOOKS ═══
|
||||||
// NOTEBOOKS
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_notebook': {
|
case 'create_notebook': {
|
||||||
const highestOrder = await prisma.notebook.findFirst({
|
const highest = await prisma.notebook.findFirst({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
orderBy: { order: 'desc' },
|
orderBy: { order: 'desc' },
|
||||||
select: { order: true },
|
select: { order: true },
|
||||||
});
|
});
|
||||||
const nextOrder = args.order !== undefined ? args.order : (highestOrder?.order ?? -1) + 1;
|
const nextOrder = args.order !== undefined ? args.order : (highest?.order ?? -1) + 1;
|
||||||
|
|
||||||
const data = {
|
const notebook = await prisma.notebook.create({
|
||||||
|
data: {
|
||||||
name: args.name.trim(),
|
name: args.name.trim(),
|
||||||
icon: args.icon || '📁',
|
icon: args.icon || '📁',
|
||||||
color: args.color || '#3B82F6',
|
color: args.color || '#3B82F6',
|
||||||
order: nextOrder,
|
order: nextOrder,
|
||||||
};
|
userId: uid || await ensureUserId(),
|
||||||
data.userId = resolvedUserId || await ensureUserId();
|
},
|
||||||
|
|
||||||
const notebook = await prisma.notebook.create({
|
|
||||||
data,
|
|
||||||
include: { labels: true, _count: { select: { notes: true } } },
|
include: { labels: true, _count: { select: { notes: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -791,9 +720,7 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notebooks': {
|
case 'get_notebooks': {
|
||||||
const where = {};
|
const where = uid ? { userId: uid } : {};
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const notebooks = await prisma.notebook.findMany({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
@@ -807,12 +734,10 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notebook': {
|
case 'get_notebook': {
|
||||||
const where = { id: args.id };
|
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const notebook = await prisma.notebook.findUnique({
|
const notebook = await prisma.notebook.findUnique({
|
||||||
where,
|
where,
|
||||||
include: { labels: true, notes: true, _count: { select: { notes: true } } },
|
include: { labels: true, notes: { where: { trashedAt: null } }, _count: { select: { notes: true } } },
|
||||||
});
|
});
|
||||||
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
|
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
|
||||||
|
|
||||||
@@ -824,18 +749,16 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'update_notebook': {
|
case 'update_notebook': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
if ('name' in args) updateData.name = args.name.trim();
|
if ('name' in args) d.name = args.name.trim();
|
||||||
if ('icon' in args) updateData.icon = args.icon;
|
if ('icon' in args) d.icon = args.icon;
|
||||||
if ('color' in args) updateData.color = args.color;
|
if ('color' in args) d.color = args.color;
|
||||||
if ('order' in args) updateData.order = args.order;
|
if ('order' in args) d.order = args.order;
|
||||||
|
|
||||||
const where = { id: args.id };
|
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
|
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||||
const notebook = await prisma.notebook.update({
|
const notebook = await prisma.notebook.update({
|
||||||
where,
|
where,
|
||||||
data: updateData,
|
data: d,
|
||||||
include: { labels: true, _count: { select: { notes: true } } },
|
include: { labels: true, _count: { select: { notes: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -843,16 +766,14 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_notebook': {
|
case 'delete_notebook': {
|
||||||
const where = { id: args.id };
|
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
// Move notes to inbox before deleting
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.note.updateMany({
|
prisma.note.updateMany({
|
||||||
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
|
where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) },
|
||||||
data: { notebookId: null },
|
data: { notebookId: null },
|
||||||
}),
|
}),
|
||||||
prisma.notebook.delete({ where }),
|
prisma.notebook.delete({
|
||||||
|
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
|
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
|
||||||
@@ -860,21 +781,14 @@ export function registerTools(server, prisma) {
|
|||||||
|
|
||||||
case 'reorder_notebooks': {
|
case 'reorder_notebooks': {
|
||||||
const ids = args.notebookIds;
|
const ids = args.notebookIds;
|
||||||
|
const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) };
|
||||||
|
|
||||||
// Optimized: Verify ownership in one query
|
const existing = await prisma.notebook.findMany({ where, select: { id: true } });
|
||||||
const where = { id: { in: ids } };
|
const existingIds = new Set(existing.map(nb => nb.id));
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
const missing = ids.filter(id => !existingIds.has(id));
|
||||||
|
|
||||||
const existingNotebooks = await prisma.notebook.findMany({
|
if (missing.length > 0) {
|
||||||
where,
|
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missing.join(', ')}`);
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingIds = new Set(existingNotebooks.map(nb => nb.id));
|
|
||||||
const missingIds = ids.filter(id => !existingIds.has(id));
|
|
||||||
|
|
||||||
if (missingIds.length > 0) {
|
|
||||||
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missingIds.join(', ')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
@@ -882,12 +796,10 @@ export function registerTools(server, prisma) {
|
|||||||
prisma.notebook.update({ where: { id }, data: { order: index } })
|
prisma.notebook.update({ where: { id }, data: { order: index } })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return textResult({ success: true, message: 'Notebooks reordered' });
|
return textResult({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ LABELS ═══
|
||||||
// LABELS - OPTIMIZED to fix N+1 query
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_label': {
|
case 'create_label': {
|
||||||
const existing = await prisma.label.findFirst({
|
const existing = await prisma.label.findFirst({
|
||||||
where: { name: args.name.trim(), notebookId: args.notebookId },
|
where: { name: args.name.trim(), notebookId: args.notebookId },
|
||||||
@@ -908,49 +820,37 @@ export function registerTools(server, prisma) {
|
|||||||
const where = {};
|
const where = {};
|
||||||
if (args?.notebookId) where.notebookId = args.notebookId;
|
if (args?.notebookId) where.notebookId = args.notebookId;
|
||||||
|
|
||||||
// OPTIMIZED: Single query with include, then filter in memory
|
|
||||||
const labels = await prisma.label.findMany({
|
const labels = await prisma.label.findMany({
|
||||||
where,
|
where,
|
||||||
include: { notebook: { select: { id: true, name: true, userId: true } } },
|
include: { notebook: { select: { id: true, name: true, userId: true } } },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter by userId in memory (much faster than N+1 queries)
|
const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels;
|
||||||
let filteredLabels = labels;
|
return textResult(filtered);
|
||||||
if (resolvedUserId) {
|
|
||||||
filteredLabels = labels.filter(l => l.notebook?.userId === resolvedUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return textResult(filteredLabels);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_label': {
|
case 'update_label': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
if ('name' in args) updateData.name = args.name.trim();
|
if ('name' in args) d.name = args.name.trim();
|
||||||
if ('color' in args) updateData.color = args.color;
|
if ('color' in args) d.color = args.color;
|
||||||
|
|
||||||
const label = await prisma.label.update({
|
const label = await prisma.label.update({ where: { id: args.id }, data: d });
|
||||||
where: { id: args.id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
return textResult(label);
|
return textResult(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_label': {
|
case 'delete_label': {
|
||||||
await prisma.label.delete({ where: { id: args.id } });
|
await prisma.label.delete({ where: { id: args.id } });
|
||||||
return textResult({ success: true, message: 'Label deleted' });
|
return textResult({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ REMINDERS ═══
|
||||||
// REMINDERS
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'get_due_reminders': {
|
case 'get_due_reminders': {
|
||||||
const where = {
|
const where = noteWhere(uid, {
|
||||||
reminder: { not: null, lte: new Date() },
|
reminder: { not: null, lte: new Date() },
|
||||||
isReminderDone: false,
|
isReminderDone: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
};
|
});
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const reminders = await prisma.note.findMany({
|
const reminders = await prisma.note.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -958,7 +858,6 @@ export function registerTools(server, prisma) {
|
|||||||
orderBy: { reminder: 'asc' },
|
orderBy: { reminder: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark them as done
|
|
||||||
if (reminders.length > 0) {
|
if (reminders.length > 0) {
|
||||||
await prisma.note.updateMany({
|
await prisma.note.updateMany({
|
||||||
where: { id: { in: reminders.map(r => r.id) } },
|
where: { id: { in: reminders.map(r => r.id) } },
|
||||||
@@ -966,7 +865,7 @@ export function registerTools(server, prisma) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return textResult({ success: true, count: reminders.length, reminders });
|
return textResult({ count: reminders.length, reminders });
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -974,7 +873,7 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof McpError) throw error;
|
if (error instanceof McpError) throw error;
|
||||||
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`);
|
throw new McpError(ErrorCode.InternalError, `Tool error: ${error.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Note } from '@/lib/types'
|
|
||||||
import { NoteCard } from './note-card'
|
|
||||||
import { ChevronDown, ChevronUp, Pin } from 'lucide-react'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface FavoritesSectionProps {
|
|
||||||
pinnedNotes: Note[]
|
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
|
||||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
|
||||||
isLoading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading }: FavoritesSectionProps) {
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<section data-testid="favorites-section" className="mb-8">
|
|
||||||
<div className="flex items-center gap-2 mb-4 px-2 py-2">
|
|
||||||
<Pin className="w-5 h-5 text-muted-foreground animate-pulse" />
|
|
||||||
<div className="h-6 w-32 bg-muted rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="h-40 bg-muted rounded-2xl animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pinnedNotes.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section data-testid="favorites-section" className="mb-8">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsCollapsed(!isCollapsed)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center justify-between gap-2 mb-4 px-2 py-2 hover:bg-accent rounded-lg transition-colors min-h-[44px]"
|
|
||||||
aria-expanded={!isCollapsed}
|
|
||||||
aria-label={t('favorites.toggleSection') || 'Toggle pinned notes section'}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-2xl">📌</span>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
{t('notes.pinnedNotes')}
|
|
||||||
<span className="text-sm font-medium text-muted-foreground ml-2">
|
|
||||||
({pinnedNotes.length})
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Collapsible Content */}
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{pinnedNotes.map((note) => (
|
|
||||||
<NoteCard
|
|
||||||
key={note.id}
|
|
||||||
note={note}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onSizeChange={(size) => onSizeChange?.(note.id, size)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { Note } from '@/lib/types'
|
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
|
||||||
import { getAllNotes, searchNotes } from '@/app/actions/notes'
|
|
||||||
import { NoteInput } from '@/components/note-input'
|
|
||||||
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
|
||||||
import { NotesViewToggle } from '@/components/notes-view-toggle'
|
|
||||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
|
||||||
import { FavoritesSection } from '@/components/favorites-section'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Wand2 } from 'lucide-react'
|
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
|
||||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
|
||||||
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { LabelFilter } from '@/components/label-filter'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
import { useHomeView } from '@/context/home-view-context'
|
|
||||||
|
|
||||||
// Lazy-load heavy dialogs — uniquement chargés à la demande
|
|
||||||
const NoteEditor = dynamic(
|
|
||||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
const BatchOrganizationDialog = dynamic(
|
|
||||||
() => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
const AutoLabelSuggestionDialog = dynamic(
|
|
||||||
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
type InitialSettings = {
|
|
||||||
showRecentNotes: boolean
|
|
||||||
notesViewMode: 'masonry' | 'tabs'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HomeClientProps {
|
|
||||||
/** Notes pré-chargées côté serveur — hydratées immédiatement sans loading spinner */
|
|
||||||
initialNotes: Note[]
|
|
||||||
initialSettings: InitialSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
|
||||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
|
||||||
initialNotes.filter(n => n.isPinned)
|
|
||||||
)
|
|
||||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
|
|
||||||
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
|
||||||
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
|
||||||
const { refreshKey } = useNoteRefresh()
|
|
||||||
const { labels } = useLabels()
|
|
||||||
const { setControls } = useHomeView()
|
|
||||||
|
|
||||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
|
||||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldSuggestLabels && suggestNotebookId) {
|
|
||||||
setAutoLabelOpen(true)
|
|
||||||
}
|
|
||||||
}, [shouldSuggestLabels, suggestNotebookId])
|
|
||||||
|
|
||||||
const notebookFilter = searchParams.get('notebook')
|
|
||||||
const isInbox = !notebookFilter
|
|
||||||
|
|
||||||
const handleNoteCreated = useCallback((note: Note) => {
|
|
||||||
setNotes((prevNotes) => {
|
|
||||||
const notebookFilter = searchParams.get('notebook')
|
|
||||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
|
||||||
const colorFilter = searchParams.get('color')
|
|
||||||
const search = searchParams.get('search')?.trim() || null
|
|
||||||
|
|
||||||
if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes
|
|
||||||
if (!notebookFilter && note.notebookId) return prevNotes
|
|
||||||
|
|
||||||
if (labelFilter.length > 0) {
|
|
||||||
const noteLabels = note.labels || []
|
|
||||||
if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
if (colorFilter) {
|
|
||||||
const labelNamesWithColor = labels
|
|
||||||
.filter((label: any) => label.color === colorFilter)
|
|
||||||
.map((label: any) => label.name)
|
|
||||||
const noteLabels = note.labels || []
|
|
||||||
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
router.refresh()
|
|
||||||
return prevNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPinned = note.isPinned || false
|
|
||||||
const pinnedNotes = prevNotes.filter(n => n.isPinned)
|
|
||||||
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
|
|
||||||
|
|
||||||
if (isPinned) {
|
|
||||||
return [note, ...pinnedNotes, ...unpinnedNotes]
|
|
||||||
} else {
|
|
||||||
return [...pinnedNotes, note, ...unpinnedNotes]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!note.notebookId) {
|
|
||||||
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
|
||||||
if (wordCount >= 20) {
|
|
||||||
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams, labels, router])
|
|
||||||
|
|
||||||
const handleOpenNote = (noteId: string) => {
|
|
||||||
const note = notes.find(n => n.id === noteId)
|
|
||||||
if (note) setEditingNote({ note, readOnly: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
|
|
||||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
|
||||||
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useReminderCheck(notes)
|
|
||||||
|
|
||||||
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
|
|
||||||
// Les notes initiales suffisent sans filtre
|
|
||||||
useEffect(() => {
|
|
||||||
const search = searchParams.get('search')?.trim() || null
|
|
||||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
|
||||||
const colorFilter = searchParams.get('color')
|
|
||||||
const notebook = searchParams.get('notebook')
|
|
||||||
const semanticMode = searchParams.get('semantic') === 'true'
|
|
||||||
|
|
||||||
// Pour le refreshKey (mutations), toujours recharger
|
|
||||||
// Pour les filtres, charger depuis le serveur
|
|
||||||
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
let allNotes = search
|
|
||||||
? await searchNotes(search, semanticMode, notebook || undefined)
|
|
||||||
: await getAllNotes()
|
|
||||||
|
|
||||||
// Filtre notebook côté client
|
|
||||||
if (notebook) {
|
|
||||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook)
|
|
||||||
} else {
|
|
||||||
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre labels
|
|
||||||
if (labelFilter.length > 0) {
|
|
||||||
allNotes = allNotes.filter((note: any) =>
|
|
||||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre couleur
|
|
||||||
if (colorFilter) {
|
|
||||||
const labelNamesWithColor = labels
|
|
||||||
.filter((label: any) => label.color === colorFilter)
|
|
||||||
.map((label: any) => label.name)
|
|
||||||
allNotes = allNotes.filter((note: any) =>
|
|
||||||
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merger avec les tailles locales pour ne pas écraser les modifications
|
|
||||||
setNotes(prev => {
|
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
|
||||||
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
||||||
})
|
|
||||||
setPinnedNotes(allNotes.filter((n: any) => n.isPinned))
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
|
|
||||||
if (refreshKey > 0 || hasActiveFilter) {
|
|
||||||
const cancelled = { value: false }
|
|
||||||
load().then(() => { if (cancelled.value) return })
|
|
||||||
return () => { cancelled.value = true }
|
|
||||||
} else {
|
|
||||||
// Données initiales : filtrage inbox/notebook côté client seulement
|
|
||||||
let filtered = initialNotes
|
|
||||||
if (notebook) {
|
|
||||||
filtered = initialNotes.filter(n => n.notebookId === notebook)
|
|
||||||
} else {
|
|
||||||
filtered = initialNotes.filter(n => !n.notebookId)
|
|
||||||
}
|
|
||||||
// Merger avec les tailles déjà modifiées localement
|
|
||||||
setNotes(prev => {
|
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
|
||||||
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
||||||
})
|
|
||||||
setPinnedNotes(filtered.filter(n => n.isPinned))
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchParams, refreshKey])
|
|
||||||
|
|
||||||
const { notebooks } = useNotebooks()
|
|
||||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
|
||||||
const [showNoteInput, setShowNoteInput] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setControls({
|
|
||||||
isTabsMode: notesViewMode === 'tabs',
|
|
||||||
openNoteComposer: () => setShowNoteInput(true),
|
|
||||||
})
|
|
||||||
return () => setControls(null)
|
|
||||||
}, [notesViewMode, setControls])
|
|
||||||
|
|
||||||
const getNotebookIcon = (iconName: string) => {
|
|
||||||
const ICON_MAP: Record<string, any> = {
|
|
||||||
'folder': Folder,
|
|
||||||
'briefcase': Briefcase,
|
|
||||||
'document': FileText,
|
|
||||||
'lightning': Zap,
|
|
||||||
'chart': BarChart3,
|
|
||||||
'globe': Globe,
|
|
||||||
'sparkle': Sparkles,
|
|
||||||
'book': Book,
|
|
||||||
'heart': Heart,
|
|
||||||
'crown': Crown,
|
|
||||||
'music': Music,
|
|
||||||
'building': Building2,
|
|
||||||
'flight_takeoff': Plane,
|
|
||||||
}
|
|
||||||
return ICON_MAP[iconName] || Folder
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNoteCreatedWrapper = (note: any) => {
|
|
||||||
handleNoteCreated(note)
|
|
||||||
setShowNoteInput(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
||||||
<span>{t('nav.notebooks')}</span>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
<span className="font-medium text-primary">{notebookName}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const isTabs = notesViewMode === 'tabs'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex w-full min-h-0 flex-1 flex-col',
|
|
||||||
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Notebook Specific Header */}
|
|
||||||
{currentNotebook ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
|
||||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Breadcrumbs notebookName={currentNotebook.name} />
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
|
|
||||||
{(() => {
|
|
||||||
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
|
|
||||||
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
|
||||||
<LabelFilter
|
|
||||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
|
||||||
onFilterChange={(newLabels) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
|
||||||
else params.delete('labels')
|
|
||||||
router.push(`/?${params.toString()}`)
|
|
||||||
}}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
{!isTabs && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
|
||||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{t('notes.addNote') || 'Add Note'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
|
||||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!isTabs && <div className="mb-1 h-5" />}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
|
||||||
<FileText className="w-8 h-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
|
||||||
<LabelFilter
|
|
||||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
|
||||||
onFilterChange={(newLabels) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
|
||||||
else params.delete('labels')
|
|
||||||
router.push(`/?${params.toString()}`)
|
|
||||||
}}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
{isInbox && !isLoading && notes.length >= 2 && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setBatchOrganizationOpen(true)}
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
|
||||||
title={t('batch.organizeWithAI')}
|
|
||||||
>
|
|
||||||
<Wand2 className="h-4 w-4 text-purple-600" />
|
|
||||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isTabs && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
|
||||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{t('notes.newNote')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNoteInput && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'animate-in fade-in slide-in-from-top-4 duration-300',
|
|
||||||
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<NoteInput
|
|
||||||
onNoteCreated={handleNoteCreatedWrapper}
|
|
||||||
forceExpanded={true}
|
|
||||||
fullWidth={isTabs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FavoritesSection
|
|
||||||
pinnedNotes={pinnedNotes}
|
|
||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{notes.filter((note) => !note.isPinned).length > 0 && (
|
|
||||||
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
|
||||||
<NotesMainSection
|
|
||||||
viewMode={notesViewMode}
|
|
||||||
notes={notes.filter((note) => !note.isPinned)}
|
|
||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
currentNotebookId={searchParams.get('notebook')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
{t('notes.emptyState')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
|
||||||
|
|
||||||
{notebookSuggestion && (
|
|
||||||
<NotebookSuggestionToast
|
|
||||||
noteId={notebookSuggestion.noteId}
|
|
||||||
noteContent={notebookSuggestion.content}
|
|
||||||
onDismiss={() => setNotebookSuggestion(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{batchOrganizationOpen && (
|
|
||||||
<BatchOrganizationDialog
|
|
||||||
open={batchOrganizationOpen}
|
|
||||||
onOpenChange={setBatchOrganizationOpen}
|
|
||||||
onNotesMoved={() => router.refresh()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{autoLabelOpen && (
|
|
||||||
<AutoLabelSuggestionDialog
|
|
||||||
open={autoLabelOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setAutoLabelOpen(open)
|
|
||||||
if (!open) dismissLabelSuggestion()
|
|
||||||
}}
|
|
||||||
notebookId={suggestNotebookId}
|
|
||||||
onLabelsCreated={() => router.refresh()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editingNote && (
|
|
||||||
<NoteEditor
|
|
||||||
note={editingNote.note}
|
|
||||||
readOnly={editingNote.readOnly}
|
|
||||||
onClose={() => setEditingNote(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* Masonry Grid — CSS Grid avec tailles visibles
|
|
||||||
* Layout avec espaces minimisés via dense, drag-and-drop via @dnd-kit
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ─── Container ──────────────────────────────────── */
|
|
||||||
.masonry-container {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 8px 40px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── CSS Grid avec dense pour minimiser les trous ─ */
|
|
||||||
.masonry-css-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
grid-auto-rows: auto;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: start;
|
|
||||||
grid-auto-flow: dense;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Sortable items ─────────────────────────────── */
|
|
||||||
.masonry-sortable-item {
|
|
||||||
break-inside: avoid;
|
|
||||||
box-sizing: border-box;
|
|
||||||
will-change: transform;
|
|
||||||
transition: opacity 0.15s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Taille des notes : small=1 colonne, medium=2 colonnes, large=3 colonnes */
|
|
||||||
.masonry-sortable-item[data-size="medium"] {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masonry-sortable-item[data-size="large"] {
|
|
||||||
grid-column: span 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Note card base ─────────────────────────────── */
|
|
||||||
.note-card {
|
|
||||||
width: 100% !important;
|
|
||||||
min-width: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Drag overlay ───────────────────────────────── */
|
|
||||||
.masonry-drag-overlay {
|
|
||||||
cursor: grabbing;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25), 0 8px 16px rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 12px;
|
|
||||||
opacity: 0.95;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Mobile (< 480px) : 1 colonne ──────────────── */
|
|
||||||
@media (max-width: 479px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sur mobile tout est 1 colonne */
|
|
||||||
.masonry-sortable-item[data-size="medium"],
|
|
||||||
.masonry-sortable-item[data-size="large"] {
|
|
||||||
grid-column: span 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masonry-container {
|
|
||||||
padding: 0 4px 16px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Small tablet (480–767px) : 2 colonnes ─────── */
|
|
||||||
@media (min-width: 480px) and (max-width: 767px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sur 2 colonnes, large prend tout */
|
|
||||||
.masonry-sortable-item[data-size="large"] {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masonry-container {
|
|
||||||
padding: 0 8px 20px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tablet (768–1023px) ────────────────────────── */
|
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Desktop (1024–1279px) ─────────────────────── */
|
|
||||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Large Desktop (1280px+) ───────────────────── */
|
|
||||||
@media (min-width: 1280px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.masonry-container {
|
|
||||||
max-width: 1600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 12px 32px 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Print ──────────────────────────────────────── */
|
|
||||||
@media print {
|
|
||||||
.masonry-sortable-item {
|
|
||||||
break-inside: avoid;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Reduced motion ─────────────────────────────── */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.masonry-sortable-item {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
DragOverlay,
|
|
||||||
DragStartEvent,
|
|
||||||
PointerSensor,
|
|
||||||
TouchSensor,
|
|
||||||
closestCenter,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
arrayMove,
|
|
||||||
rectSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { Note } from '@/lib/types';
|
|
||||||
import { NoteCard } from './note-card';
|
|
||||||
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
|
||||||
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
|
||||||
import { useLanguage } from '@/lib/i18n';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import './masonry-grid.css';
|
|
||||||
|
|
||||||
// Lazy-load NoteEditor — uniquement chargé au clic
|
|
||||||
const NoteEditor = dynamic(
|
|
||||||
() => import('./note-editor').then(m => ({ default: m.NoteEditor })),
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
interface MasonryGridProps {
|
|
||||||
notes: Note[];
|
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void;
|
|
||||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Sortable Note Item
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
interface SortableNoteProps {
|
|
||||||
note: Note;
|
|
||||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
|
||||||
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
|
||||||
onDragStartNote?: (noteId: string) => void;
|
|
||||||
onDragEndNote?: () => void;
|
|
||||||
isDragging?: boolean;
|
|
||||||
isOverlay?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableNoteItem = memo(function SortableNoteItem({
|
|
||||||
note,
|
|
||||||
onEdit,
|
|
||||||
onSizeChange,
|
|
||||||
onDragStartNote,
|
|
||||||
onDragEndNote,
|
|
||||||
isDragging,
|
|
||||||
isOverlay,
|
|
||||||
}: SortableNoteProps) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging: isSortableDragging,
|
|
||||||
} = useSortable({ id: note.id });
|
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isSortableDragging && !isOverlay ? 0.3 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="masonry-sortable-item"
|
|
||||||
data-id={note.id}
|
|
||||||
data-size={note.size}
|
|
||||||
>
|
|
||||||
<NoteCard
|
|
||||||
note={note}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDragStart={onDragStartNote}
|
|
||||||
onDragEnd={onDragEndNote}
|
|
||||||
isDragging={isDragging}
|
|
||||||
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Sortable Grid Section (pinned or others)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
interface SortableGridSectionProps {
|
|
||||||
notes: Note[];
|
|
||||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
|
||||||
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
|
||||||
draggedNoteId: string | null;
|
|
||||||
onDragStartNote: (noteId: string) => void;
|
|
||||||
onDragEndNote: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableGridSection = memo(function SortableGridSection({
|
|
||||||
notes,
|
|
||||||
onEdit,
|
|
||||||
onSizeChange,
|
|
||||||
draggedNoteId,
|
|
||||||
onDragStartNote,
|
|
||||||
onDragEndNote,
|
|
||||||
}: SortableGridSectionProps) {
|
|
||||||
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
|
||||||
<div className="masonry-css-grid">
|
|
||||||
{notes.map(note => (
|
|
||||||
<SortableNoteItem
|
|
||||||
key={note.id}
|
|
||||||
note={note}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onSizeChange={onSizeChange}
|
|
||||||
onDragStartNote={onDragStartNote}
|
|
||||||
onDragEndNote={onDragEndNote}
|
|
||||||
isDragging={draggedNoteId === note.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Main MasonryGrid component
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
export function MasonryGrid({ notes, onEdit, onSizeChange }: MasonryGridProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
|
||||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
|
||||||
|
|
||||||
// Local notes state for optimistic size/order updates
|
|
||||||
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalNotes(prev => {
|
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
|
||||||
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
||||||
})
|
|
||||||
}, [notes]);
|
|
||||||
|
|
||||||
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
|
|
||||||
const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]);
|
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
const activeNote = useMemo(
|
|
||||||
() => localNotes.find(n => n.id === activeId) ?? null,
|
|
||||||
[localNotes, activeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
|
||||||
if (onEdit) {
|
|
||||||
onEdit(note, readOnly);
|
|
||||||
} else {
|
|
||||||
setEditingNote({ note, readOnly });
|
|
||||||
}
|
|
||||||
}, [onEdit]);
|
|
||||||
|
|
||||||
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
|
|
||||||
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
|
|
||||||
onSizeChange?.(noteId, newSize);
|
|
||||||
}, [onSizeChange]);
|
|
||||||
|
|
||||||
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: { distance: 8 }, // Évite les activations accidentelles
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const localNotesRef = useRef<Note[]>(localNotes)
|
|
||||||
useEffect(() => {
|
|
||||||
localNotesRef.current = localNotes
|
|
||||||
}, [localNotes])
|
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
||||||
setActiveId(event.active.id as string);
|
|
||||||
startDrag(event.active.id as string);
|
|
||||||
}, [startDrag]);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveId(null);
|
|
||||||
endDrag();
|
|
||||||
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
const reordered = arrayMove(
|
|
||||||
localNotesRef.current,
|
|
||||||
localNotesRef.current.findIndex(n => n.id === active.id),
|
|
||||||
localNotesRef.current.findIndex(n => n.id === over.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (reordered.length === 0) return;
|
|
||||||
|
|
||||||
setLocalNotes(reordered);
|
|
||||||
// Persist order outside of setState to avoid "setState in render" warning
|
|
||||||
const ids = reordered.map(n => n.id);
|
|
||||||
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
|
||||||
console.error('Failed to persist order:', err);
|
|
||||||
});
|
|
||||||
}, [endDrag]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
id="masonry-dnd"
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div className="masonry-container">
|
|
||||||
{pinnedNotes.length > 0 && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
|
||||||
{t('notes.pinned')}
|
|
||||||
</h2>
|
|
||||||
<SortableGridSection
|
|
||||||
notes={pinnedNotes}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
draggedNoteId={draggedNoteId}
|
|
||||||
onDragStartNote={startDrag}
|
|
||||||
onDragEndNote={endDrag}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{othersNotes.length > 0 && (
|
|
||||||
<div>
|
|
||||||
{pinnedNotes.length > 0 && (
|
|
||||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
|
||||||
{t('notes.others')}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<SortableGridSection
|
|
||||||
notes={othersNotes}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
draggedNoteId={draggedNoteId}
|
|
||||||
onDragStartNote={startDrag}
|
|
||||||
onDragEndNote={endDrag}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DragOverlay — montre une copie flottante pendant le drag */}
|
|
||||||
<DragOverlay>
|
|
||||||
{activeNote ? (
|
|
||||||
<div className="masonry-sortable-item masonry-drag-overlay" data-size={activeNote.size}>
|
|
||||||
<NoteCard
|
|
||||||
note={activeNote}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
isDragging={true}
|
|
||||||
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
|
|
||||||
{editingNote && (
|
|
||||||
<NoteEditor
|
|
||||||
note={editingNote.note}
|
|
||||||
readOnly={editingNote.readOnly}
|
|
||||||
onClose={() => setEditingNote(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,677 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
|
|
||||||
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
|
|
||||||
import { useSession } from 'next-auth/react'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
|
||||||
import { enUS } from 'date-fns/locale/en-US'
|
|
||||||
import { fr } from 'date-fns/locale/fr'
|
|
||||||
import { es } from 'date-fns/locale/es'
|
|
||||||
import { de } from 'date-fns/locale/de'
|
|
||||||
import { faIR } from 'date-fns/locale/fa-IR'
|
|
||||||
import { it } from 'date-fns/locale/it'
|
|
||||||
import { pt } from 'date-fns/locale/pt'
|
|
||||||
import { ru } from 'date-fns/locale/ru'
|
|
||||||
import { zhCN } from 'date-fns/locale/zh-CN'
|
|
||||||
import { ja } from 'date-fns/locale/ja'
|
|
||||||
import { ko } from 'date-fns/locale/ko'
|
|
||||||
import { ar } from 'date-fns/locale/ar'
|
|
||||||
import { hi } from 'date-fns/locale/hi'
|
|
||||||
import { nl } from 'date-fns/locale/nl'
|
|
||||||
import { pl } from 'date-fns/locale/pl'
|
|
||||||
import { MarkdownContent } from './markdown-content'
|
|
||||||
import { LabelBadge } from './label-badge'
|
|
||||||
import { NoteImages } from './note-images'
|
|
||||||
import { NoteChecklist } from './note-checklist'
|
|
||||||
import { NoteActions } from './note-actions'
|
|
||||||
import { CollaboratorDialog } from './collaborator-dialog'
|
|
||||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
|
||||||
import { ConnectionsBadge } from './connections-badge'
|
|
||||||
import { ConnectionsOverlay } from './connections-overlay'
|
|
||||||
import { ComparisonModal } from './comparison-modal'
|
|
||||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
// Mapping of supported languages to date-fns locales
|
|
||||||
const localeMap: Record<string, Locale> = {
|
|
||||||
en: enUS,
|
|
||||||
fr: fr,
|
|
||||||
es: es,
|
|
||||||
de: de,
|
|
||||||
fa: faIR,
|
|
||||||
it: it,
|
|
||||||
pt: pt,
|
|
||||||
ru: ru,
|
|
||||||
zh: zhCN,
|
|
||||||
ja: ja,
|
|
||||||
ko: ko,
|
|
||||||
ar: ar,
|
|
||||||
hi: hi,
|
|
||||||
nl: nl,
|
|
||||||
pl: pl,
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateLocale(language: string): Locale {
|
|
||||||
return localeMap[language] || enUS
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map icon names to lucide-react components
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
|
||||||
'folder': Folder,
|
|
||||||
'briefcase': Briefcase,
|
|
||||||
'document': FileText,
|
|
||||||
'lightning': Zap,
|
|
||||||
'chart': BarChart3,
|
|
||||||
'globe': Globe,
|
|
||||||
'sparkle': Sparkles,
|
|
||||||
'book': Book,
|
|
||||||
'heart': Heart,
|
|
||||||
'crown': Crown,
|
|
||||||
'music': Music,
|
|
||||||
'building': Building2,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get icon component by name
|
|
||||||
function getNotebookIcon(iconName: string): LucideIcon {
|
|
||||||
const IconComponent = ICON_MAP[iconName] || Folder
|
|
||||||
return IconComponent
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NoteCardProps {
|
|
||||||
note: Note
|
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
|
||||||
isDragging?: boolean
|
|
||||||
isDragOver?: boolean
|
|
||||||
onDragStart?: (noteId: string) => void
|
|
||||||
onDragEnd?: () => void
|
|
||||||
onResize?: () => void
|
|
||||||
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get initials from name
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
if (!name) return '??'
|
|
||||||
const trimmedName = name.trim()
|
|
||||||
const parts = trimmedName.split(' ')
|
|
||||||
if (parts.length === 1) {
|
|
||||||
return trimmedName.substring(0, 2).toUpperCase()
|
|
||||||
}
|
|
||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get avatar color based on name hash
|
|
||||||
function getAvatarColor(name: string): string {
|
|
||||||
const colors = [
|
|
||||||
'bg-primary',
|
|
||||||
'bg-purple-500',
|
|
||||||
'bg-green-500',
|
|
||||||
'bg-orange-500',
|
|
||||||
'bg-pink-500',
|
|
||||||
'bg-teal-500',
|
|
||||||
'bg-red-500',
|
|
||||||
'bg-indigo-500',
|
|
||||||
]
|
|
||||||
|
|
||||||
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
|
||||||
return colors[hash % colors.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NoteCard = memo(function NoteCard({
|
|
||||||
note,
|
|
||||||
onEdit,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
isDragging,
|
|
||||||
onResize,
|
|
||||||
onSizeChange
|
|
||||||
}: NoteCardProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { refreshLabels } = useLabels()
|
|
||||||
const { data: session } = useSession()
|
|
||||||
const { t, language } = useLanguage()
|
|
||||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
|
||||||
const [, startTransition] = useTransition()
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
||||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
|
||||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
|
||||||
const [owner, setOwner] = useState<any>(null)
|
|
||||||
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
|
|
||||||
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
|
|
||||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
|
||||||
|
|
||||||
// Move note to a notebook
|
|
||||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
|
||||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
|
||||||
setShowNotebookMenu(false)
|
|
||||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistic UI state for instant feedback
|
|
||||||
const [optimisticNote, addOptimisticNote] = useOptimistic(
|
|
||||||
note,
|
|
||||||
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
|
|
||||||
)
|
|
||||||
|
|
||||||
// Local color state so color persists after transition ends
|
|
||||||
const [localColor, setLocalColor] = useState(note.color)
|
|
||||||
|
|
||||||
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
|
|
||||||
|
|
||||||
// Check if this note is currently open in the editor
|
|
||||||
const isNoteOpenInEditor = searchParams.get('note') === note.id
|
|
||||||
|
|
||||||
// Only fetch comparison notes when we have IDs to compare
|
|
||||||
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
|
|
||||||
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentUserId = session?.user?.id
|
|
||||||
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
|
|
||||||
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
|
|
||||||
const isOwner = currentUserId && note.userId && currentUserId === note.userId
|
|
||||||
|
|
||||||
// Load collaborators only for shared notes (not owned by current user)
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip API call for notes owned by current user — no need to fetch collaborators
|
|
||||||
if (!isSharedNote) {
|
|
||||||
// For own notes, set owner to current user
|
|
||||||
if (currentUserId && session?.user) {
|
|
||||||
setOwner({
|
|
||||||
id: currentUserId,
|
|
||||||
name: session.user.name,
|
|
||||||
email: session.user.email,
|
|
||||||
image: session.user.image,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let isMounted = true
|
|
||||||
|
|
||||||
const loadCollaborators = async () => {
|
|
||||||
if (note.userId && isMounted) {
|
|
||||||
try {
|
|
||||||
const users = await getNoteAllUsers(note.id)
|
|
||||||
if (isMounted) {
|
|
||||||
setCollaborators(users)
|
|
||||||
if (users.length > 0) {
|
|
||||||
setOwner(users[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load collaborators:', error)
|
|
||||||
if (isMounted) {
|
|
||||||
setCollaborators([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadCollaborators()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false
|
|
||||||
}
|
|
||||||
}, [note.id, note.userId, isSharedNote, currentUserId, session?.user])
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
setIsDeleting(true)
|
|
||||||
try {
|
|
||||||
await deleteNote(note.id)
|
|
||||||
await refreshLabels()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete note:', error)
|
|
||||||
setIsDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTogglePin = async () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ isPinned: !note.isPinned })
|
|
||||||
await togglePin(note.id, !note.isPinned)
|
|
||||||
|
|
||||||
if (!note.isPinned) {
|
|
||||||
toast.success(t('notes.pinned') || 'Note pinned')
|
|
||||||
} else {
|
|
||||||
toast.info(t('notes.unpinned') || 'Note unpinned')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleArchive = async () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ isArchived: !note.isArchived })
|
|
||||||
await toggleArchive(note.id, !note.isArchived)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleColorChange = async (color: string) => {
|
|
||||||
setLocalColor(color) // instant visual update, survives transition
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ color })
|
|
||||||
await updateNote(note.id, { color }, { skipRevalidation: false })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSizeChange = (size: 'small' | 'medium' | 'large') => {
|
|
||||||
// Notifier le parent immédiatement (hors transition) — c'est lui
|
|
||||||
// qui détient la source de vérité via localNotes
|
|
||||||
onSizeChange?.(size)
|
|
||||||
onResize?.()
|
|
||||||
|
|
||||||
// Persister en arrière-plan
|
|
||||||
updateSize(note.id, size).catch(err =>
|
|
||||||
console.error('Failed to update note size:', err)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckItem = async (checkItemId: string) => {
|
|
||||||
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
|
|
||||||
const updatedItems = note.checkItems.map(item =>
|
|
||||||
item.id === checkItemId ? { ...item, checked: !item.checked } : item
|
|
||||||
)
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ checkItems: updatedItems })
|
|
||||||
await updateNote(note.id, { checkItems: updatedItems })
|
|
||||||
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLeaveShare = async () => {
|
|
||||||
if (confirm(t('notes.confirmLeaveShare'))) {
|
|
||||||
try {
|
|
||||||
await leaveSharedNote(note.id)
|
|
||||||
setIsDeleting(true) // Hide the note from view
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to leave share:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation() // Prevent opening the note editor
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ autoGenerated: null })
|
|
||||||
await removeFusedBadge(note.id)
|
|
||||||
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDeleting) return null
|
|
||||||
|
|
||||||
const getMinHeight = (size?: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'medium': return '350px'
|
|
||||||
case 'large': return '500px'
|
|
||||||
default: return '150px' // small
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
data-testid="note-card"
|
|
||||||
data-draggable="true"
|
|
||||||
data-note-id={note.id}
|
|
||||||
data-size={optimisticNote.size}
|
|
||||||
style={{ minHeight: getMinHeight(optimisticNote.size) }}
|
|
||||||
draggable={true}
|
|
||||||
onDragStart={(e) => {
|
|
||||||
e.dataTransfer.setData('text/plain', note.id)
|
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
|
||||||
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
|
|
||||||
onDragStart?.(note.id)
|
|
||||||
}}
|
|
||||||
onDragEnd={() => onDragEnd?.()}
|
|
||||||
className={cn(
|
|
||||||
'note-card group relative rounded-2xl overflow-hidden p-5 border shadow-sm',
|
|
||||||
'transition-all duration-200 ease-out',
|
|
||||||
'hover:shadow-xl hover:-translate-y-1',
|
|
||||||
colorClasses.bg,
|
|
||||||
colorClasses.card,
|
|
||||||
colorClasses.hover,
|
|
||||||
colorClasses.hover,
|
|
||||||
isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
// Only trigger edit if not clicking on buttons
|
|
||||||
const target = e.target as HTMLElement
|
|
||||||
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) {
|
|
||||||
// For shared notes, pass readOnly flag
|
|
||||||
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Drag Handle - Only visible on mobile/touch devices */}
|
|
||||||
<div
|
|
||||||
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
|
||||||
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
|
|
||||||
title={t('notes.dragToReorder') || 'Drag to reorder'}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Move to Notebook Dropdown Menu */}
|
|
||||||
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
|
||||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
|
|
||||||
title={t('notebookSuggestion.moveToNotebook')}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
||||||
{t('notebookSuggestion.moveToNotebook')}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
|
|
||||||
<StickyNote className="h-4 w-4 mr-2" />
|
|
||||||
{t('notebookSuggestion.generalNotes')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{notebooks.map((notebook: any) => {
|
|
||||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={notebook.id}
|
|
||||||
onClick={() => handleMoveToNotebook(notebook.id)}
|
|
||||||
>
|
|
||||||
<NotebookIcon className="h-4 w-4 mr-2" />
|
|
||||||
{notebook.name}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pin Button - Visible on hover or if pinned */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
data-testid="pin-button"
|
|
||||||
className={cn(
|
|
||||||
"absolute top-2 right-12 z-20 min-h-[44px] min-w-[44px] h-8 w-8 p-0 rounded-md transition-opacity",
|
|
||||||
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleTogglePin()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pin
|
|
||||||
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Reminder Icon - Move slightly if pin button is there */}
|
|
||||||
{note.reminder && new Date(note.reminder) > new Date() && (
|
|
||||||
<Bell
|
|
||||||
className="absolute top-3 right-10 h-4 w-4 text-primary"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Memory Echo Badges - Fusion + Connections (BEFORE Title) */}
|
|
||||||
<div className="flex flex-wrap gap-1 mb-2">
|
|
||||||
{/* Fusion Badge with remove button */}
|
|
||||||
{note.autoGenerated && (
|
|
||||||
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative">
|
|
||||||
<Link2 className="h-2.5 w-2.5" />
|
|
||||||
{t('memoryEcho.fused')}
|
|
||||||
<button
|
|
||||||
onClick={handleRemoveFusedBadge}
|
|
||||||
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
|
||||||
title={t('notes.remove') || 'Remove'}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-2.5 w-2.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Connections Badge */}
|
|
||||||
<ConnectionsBadge
|
|
||||||
noteId={note.id}
|
|
||||||
onClick={() => {
|
|
||||||
// Only open overlay if note is NOT open in editor
|
|
||||||
// (to avoid having 2 Dialogs with 2 close buttons)
|
|
||||||
if (!isNoteOpenInEditor) {
|
|
||||||
setShowConnectionsOverlay(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
{optimisticNote.title && (
|
|
||||||
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
|
|
||||||
{optimisticNote.title}
|
|
||||||
</h3>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search Match Type Badge */}
|
|
||||||
{optimisticNote.matchType && (
|
|
||||||
<Badge
|
|
||||||
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
|
|
||||||
className={cn(
|
|
||||||
'mb-2 text-xs',
|
|
||||||
optimisticNote.matchType === 'exact'
|
|
||||||
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
|
|
||||||
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Shared badge */}
|
|
||||||
{isSharedNote && owner && (
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
|
|
||||||
{t('notes.sharedBy')} {owner.name || owner.email}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleLeaveShare()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogOut className="h-3 w-3 mr-1" />
|
|
||||||
{t('notes.leaveShare')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Images Component */}
|
|
||||||
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
|
|
||||||
|
|
||||||
{/* Link Previews */}
|
|
||||||
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-2 mb-2">
|
|
||||||
{optimisticNote.links.map((link, idx) => (
|
|
||||||
<a
|
|
||||||
key={idx}
|
|
||||||
href={link.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{link.imageUrl && (
|
|
||||||
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
|
||||||
)}
|
|
||||||
<div className="p-2">
|
|
||||||
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
|
|
||||||
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
|
|
||||||
<span className="text-[10px] text-primary mt-1 block">
|
|
||||||
{new URL(link.url).hostname}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{optimisticNote.type === 'text' ? (
|
|
||||||
<div className="text-sm text-foreground line-clamp-10">
|
|
||||||
<MarkdownContent content={optimisticNote.content} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<NoteChecklist
|
|
||||||
items={optimisticNote.checkItems || []}
|
|
||||||
onToggleItem={handleCheckItem}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Labels - using shared LabelBadge component */}
|
|
||||||
{optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
|
||||||
{optimisticNote.labels.map((label) => (
|
|
||||||
<LabelBadge key={label} label={label} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer with Date only */}
|
|
||||||
<div className="mt-3 flex items-center justify-end">
|
|
||||||
{/* Creation Date */}
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Owner Avatar - Aligned with action buttons at bottom */}
|
|
||||||
{owner && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute bottom-2 left-2 z-20",
|
|
||||||
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
|
|
||||||
getAvatarColor(owner.name || owner.email || 'Unknown')
|
|
||||||
)}
|
|
||||||
title={owner.name || owner.email || 'Unknown'}
|
|
||||||
>
|
|
||||||
{getInitials(owner.name || owner.email || '??')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Bar Component - Always show for now to fix regression */}
|
|
||||||
{true && (
|
|
||||||
<NoteActions
|
|
||||||
isPinned={optimisticNote.isPinned}
|
|
||||||
isArchived={optimisticNote.isArchived}
|
|
||||||
currentColor={optimisticNote.color}
|
|
||||||
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
|
||||||
onTogglePin={handleTogglePin}
|
|
||||||
onToggleArchive={handleToggleArchive}
|
|
||||||
onColorChange={handleColorChange}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
onDelete={() => setShowDeleteDialog(true)}
|
|
||||||
onShareCollaborators={() => setShowCollaboratorDialog(true)}
|
|
||||||
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collaborator Dialog */}
|
|
||||||
{currentUserId && note.userId && (
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<CollaboratorDialog
|
|
||||||
open={showCollaboratorDialog}
|
|
||||||
onOpenChange={setShowCollaboratorDialog}
|
|
||||||
noteId={note.id}
|
|
||||||
noteOwnerId={note.userId}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Connections Overlay */}
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<ConnectionsOverlay
|
|
||||||
isOpen={showConnectionsOverlay}
|
|
||||||
onClose={() => setShowConnectionsOverlay(false)}
|
|
||||||
noteId={note.id}
|
|
||||||
onOpenNote={(noteId) => {
|
|
||||||
// Find the note and open it
|
|
||||||
onEdit?.(note, false)
|
|
||||||
}}
|
|
||||||
onCompareNotes={(noteIds) => {
|
|
||||||
setComparisonNotes(noteIds)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comparison Modal */}
|
|
||||||
{comparisonNotes && comparisonNotesData.length > 0 && (
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<ComparisonModal
|
|
||||||
isOpen={!!comparisonNotes}
|
|
||||||
onClose={() => setComparisonNotes(null)}
|
|
||||||
notes={comparisonNotesData}
|
|
||||||
onOpenNote={(noteId) => {
|
|
||||||
const foundNote = comparisonNotesData.find(n => n.id === noteId)
|
|
||||||
if (foundNote) {
|
|
||||||
onEdit?.(foundNote, false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction variant="destructive" onClick={handleDelete}>
|
|
||||||
{t('notes.delete') || 'Delete'}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { Note } from '@/lib/types'
|
|
||||||
import { NotesTabsView } from '@/components/notes-tabs-view'
|
|
||||||
|
|
||||||
const MasonryGridLazy = dynamic(
|
|
||||||
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div
|
|
||||||
className="min-h-[200px] rounded-xl border border-dashed border-muted-foreground/20 bg-muted/30 animate-pulse"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export type NotesViewMode = 'masonry' | 'tabs'
|
|
||||||
|
|
||||||
interface NotesMainSectionProps {
|
|
||||||
notes: Note[]
|
|
||||||
viewMode: NotesViewMode
|
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
|
||||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
|
||||||
currentNotebookId?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) {
|
|
||||||
if (viewMode === 'tabs') {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
|
||||||
<NotesTabsView notes={notes} onEdit={onEdit} currentNotebookId={currentNotebookId} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-testid="notes-grid">
|
|
||||||
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
60
memento-note/.win11-prompt.txt
Normal file
60
memento-note/.win11-prompt.txt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
Redesign the entire UI of this application to look like Windows 11 Fluent Design. This is a UI-only redesign - do NOT change any business logic, API routes, database schema, or functionality. Only modify visual styling (CSS classes, Tailwind utilities, color values, border-radius, shadows, etc.).
|
||||||
|
|
||||||
|
Changes needed:
|
||||||
|
|
||||||
|
1. globals.css - Update theme:
|
||||||
|
- Primary color: #0078D4 (Windows 11 blue)
|
||||||
|
- Add --color-win11-accent: #0078D4 and shades (#106EBE, #005A9E, #003D6B)
|
||||||
|
- Background: light #f3f3f3, dark #202020
|
||||||
|
- Rounded corners: 8px cards, 4px small elements
|
||||||
|
- Shadows: subtle layered shadows like Win11 (0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.08))
|
||||||
|
- Smooth transitions: 200-300ms ease
|
||||||
|
- Add acrylic utility: .acrylic { backdrop-filter: blur(20px) saturate(180%); background: rgba(255,255,255,0.7); }
|
||||||
|
- Add .acrylic-dark for dark mode
|
||||||
|
|
||||||
|
2. app/(main)/layout.tsx - Main layout:
|
||||||
|
- Background: #f3f3f3 (light) / #202020 (dark)
|
||||||
|
- Sidebar: add bg-white/80 dark:bg-[#2d2d2d]/80 backdrop-blur-xl rounded-e-lg
|
||||||
|
- Content area: clean with subtle padding
|
||||||
|
|
||||||
|
3. components/sidebar.tsx - Windows 11 navigation:
|
||||||
|
- Semi-transparent bg with backdrop-blur-xl
|
||||||
|
- Nav items: rounded-lg hover states with subtle bg-slate-100 dark:bg-slate-800
|
||||||
|
- Active item: bg-blue-50 dark:bg-blue-900/30 text-[#0078D4] with left border-2 indicator
|
||||||
|
- Smooth collapse animation with transition-all duration-300
|
||||||
|
|
||||||
|
4. components/header.tsx - Windows 11 title bar:
|
||||||
|
- Clean minimal, height h-12
|
||||||
|
- Rounded search input (rounded-full or rounded-lg) like Win11 search
|
||||||
|
- Subtle bottom border
|
||||||
|
|
||||||
|
5. components/note-card.tsx - Win11 cards:
|
||||||
|
- rounded-lg (8px)
|
||||||
|
- border border-slate-200 dark:border-slate-700
|
||||||
|
- hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200
|
||||||
|
- Clean white bg
|
||||||
|
|
||||||
|
6. components/home-client.tsx - Widget layout:
|
||||||
|
- Rounded containers (rounded-xl) for each section
|
||||||
|
- Subtle hover:shadow-md transition
|
||||||
|
|
||||||
|
7. components/note-editor.tsx & rich-text-editor.tsx - Win11 editor:
|
||||||
|
- Rounded toolbar buttons (rounded-md)
|
||||||
|
- Subtle separators between toolbar groups
|
||||||
|
- Clean focused state
|
||||||
|
|
||||||
|
8. components/ui/button.tsx - Win11 buttons:
|
||||||
|
- rounded-md (6px)
|
||||||
|
- Primary: bg-[#0078D4] hover:bg-[#106EBE] text-white
|
||||||
|
- Secondary: bg-slate-100 hover:bg-slate-200 border border-slate-300
|
||||||
|
- Subtle active states
|
||||||
|
|
||||||
|
9. components/ui/card.tsx - Win11 card:
|
||||||
|
- rounded-lg border border-slate-200/60
|
||||||
|
- hover:shadow-md transition-shadow duration-200
|
||||||
|
|
||||||
|
10. components/ui/input.tsx - Win11 input:
|
||||||
|
- rounded-md (6px)
|
||||||
|
- border-slate-300 focus:border-[#0078D4] focus:ring-1 focus:ring-[#0078D4]/30
|
||||||
|
|
||||||
|
Read each file first, understand its structure, then make surgical edits. After all changes, run npm run build to verify the build passes. Fix any build errors if any.
|
||||||
@@ -20,6 +20,10 @@ RUN npx prisma generate
|
|||||||
FROM node:22-bookworm-slim AS builder
|
FROM node:22-bookworm-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
|
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info, Shield, Brain, MessageSquare, Search } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
@@ -65,14 +64,14 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`✅ ${t('admin.aiTest.testSuccessToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
|
`${t('admin.aiTest.testSuccessToast', { type: type === 'tags' ? 'Tags' : type === 'chat' ? 'Chat' : 'Embeddings' })}`,
|
||||||
{
|
{
|
||||||
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
|
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
`❌ ${t('admin.aiTest.testFailedToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
|
`${t('admin.aiTest.testFailedToast', { type: type === 'tags' ? 'Tags' : type === 'chat' ? 'Chat' : 'Embeddings' })}`,
|
||||||
{
|
{
|
||||||
description: data.error || 'Unknown error'
|
description: data.error || 'Unknown error'
|
||||||
}
|
}
|
||||||
@@ -93,7 +92,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getProviderInfo = () => {
|
const getProviderInfo = () => {
|
||||||
if (!config) return { provider: t('admin.aiTest.testing'), model: t('admin.aiTest.testing') }
|
if (!config) return { provider: '...', model: '...' }
|
||||||
|
|
||||||
if (type === 'tags') {
|
if (type === 'tags') {
|
||||||
return {
|
return {
|
||||||
@@ -116,127 +115,164 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
const providerInfo = getProviderInfo()
|
const providerInfo = getProviderInfo()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
{/* Provider Info */}
|
{/* Current Config Summary */}
|
||||||
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
|
<div className="rounded-2xl border border-border bg-muted/20 overflow-hidden shadow-inner">
|
||||||
<div className="flex items-center justify-between">
|
<div className="px-4 py-2.5 border-b border-border/50 bg-muted/40 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{t('admin.aiTest.provider')}</span>
|
{type === 'tags' ? <Brain className="h-4 w-4 text-primary" /> :
|
||||||
<Badge variant="outline" className="text-xs">
|
type === 'embeddings' ? <Search className="h-4 w-4 text-green-600" /> :
|
||||||
{providerInfo.provider.toUpperCase()}
|
<MessageSquare className="h-4 w-4 text-violet-600" />}
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('admin.aiTest.provider')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground truncate">{t('admin.aiTest.provider')}</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs py-1 h-7 px-4 bg-background border-border rounded-xl shadow-sm">
|
||||||
|
{providerInfo.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-6">
|
||||||
<span className="text-sm font-medium">{t('admin.aiTest.model')}</span>
|
<span className="text-sm font-bold text-muted-foreground truncate">{t('admin.aiTest.model')}</span>
|
||||||
<span className="text-sm text-muted-foreground font-mono">
|
<span className="text-xs font-mono font-bold text-foreground truncate bg-background/80 px-3 py-1 rounded-lg border border-border/50" title={providerInfo.model}>
|
||||||
{providerInfo.model}
|
{providerInfo.model}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test Button */}
|
{/* Run Test Action */}
|
||||||
<Button
|
<Button
|
||||||
onClick={runTest}
|
onClick={runTest}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full"
|
className={`w-full shadow-lg py-7 h-auto rounded-2xl transition-all duration-300 relative overflow-hidden group/btn ${
|
||||||
variant={result?.success ? 'default' : result?.success === false ? 'destructive' : 'default'}
|
isLoading ? 'opacity-90' : 'hover:scale-[1.02] active:scale-[0.98] hover:shadow-xl'
|
||||||
|
} ${
|
||||||
|
result?.success ? 'bg-primary' : result?.success === false ? 'bg-destructive hover:bg-destructive/90' : 'bg-primary hover:bg-primary/90'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/10 to-white/0 -translate-x-full group-hover/btn:animate-[shimmer_2s_infinite] pointer-events-none" />
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
{t('admin.aiTest.testing')}
|
<span className="text-xs font-bold tracking-widest uppercase">{t('admin.aiTest.testing')}...</span>
|
||||||
</>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center justify-center gap-4">
|
||||||
<Zap className="mr-2 h-4 w-4" />
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center transition-all ${
|
||||||
|
result?.success ? 'bg-white/20' : 'bg-white/20 group-hover/btn:scale-110'
|
||||||
|
}`}>
|
||||||
|
<Zap className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="font-black tracking-tight text-lg py-1">
|
||||||
{t('admin.aiTest.runTest')}
|
{t('admin.aiTest.runTest')}
|
||||||
</>
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Result Display */}
|
||||||
{result && (
|
{result && (
|
||||||
<Card className={result.success ? 'border-green-200 dark:border-green-900' : 'border-red-200 dark:border-red-900'}>
|
<div className={`rounded-xl border transition-all duration-300 animate-in fade-in slide-in-from-top-2 ${
|
||||||
<CardContent className="pt-6">
|
result.success
|
||||||
{/* Status */}
|
? 'bg-green-500/[0.03] border-green-500/20 shadow-green-500/[0.02] shadow-lg'
|
||||||
<div className="flex items-center gap-2 mb-4">
|
: 'bg-destructive/[0.03] border-destructive/20 shadow-destructive/[0.02] shadow-lg'
|
||||||
|
}`}>
|
||||||
|
<div className="p-5 space-y-5">
|
||||||
|
{/* Status Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{result.success ? (
|
{result.success ? (
|
||||||
<>
|
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-600">
|
||||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
<span className="font-semibold text-green-600 dark:text-green-400">{t('admin.aiTest.testPassed')}</span>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive">
|
||||||
<XCircle className="h-5 w-5 text-red-600" />
|
<XCircle className="h-5 w-5" />
|
||||||
<span className="font-semibold text-red-600 dark:text-red-400">{t('admin.aiTest.testFailed')}</span>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
<span className={`font-bold tracking-tight ${result.success ? 'text-green-700 dark:text-green-400' : 'text-destructive'}`}>
|
||||||
|
{result.success ? t('admin.aiTest.testPassed') : t('admin.aiTest.testFailed')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Response Time */}
|
|
||||||
{result.responseTime && (
|
{result.responseTime && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
<div className="flex items-center gap-1.5 px-2 py-1 bg-background/50 rounded-md border border-border/50 text-[10px] font-bold text-muted-foreground">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-3 w-3" />
|
||||||
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
|
{result.responseTime}ms
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tags Results */}
|
{/* Response Content - Tags */}
|
||||||
{type === 'tags' && result.success && result.tags && (
|
{type === 'tags' && result.success && result.tags && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 pt-3 border-t border-border/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground uppercase tracking-widest">
|
||||||
<Info className="h-4 w-4 text-primary" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
<span className="text-sm font-medium">{t('admin.aiTest.generatedTags')}</span>
|
{t('admin.aiTest.generatedTags')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{result.tags.map((tag, idx) => (
|
{result.tags.map((tag, idx) => (
|
||||||
<Badge
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
variant="secondary"
|
className="group relative flex items-center gap-2 px-3 py-1.5 bg-background border border-border rounded-lg text-sm font-medium hover:border-primary/30 hover:bg-primary/[0.02] transition-colors"
|
||||||
className="text-sm"
|
|
||||||
>
|
>
|
||||||
{tag.tag}
|
<span className="text-foreground">{tag.tag}</span>
|
||||||
<span className="ml-1 text-xs opacity-70">
|
<span className="text-[10px] text-muted-foreground bg-muted px-1 rounded font-mono">
|
||||||
({Math.round(tag.confidence * 100)}%)
|
{Math.round(tag.confidence * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</Badge>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat Response */}
|
{/* Response Content - Chat */}
|
||||||
{type === 'chat' && result.success && result.chatResponse && (
|
{type === 'chat' && result.success && result.chatResponse && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 pt-3 border-t border-border/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground uppercase tracking-widest">
|
||||||
<Info className="h-4 w-4 text-violet-600" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
<span className="text-sm font-medium">Réponse du modèle</span>
|
Modèle Réponse
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
<div className="p-4 bg-background/50 border border-border/50 rounded-xl relative">
|
||||||
<p className="text-sm italic">"{result.chatResponse}"</p>
|
<div className="absolute -left-1.5 top-4 w-3 h-3 bg-background border-l border-t border-border/50 rotate-[-45deg] rounded-sm" />
|
||||||
|
<p className="text-sm leading-relaxed text-foreground italic">
|
||||||
|
"{result.chatResponse}"
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Embeddings Results */}
|
{/* Response Content - Embeddings */}
|
||||||
{type === 'embeddings' && result.success && result.embeddingLength && (
|
{type === 'embeddings' && result.success && result.embeddingLength && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4 pt-3 border-t border-border/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Info className="h-4 w-4 text-green-600" />
|
<div className="bg-background/50 border border-border/50 rounded-xl p-4 text-center">
|
||||||
<span className="text-sm font-medium">{t('admin.aiTest.embeddingDimensions')}</span>
|
<div className="text-2xl font-black tracking-tighter text-foreground">
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-center">
|
|
||||||
{result.embeddingLength}
|
{result.embeddingLength}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-center text-muted-foreground mt-1">
|
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-1">
|
||||||
{t('admin.aiTest.vectorDimensions')}
|
{t('admin.aiTest.vectorDimensions')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-background/50 border border-border/50 rounded-xl p-4 text-center flex flex-col justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<span className="text-xs font-bold text-foreground">Active Vector</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{result.firstValues && result.firstValues.length > 0 && (
|
{result.firstValues && result.firstValues.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<span className="text-xs font-medium">{t('admin.aiTest.first5Values')}</span>
|
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
<div className="p-2 bg-muted rounded font-mono text-xs">
|
{t('admin.aiTest.first5Values')}
|
||||||
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
|
</div>
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg font-mono text-[11px] leading-none flex justify-between overflow-x-auto whitespace-nowrap gap-2 scrollbar-hide border border-border/50">
|
||||||
|
{result.firstValues.slice(0, 5).map((v, i) => (
|
||||||
|
<span key={i} className="text-foreground tabular-nums">
|
||||||
|
{v.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -245,32 +281,30 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
|
|
||||||
{/* Error Details */}
|
{/* Error Details */}
|
||||||
{!result.success && result.error && (
|
{!result.success && result.error && (
|
||||||
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
|
<div className="space-y-3 pt-3 border-t border-border/10">
|
||||||
<p className="text-sm font-medium text-red-900 dark:text-red-100">{t('admin.aiTest.error')}</p>
|
<div className="p-4 bg-destructive/[0.05] border border-destructive/20 rounded-xl">
|
||||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Info className="h-4 w-4 text-destructive" />
|
||||||
|
<span className="text-xs font-bold text-destructive uppercase tracking-widest">Détails de l'erreur</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-destructive font-medium leading-relaxed">{result.error}</p>
|
||||||
|
|
||||||
{result.details && (
|
{result.details && (
|
||||||
<details className="mt-2">
|
<details className="mt-4 group">
|
||||||
<summary className="text-xs cursor-pointer text-red-600 dark:text-red-400">
|
<summary className="text-[10px] font-bold uppercase tracking-widest cursor-pointer text-muted-foreground hover:text-destructive transition-colors">
|
||||||
{t('admin.aiTest.technicalDetails')}
|
{t('admin.aiTest.technicalDetails')}
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 text-xs overflow-auto p-2 bg-red-100 dark:bg-red-900/30 rounded">
|
<div className="mt-2 p-3 bg-black/5 dark:bg-black/20 rounded-lg overflow-x-auto border border-border/50">
|
||||||
|
<pre className="text-[10px] font-mono text-muted-foreground leading-tight">
|
||||||
{JSON.stringify(result.details, null, 2)}
|
{JSON.stringify(result.details, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
{t('admin.aiTest.testingType', { type: type === 'tags' ? 'tags generation' : 'embeddings' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ArrowLeft, TestTube } from 'lucide-react'
|
import { ArrowLeft, TestTube, Info, Sparkles, Zap, Brain, MessageSquare, Search } from 'lucide-react'
|
||||||
import { AI_TESTER } from './ai-tester'
|
import { AI_TESTER } from './ai-tester'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -10,109 +9,140 @@ export default function AITestPage() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
<div className="bg-background flex flex-col min-h-screen">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex items-center gap-3">
|
<div className="max-w-[1800px] mx-auto px-6 md:px-16 py-12 space-y-16">
|
||||||
<a href="/admin/settings">
|
{/* Header - Even larger and more spaced */}
|
||||||
<Button variant="outline" size="icon">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-10 border-b border-border/40 pb-12">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<div className="flex items-start gap-8">
|
||||||
|
<a href="/admin/settings" className="mt-2">
|
||||||
|
<Button variant="outline" size="icon" className="rounded-3xl h-16 w-16 border-border/60 bg-card hover:bg-muted transition-all shadow-md group">
|
||||||
|
<ArrowLeft className="h-7 w-7 group-hover:-translate-x-1 transition-transform" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
<div className="flex items-center gap-4 mb-3">
|
||||||
<TestTube className="h-8 w-8" />
|
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
|
||||||
|
<TestTube className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-black uppercase tracking-[0.3em] text-primary/70">Diagnostics Haute Précision</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-7xl font-black tracking-tight text-foreground leading-[1.1]">
|
||||||
{t('admin.aiTest.title')}
|
{t('admin.aiTest.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-4 text-xl font-medium max-w-3xl leading-relaxed">
|
||||||
{t('admin.aiTest.description')}
|
{t('admin.aiTest.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-3 px-8 py-5 bg-card border border-border/50 rounded-[2.5rem] shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<span className="text-base font-black uppercase tracking-widest text-foreground">Système Actif</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-bold uppercase tracking-widest">Latence: Optimisée</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
{/* New Horizontal Section for each Test - Maximum Width Utilization */}
|
||||||
{/* Tags Provider Test */}
|
<div className="space-y-12">
|
||||||
<Card className="border-primary/20 dark:border-primary/30">
|
{/* 1. Tags Test - Horizontal Layout */}
|
||||||
<CardHeader className="bg-primary/5 dark:bg-primary/10">
|
<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">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<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-primary/[0.05] to-transparent relative overflow-hidden">
|
||||||
<span className="text-2xl">🏷️</span>
|
<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-12">
|
||||||
{t('admin.aiTest.tagsTestTitle')}
|
<Brain className="h-80 w-80 text-primary" />
|
||||||
</CardTitle>
|
</div>
|
||||||
<CardDescription>
|
<div className="relative space-y-8">
|
||||||
{t('admin.aiTest.tagsTestDescription')}
|
<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">
|
||||||
</CardDescription>
|
🏷️
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="pt-6">
|
<div>
|
||||||
|
<h3 className="text-3xl md:text-4xl font-black text-foreground tracking-tight mb-4">{t('admin.aiTest.tagsTestTitle')}</h3>
|
||||||
|
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.tagsTestDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<span className="px-4 py-2 bg-primary/10 rounded-xl text-primary text-[10px] font-black uppercase tracking-widest">Auto-Labeling</span>
|
||||||
|
<span className="px-4 py-2 bg-primary/10 rounded-xl text-primary text-[10px] font-black uppercase tracking-widest">Suggestions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-primary/[0.01]">
|
||||||
|
<div className="max-w-4xl">
|
||||||
<AI_TESTER type="tags" />
|
<AI_TESTER type="tags" />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Embeddings Provider Test */}
|
{/* 2. Embeddings Test - Horizontal Layout */}
|
||||||
<Card className="border-green-200 dark:border-green-900">
|
<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">
|
||||||
<CardHeader className="bg-green-50/50 dark:bg-green-950/20">
|
<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-green-500/[0.05] to-transparent relative overflow-hidden">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<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-12">
|
||||||
<span className="text-2xl">🔍</span>
|
<Search className="h-80 w-80 text-green-500" />
|
||||||
{t('admin.aiTest.embeddingsTestTitle')}
|
</div>
|
||||||
</CardTitle>
|
<div className="relative space-y-8">
|
||||||
<CardDescription>
|
<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">
|
||||||
{t('admin.aiTest.embeddingsTestDescription')}
|
🔍
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent className="pt-6">
|
<h3 className="text-3xl md:text-4xl font-black text-foreground tracking-tight mb-4">{t('admin.aiTest.embeddingsTestTitle')}</h3>
|
||||||
|
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.embeddingsTestDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<span className="px-4 py-2 bg-green-500/10 rounded-xl text-green-600 text-[10px] font-black uppercase tracking-widest">Vector Store</span>
|
||||||
|
<span className="px-4 py-2 bg-green-500/10 rounded-xl text-green-600 text-[10px] font-black uppercase tracking-widest">Similarity</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-green-500/[0.01]">
|
||||||
|
<div className="max-w-4xl">
|
||||||
<AI_TESTER type="embeddings" />
|
<AI_TESTER type="embeddings" />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Chat Provider Test */}
|
{/* 3. Chat Test - Horizontal Layout */}
|
||||||
<Card className="border-violet-200 dark:border-violet-900">
|
<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">
|
||||||
<CardHeader className="bg-violet-50/50 dark:bg-violet-950/20">
|
<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">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<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">
|
||||||
<span className="text-2xl">💬</span>
|
<MessageSquare className="h-80 w-80 text-zinc-500" />
|
||||||
Fournisseur de chat
|
</div>
|
||||||
</CardTitle>
|
<div className="relative space-y-8">
|
||||||
<CardDescription>
|
<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">
|
||||||
Testez le fournisseur IA responsable de l'assistant conversationnel
|
💬
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent className="pt-6">
|
<h3 className="text-3xl md:text-4xl font-black text-foreground tracking-tight mb-4">{t('admin.aiTest.chatTestTitle')}</h3>
|
||||||
|
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.chatTestDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<span className="px-4 py-2 bg-zinc-500/10 rounded-xl text-zinc-600 text-[10px] font-black uppercase tracking-widest">Conversational</span>
|
||||||
|
<span className="px-4 py-2 bg-zinc-500/10 rounded-xl text-zinc-600 text-[10px] font-black uppercase tracking-widest">Streaming</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-zinc-500/[0.01]">
|
||||||
|
<div className="max-w-4xl">
|
||||||
<AI_TESTER type="chat" />
|
<AI_TESTER type="chat" />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tips Section - Broad and visible */}
|
||||||
{/* Info Section */}
|
<div className="bg-amber-500/5 border border-amber-500/20 rounded-[3rem] p-12 flex flex-col md:flex-row items-center gap-10">
|
||||||
<Card className="mt-6">
|
<div className="w-24 h-24 rounded-3xl bg-amber-500/10 flex items-center justify-center text-5xl shrink-0 shadow-inner">
|
||||||
<CardHeader>
|
💡
|
||||||
<CardTitle>ℹ️ {t('admin.aiTest.howItWorksTitle')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.tagsGenerationTest')}</h4>
|
|
||||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
|
||||||
<li>{t('admin.aiTest.tagsStep1')}</li>
|
|
||||||
<li>{t('admin.aiTest.tagsStep2')}</li>
|
|
||||||
<li>{t('admin.aiTest.tagsStep3')}</li>
|
|
||||||
<li>{t('admin.aiTest.tagsStep4')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.embeddingsTestLabel')}</h4>
|
<h4 className="text-2xl font-black text-amber-700 dark:text-amber-400 uppercase tracking-tight">{t('admin.aiTest.tipTitle')}</h4>
|
||||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
<p className="text-xl text-amber-600/90 dark:text-amber-300/80 leading-relaxed font-bold">
|
||||||
<li>{t('admin.aiTest.embeddingsStep1')}</li>
|
|
||||||
<li>{t('admin.aiTest.embeddingsStep2')}</li>
|
|
||||||
<li>{t('admin.aiTest.embeddingsStep3')}</li>
|
|
||||||
<li>{t('admin.aiTest.embeddingsStep4')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-amber-50 dark:bg-amber-950/20 p-4 rounded-lg border border-amber-200 dark:border-amber-900">
|
|
||||||
<p className="font-semibold text-amber-900 dark:text-amber-100">💡 {t('admin.aiTest.tipTitle')}</p>
|
|
||||||
<p className="text-amber-800 dark:text-amber-200 mt-1">
|
|
||||||
{t('admin.aiTest.tipContent')}
|
{t('admin.aiTest.tipContent')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,33 +56,33 @@ export function AdminAIPageClient({
|
|||||||
key: 'titleSuggestions' as const,
|
key: 'titleSuggestions' as const,
|
||||||
label: t('admin.ai.titleSuggestions'),
|
label: t('admin.ai.titleSuggestions'),
|
||||||
description: t('admin.ai.titleSuggestionsDesc'),
|
description: t('admin.ai.titleSuggestionsDesc'),
|
||||||
icon: <Sparkles className="h-4 w-4 text-yellow-500" />,
|
icon: <Sparkles className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'paragraphRefactor' as const,
|
key: 'paragraphRefactor' as const,
|
||||||
label: t('admin.ai.aiAssistant'),
|
label: t('admin.ai.aiAssistant'),
|
||||||
description: t('admin.ai.aiAssistantDesc'),
|
description: t('admin.ai.aiAssistantDesc'),
|
||||||
icon: <Brain className="h-4 w-4 text-purple-500" />,
|
icon: <Brain className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'memoryEcho' as const,
|
key: 'memoryEcho' as const,
|
||||||
label: t('admin.ai.memoryEchoFeature'),
|
label: t('admin.ai.memoryEchoFeature'),
|
||||||
description: t('admin.ai.memoryEchoFeatureDesc'),
|
description: t('admin.ai.memoryEchoFeatureDesc'),
|
||||||
icon: <Zap className="h-4 w-4 text-amber-500" />,
|
icon: <Zap className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'languageDetection' as const,
|
key: 'languageDetection' as const,
|
||||||
label: t('admin.ai.languageDetection'),
|
label: t('admin.ai.languageDetection'),
|
||||||
description: t('admin.ai.languageDetectionDesc'),
|
description: t('admin.ai.languageDetectionDesc'),
|
||||||
icon: <Globe className="h-4 w-4 text-green-500" />,
|
icon: <Globe className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'autoLabeling' as const,
|
key: 'autoLabeling' as const,
|
||||||
label: t('admin.ai.autoLabeling'),
|
label: t('admin.ai.autoLabeling'),
|
||||||
description: t('admin.ai.autoLabelingDesc'),
|
description: t('admin.ai.autoLabelingDesc'),
|
||||||
icon: <Tag className="h-4 w-4 text-rose-500" />,
|
icon: <Tag className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -91,40 +91,40 @@ export function AdminAIPageClient({
|
|||||||
title: t('admin.ai.activeFeatures'),
|
title: t('admin.ai.activeFeatures'),
|
||||||
value: String(Object.values(features).filter(Boolean).length) + ' / ' + featureList.length,
|
value: String(Object.values(features).filter(Boolean).length) + ' / ' + featureList.length,
|
||||||
trend: { value: 0, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
icon: <Zap className="h-5 w-5 text-primary" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('admin.ai.successRate'),
|
title: t('admin.ai.successRate'),
|
||||||
value: '100%',
|
value: '100%',
|
||||||
trend: { value: 0, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
icon: <TrendingUp className="h-5 w-5 text-green-600" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('admin.ai.avgResponseTime'),
|
title: t('admin.ai.avgResponseTime'),
|
||||||
value: '—',
|
value: '—',
|
||||||
trend: { value: 0, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
|
icon: <Activity className="h-5 w-5 text-primary" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('admin.ai.configuredProviders'),
|
title: t('admin.ai.configuredProviders'),
|
||||||
value: String(providers.filter(p => p.status !== 'Not Configured').length),
|
value: String(providers.filter(p => p.status !== 'Not Configured').length),
|
||||||
icon: <Settings className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
|
icon: <Settings className="h-5 w-5 text-primary" />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
{t('admin.ai.pageTitle')}
|
{t('admin.ai.pageTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
{t('admin.ai.pageDescription')}
|
{t('admin.ai.pageDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/settings">
|
<Link href="/admin/settings">
|
||||||
<Button variant="outline">
|
<Button variant="outline" className="border-border">
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
{t('admin.ai.configure')}
|
{t('admin.ai.configure')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -133,25 +133,31 @@ export function AdminAIPageClient({
|
|||||||
|
|
||||||
<AdminMetrics metrics={aiMetrics} />
|
<AdminMetrics metrics={aiMetrics} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Feature Toggles */}
|
{/* Feature Toggles */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
{t('admin.ai.features')}
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
</h2>
|
<Zap className="h-5 w-5" />
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.ai.features')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Activez ou désactivez les fonctionnalités IA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 pt-2 border-t border-border">
|
||||||
{featureList.map(({ key, label, description, icon }) => (
|
{featureList.map(({ key, label, description, icon }) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
className="flex items-start justify-between p-3 bg-muted rounded-lg border border-border/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
{icon}
|
<div className="mt-0.5 text-primary">{icon}</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +166,7 @@ export function AdminAIPageClient({
|
|||||||
checked={features[key]}
|
checked={features[key]}
|
||||||
onCheckedChange={(v) => handleToggle(key, v)}
|
onCheckedChange={(v) => handleToggle(key, v)}
|
||||||
disabled={saving === key}
|
disabled={saving === key}
|
||||||
className="ml-3 flex-shrink-0"
|
className="ml-3 mt-0.5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -168,24 +174,31 @@ export function AdminAIPageClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Provider Status */}
|
{/* AI Provider Status */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
<div className="flex flex-col gap-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
{t('admin.ai.providerStatus')}
|
<div className="flex items-center gap-3 mb-2">
|
||||||
</h2>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<div className="space-y-3">
|
<Settings className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.ai.providerStatus')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">État de vos fournisseurs connectés</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 pt-2 border-t border-border">
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) => (
|
||||||
<div
|
<div
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
className="flex items-center justify-between p-3 bg-muted rounded-lg border border-border/50"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
provider.status === 'Connected' || provider.status === 'Available'
|
provider.status === 'Connected' || provider.status === 'Available'
|
||||||
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
|
? 'text-green-700 bg-green-500/10 border border-green-500/20'
|
||||||
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
|
: 'text-muted-foreground bg-muted-foreground/10 border border-muted-foreground/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{provider.status}
|
{provider.status}
|
||||||
@@ -194,16 +207,20 @@ export function AdminAIPageClient({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-sm font-semibold text-foreground mb-2">
|
||||||
{t('admin.ai.recentRequests')}
|
{t('admin.ai.recentRequests')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<div className="p-4 rounded-lg bg-muted border border-border/50 flex flex-col items-center justify-center text-center">
|
||||||
|
<Activity className="h-6 w-6 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('admin.ai.comingSoon')}
|
{t('admin.ai.comingSoon')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { AdminHeader } from '@/components/admin-header'
|
|
||||||
import { AdminSidebar } from '@/components/admin-sidebar'
|
import { AdminSidebar } from '@/components/admin-sidebar'
|
||||||
import { AdminContentArea } from '@/components/admin-content-area'
|
|
||||||
|
|
||||||
// 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-light dark:bg-background-dark font-display text-slate-900 dark:text-white flex flex-col min-h-screen">
|
<div className="flex h-screen overflow-hidden bg-[#E5E2D9] dark:bg-background">
|
||||||
<AdminHeader />
|
|
||||||
<div className="flex flex-1">
|
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
<AdminContentArea>{children}</AdminContentArea>
|
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
|
||||||
|
<div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-6 sm:px-6 sm:py-8 lg:px-10">
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default async function AdminPage() {
|
|||||||
|
|
||||||
<AdminMetrics metrics={metrics} />
|
<AdminMetrics metrics={metrics} />
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -9,15 +9,36 @@ import { Combobox } from '@/components/ui/combobox'
|
|||||||
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { TestTube, ExternalLink, RefreshCw } 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'],
|
||||||
@@ -80,6 +125,7 @@ type ModelPurpose = 'tags' | 'embeddings' | 'chat'
|
|||||||
|
|
||||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
|
|
||||||
@@ -99,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
|
||||||
@@ -169,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)
|
||||||
@@ -179,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)
|
||||||
@@ -189,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)
|
||||||
@@ -458,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
|
||||||
@@ -473,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' : ''}`} />
|
||||||
@@ -499,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' : ''}`} />
|
||||||
@@ -524,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')}
|
||||||
@@ -537,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') },
|
||||||
@@ -545,15 +608,24 @@ 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="space-y-6">
|
<div className="columns-1 lg:columns-2 gap-6">
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.security.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.security.description')}</CardDescription>
|
<Shield className="h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.security.title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.security.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveSecurity(new FormData(e.currentTarget)) }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveSecurity(new FormData(e.currentTarget)) }}>
|
||||||
<CardContent className="space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="ALLOW_REGISTRATION"
|
id="ALLOW_REGISTRATION"
|
||||||
@@ -570,22 +642,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('admin.security.allowPublicRegistrationDescription')}
|
{t('admin.security.allowPublicRegistrationDescription')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter>
|
<div className="px-6 pb-6">
|
||||||
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.ai.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.ai.description')}</CardDescription>
|
<Brain className="h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.ai.title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.ai.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveAI(new FormData(e.currentTarget)) }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveAI(new FormData(e.currentTarget)) }}>
|
||||||
<CardContent className="space-y-6">
|
<div className="px-6 pt-6">
|
||||||
|
<div className="flex border-b border-border/50 overflow-x-auto">
|
||||||
|
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷️ Tags</button>
|
||||||
|
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
|
||||||
|
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
{/* Tags Generation Provider */}
|
{/* Tags Generation Provider */}
|
||||||
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
|
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'tags' ? '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-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
<span className="text-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -615,7 +699,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Embeddings Provider */}
|
{/* Embeddings Provider */}
|
||||||
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
|
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'embeddings' ? '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-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -639,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>
|
||||||
@@ -650,9 +734,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Provider */}
|
{/* Chat Provider */}
|
||||||
<div className="space-y-4 p-4 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
|
<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>
|
||||||
|
|
||||||
@@ -678,8 +762,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
|
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
|
||||||
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
|
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between pt-6">
|
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||||
<a href="/admin/ai-test">
|
<a href="/admin/ai-test">
|
||||||
<Button type="button" variant="outline" className="gap-2">
|
<Button type="button" variant="outline" className="gap-2">
|
||||||
@@ -688,17 +772,22 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.email.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.email.description')}</CardDescription>
|
<Mail className="h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.email.title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.email.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
|
||||||
<CardContent className="space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -826,23 +915,28 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between pt-6">
|
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||||
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
|
||||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.tools.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.tools.description')}</CardDescription>
|
<Wrench className="h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-foreground">{t('admin.tools.title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.tools.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
|
||||||
<CardContent className="space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
||||||
<select
|
<select
|
||||||
@@ -880,13 +974,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
|
|
||||||
{/* Search test result */}
|
{/* Search test result */}
|
||||||
{searchTestResult && (
|
{searchTestResult && (
|
||||||
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300'}`}>
|
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-500/20 bg-green-500/10 text-green-600' : 'border-red-500/20 bg-red-500/10 text-red-600'}`}>
|
||||||
<span className={`mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0 ${searchTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
|
<span className={`mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0 ${searchTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
<span>{searchTestResult.message}</span>
|
<span>{searchTestResult.message}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between">
|
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between">
|
||||||
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -896,9 +990,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
>
|
>
|
||||||
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
|
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ export default async function AdminSettingsPage() {
|
|||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<SettingsHeader />
|
<SettingsHeader />
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
|
||||||
<AdminSettingsForm config={config} />
|
<AdminSettingsForm config={config} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ export function SettingsHeader() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
{t('admin.settings')}
|
{t('admin.settings')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
{t('admin.settingsDescription')}
|
{t('admin.settingsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function AdminUsersPage() {
|
|||||||
<CreateUserDialog />
|
<CreateUserDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800">
|
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border">
|
||||||
<UserList initialUsers={users} />
|
<UserList initialUsers={users} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export function AgentsPageClient({
|
|||||||
role: formData.get('role') as string,
|
role: formData.get('role') as string,
|
||||||
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
|
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
|
||||||
sourceNotebookId: (formData.get('sourceNotebookId') as string) || undefined,
|
sourceNotebookId: (formData.get('sourceNotebookId') as string) || undefined,
|
||||||
|
sourceNoteIds: formData.get('sourceNoteIds') ? JSON.parse(formData.get('sourceNoteIds') as string) : undefined,
|
||||||
targetNotebookId: (formData.get('targetNotebookId') as string) || undefined,
|
targetNotebookId: (formData.get('targetNotebookId') as string) || undefined,
|
||||||
frequency: formData.get('frequency') as string,
|
frequency: formData.get('frequency') as string,
|
||||||
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
|
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
|
||||||
@@ -168,6 +169,8 @@ export function AgentsPageClient({
|
|||||||
scheduledTime: (formData.get('scheduledTime') as string) || undefined,
|
scheduledTime: (formData.get('scheduledTime') as string) || undefined,
|
||||||
scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined,
|
scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined,
|
||||||
timezone: (formData.get('timezone') as string) || undefined,
|
timezone: (formData.get('timezone') as string) || undefined,
|
||||||
|
slideTheme: (formData.get('slideTheme') as string) || undefined,
|
||||||
|
slideStyle: (formData.get('slideStyle') as string) || undefined,
|
||||||
}
|
}
|
||||||
if (editingAgent) {
|
if (editingAgent) {
|
||||||
await updateAgent(editingAgent.id, data)
|
await updateAgent(editingAgent.id, data)
|
||||||
@@ -196,70 +199,30 @@ export function AgentsPageClient({
|
|||||||
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
|
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* Full-bleed layout: -m-4 cancels the p-4 of the parent <main> */
|
/* Full-bleed layout */
|
||||||
<div className="flex -m-4 h-[calc(100vh-4rem)] overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
|
||||||
{/* ── LEFT SIDEBAR ── */}
|
{/* ── Top header bar — architectural grid style ── */}
|
||||||
<aside className="w-60 flex-shrink-0 flex flex-col bg-muted/30 border-r border-border/40 h-full font-display">
|
<header className="flex items-center justify-between px-12 py-10 border-b border-border/40 flex-shrink-0">
|
||||||
{/* Brand */}
|
|
||||||
<div className="flex items-center gap-3 px-5 py-5 border-b border-border/40">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center flex-shrink-0">
|
|
||||||
<Bot className="w-4 h-4 text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-base tracking-tight">{t('agents.title')}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav */}
|
|
||||||
<nav className="flex-1 p-3 space-y-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('dashboard')}
|
|
||||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm font-medium rounded-lg transition-all ${
|
|
||||||
activeTab === 'dashboard'
|
|
||||||
? 'bg-primary/10 text-primary border-r-2 border-primary'
|
|
||||||
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Bot className="w-4 h-4" />
|
|
||||||
{t('agents.myAgents')}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Footer: Help */}
|
|
||||||
<div className="p-3 border-t border-border/40">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHelp(true)}
|
|
||||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-background/70 hover:text-foreground rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
<HelpCircle className="w-4 h-4" />
|
|
||||||
{t('agents.help.btnLabel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* ── MAIN CONTENT ── */}
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 bg-background overflow-hidden">
|
|
||||||
|
|
||||||
{/* Top header bar */}
|
|
||||||
<header className="flex items-center justify-between px-8 py-4 border-b border-border/40 bg-background flex-shrink-0 font-display">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold tracking-tight">
|
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
|
||||||
{t('agents.myAgents')}
|
{t('agents.myAgents')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
|
||||||
{t('agents.subtitle')}
|
{t('agents.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg shadow-sm hover:shadow-md hover:shadow-primary/20 transition-all"
|
className="flex items-center gap-2 px-6 py-3 text-[13px] font-medium uppercase tracking-[0.12em] border border-foreground text-foreground hover:bg-foreground hover:text-background transition-all"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
{t('agents.newAgent')}
|
{t('agents.newAgent')}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Scrollable content area */}
|
{/* ── Scrollable content area ── */}
|
||||||
<main className="flex-1 overflow-y-auto p-8">
|
<main className="flex-1 overflow-y-auto px-12 py-10">
|
||||||
|
|
||||||
{/* Dashboard tab - agents + templates */}
|
{/* Dashboard tab - agents + templates */}
|
||||||
{activeTab === 'dashboard' && (
|
{activeTab === 'dashboard' && (
|
||||||
@@ -329,7 +292,6 @@ export function AgentsPageClient({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sliding panels */}
|
{/* Sliding panels */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
|
|||||||
@@ -14,18 +14,18 @@ export const dynamic = 'force-dynamic'
|
|||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
|
|
||||||
export default async function LabPage(props: {
|
export default async function LabPage(props: {
|
||||||
searchParams: Promise<{ id?: string }>
|
searchParams: Promise<{ id?: string; canvas?: string }>
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams
|
const searchParams = await props.searchParams
|
||||||
const id = searchParams.id
|
/** Canonical param is id; notifications / older UI used canvas= */
|
||||||
|
const requestedId = searchParams.id ?? searchParams.canvas
|
||||||
|
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) redirect('/login')
|
if (!session?.user?.id) redirect('/login')
|
||||||
|
|
||||||
const canvases = await getCanvases()
|
const canvases = await getCanvases()
|
||||||
|
|
||||||
// Resolve current canvas correctly
|
const currentCanvasId = requestedId || (canvases.length > 0 ? canvases[0].id : undefined)
|
||||||
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
|
|
||||||
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
|
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
|
||||||
|
|
||||||
// Wrapper for server action creation
|
// Wrapper for server action creation
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user