Compare commits

55 Commits

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

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

NoteRefreshContext marked as @deprecated with JSDoc migration guide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:45:50 +00:00
Antigravity
9b8df398dc refactor: sidebar removes useNoteRefreshOptional dependency
Removed useNoteRefreshOptional() and refreshKey from sidebar.
The notebook note titles useEffect now only depends on [notebooks]
instead of [notebookIdsKey, refreshKey, notebooks, t].

This means sidebar note titles only re-fetch when notebooks
change (add/delete/reorder), not on every triggerRefresh().
Individual note changes are handled by React Query cache invalidation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:37:51 +00:00
Antigravity
65568c0f07 refactor: home-client uses useRefresh() for note invalidation
Replace direct triggerRefresh() with useRefresh().refreshNotes()
in handleEditorClose. This invalidates the notes cache via
React Query and triggers the old refresh mechanism for backward compat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:34:20 +00:00
Antigravity
def683982c refactor: note-editor-context uses invalidateQueries on save/copy
Replace triggerRefresh() with targeted invalidateQueries() for:
- handleSave: invalidate note + notes list for notebookId
- handleSaveInPlace: invalidate note + notes list for notebookId
- handleMakeCopy: invalidate notes list for current notebook

Keeps triggerRefresh() for backward compat until fully migrated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:32:30 +00:00
Antigravity
91b1201112 refactor: split NoteEditor into focused components + consolidate contexts
Phase 1: NoteEditor Split (64KB → 9 focused components)
- components/note-editor/: types.ts, context, toolbar, title-block,
  content-area, metadata-section, full-page, dialog compositions
- Maintains backwards compatibility via re-export from note-editor.tsx

Phase 2: Context Consolidation (5 → 3 contexts)
- NotebooksContext absorbs LabelContext (labels CRUD)
- EditorUIContext merges HomeViewContext + NotebookDragContext
- Removed: LabelContext, home-view-context, notebook-drag-context

Phase 3: React Query Infrastructure
- Added QueryProvider with @tanstack/react-query
- lib/query-keys.ts: centralized query key definitions
- lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery
- lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper
- NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:31:08 +00:00
Antigravity
a58610003d fix: remove duplicate useMemo inside handleGenerateTitles (hooks violation = broken save+title), add Save btn + three-dot delete + Ctrl+S to fullPage toolbar 2026-05-07 23:52:21 +00:00
Antigravity
29e65038b7 fix: title textarea auto-resize (no overflow), auto-title 10-word check + toast feedback, AI expand = fixed overlay (no compress) 2026-05-07 23:39:19 +00:00
Antigravity
38c637cfac fix: restore Expand/Minimize button in AI panel + dynamic width, note thumbnail SVG placeholder with emoji/letter 2026-05-07 23:33:20 +00:00
Antigravity
3b036e84b8 fix: auto-title button always visible on hover + wired to AI panel Wand button, title text-4xl/5xl no overflow, content max-w-3xl 2026-05-07 23:29:18 +00:00
Antigravity
ea62d68cdd fix: sticky header needs overflow-y-auto parent, textarea auto-resize on preview toggle, markdown source always visible 2026-05-07 23:09:45 +00:00
Antigravity
77d6458946 fix: AI/Info panels h-full self-stretch fill screen, bg-background consistent, items-stretch on parent 2026-05-07 23:02:20 +00:00
Antigravity
8d4e4d5d56 fix: fullpage editor large text via .fullpage-editor CSS, AI tabs no-wrap, tone selector stronger style, doc-info panel redesign 2026-05-07 22:59:26 +00:00
Antigravity
24b5d6bdac ux: textarea auto-grow, preview toggle in toolbar, prose-lg on richtext, no more bottom preview btn 2026-05-07 22:52:51 +00:00
Antigravity
01390ebb5b fix: header sticky uses bg-background (paper color), sort label hardcoded fallback, border-b added 2026-05-07 22:47:57 +00:00
Antigravity
db899b0da2 fix: content area unified branch (text+markdown→textarea/preview, richtext→RTE), no null dead-end 2026-05-07 22:43:51 +00:00
Antigravity
7bc63158bf fix: textarea 60vh, white header, sidebar corbeille+footer alignment, orphan var removed 2026-05-07 22:43:09 +00:00
Antigravity
ccbd1b5abc feat: fullPage layout 1:1 prototype - white bg, px-12 py-8 toolbar, rounded-full buttons, breadcrumb notebook>date, hero image, prose-lg content 2026-05-07 22:35:52 +00:00
Antigravity
df943878a0 fix: toolbar white bg, rounded-full buttons, px-12 padding like prototype 2026-05-07 22:32:12 +00:00
Antigravity
e458b63115 feat: architectural grid editor fullPage + slash commands + doc info panel + AI title 2026-05-07 22:29:02 +00:00
Antigravity
0d8252aec0 fix(ci): typings dagre (@types/dagre) et pptx (PptxGenJSModule, fill bullets)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m8s
Corrige le typecheck Next Docker: module dagre sans .d.ts, et annotations
Presentation vs constructeur pptxgenjs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 22:04:07 +00:00
Antigravity
33ad874e5d fix(agents): TYPE_DEFAULTS.tools en string[] pour run-for-note
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m1s
Prisma attend une chaîne JSON pour Agent.tools ; on sérialise au create.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 22:01:12 +00:00
Antigravity
3b72e4afbb fix(prisma): colonnes Agent sourceNoteIds, slideTheme, slideStyle
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m33s
Aligne prisma/schema.prisma avec agent-actions.ts (typecheck Docker/Next).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:58:18 +00:00
Antigravity
ccc94a4b35 fix(ci): next build --webpack pour dagre/pptxgenjs/elkjs (Turbopack ne résout pas)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m36s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:53:03 +00:00
Antigravity
fb6823e25e fix(lab): ouvrir le bon diagramme sans F5 (?id alias ?canvas + tri canvases)
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:52:30 +00:00
Antigravity
7326cfc98f fix: import type + lazy singleton pour dagre et pptxgenjs (Turbopack build)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:48:54 +00:00
Antigravity
4f950740eb feat: options de génération (thème/style/type) + masquer agents one-shot
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
- Les agents créés par run-for-note ont frequency:'one-shot' et sont
  filtrés de la page /agents (NOT frequency:'one-shot' dans getAgents)
- Panneau slides : sélecteurs Thème (11 palettes) + Style (sharp/soft/rounded/pill)
- Panneau diagramme : sélecteurs Type (auto/flowchart/mindmap/timeline/
  org-chart/architecture/process-map) + Style (sketchy/austere/sketch+)
- Les valeurs sélectionnées sont transmises à l'API et injectées dans le prompt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:45:57 +00:00
Antigravity
db200bbc9f fix: serverExternalPackages pour pptxgenjs, dagre, elkjs (Turbopack)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:41:07 +00:00
Antigravity
08a49a04ce fix: désactiver thinking mode DeepSeek pour fiabiliser le tool calling
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:37:55 +00:00
Antigravity
ab914f0587 fix: reasoning_content DeepSeek — retry avec maxSteps=1 si erreur thinking mode
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:33:16 +00:00
Antigravity
b55f558a62 fix: import auth depuis @/auth (pas @/lib/auth)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:27:43 +00:00
Antigravity
19d0b2759a fix: toast position bottom-right pour ne pas bloquer la navigation
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:27:07 +00:00
Antigravity
34a977b5c4 fix: runAgent fire-and-forget + polling sur la page /agents
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Le bouton Play des cartes agents appelait runAgent (Server Action) qui
attendait executeAgent (~2-5 min) → spinner bloqué sans résultat.

- runAgent retourne immédiatement { success, agentId }
- agent-card.tsx lance un polling toutes les 3s sur GET
  /api/agents/run-for-note?agentId= jusqu'au statut terminal
- Toast persistant Sonner pendant la génération, mis à jour au résultat
- Cleanup automatique du polling au démontage

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:26:35 +00:00
Antigravity
75b08ef53b fix: génération asynchrone (fire-and-forget + polling)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Le problème : la route POST /api/agents/run-for-note bloquait le thread
HTTP pendant toute la durée de génération (2-5 min), provoquant un
spinner infini sans résultat.

Solution :
- POST retourne immédiatement avec { agentId } et lance executeAgent
  en arrière-plan (fire-and-forget, sans await)
- GET /api/agents/run-for-note?agentId= retourne le statut de la
  dernière agentAction (running | success | failure) + canvasId/noteId
- Le client poll le statut toutes les 3 secondes jusqu'au résultat,
  le toast persistant Sonner se met à jour automatiquement
- Cleanup du polling au démontage du composant

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:21:28 +00:00
Antigravity
98e246e257 fix: force git reset on deploy + toast persistant de génération
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
- Remplace git pull par git fetch + git reset --hard origin/main dans
  le workflow CI pour éviter les conflits avec les fichiers locaux
- Ajoute un toast Sonner persistant (duration: Infinity) dès le clic sur
  Générer, qui survit à la navigation et se met à jour en succès/erreur
  avec action directe (Télécharger / Ouvrir)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:15:38 +00:00
Antigravity
ff0b56f805 feat(ai-note): passer noteId au ContextualAIChat en mode liste (note-inline-editor)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:11:21 +00:00
Antigravity
d1e08f64c8 feat(ai-note): ajouter boutons Générer slides/diagramme dans le panneau IA
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
- Nouveau endpoint POST /api/agents/run-for-note : crée un agent one-shot
  (slide-generator ou excalidraw-generator) avec la note courante comme source
  et l'exécute immédiatement
- ContextualAIChat : prop noteId + section "Générer depuis cette note" avec
  deux boutons gradient (violet=slides, cyan=diagramme), spinner pendant la
  génération, bouton de téléchargement .pptx ou lien "Ouvrir dans le Lab"
  après succès
- note-editor.tsx : passage de note.id à ContextualAIChat
- i18n fr/en : nouvelles clés ai.generate.*

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:07:43 +00:00
Antigravity
e7f28abccc fix(pptx): pre-fetch images server-side before passing to pptxgenjs
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
pptxgenjs sur Node.js ne peut pas charger les URLs qui nécessitent une auth
ou des cookies. On fetch maintenant chaque imageUrl côté serveur et on la
convertit en data URI base64 avant d'appeler buildPresentation.
Si le fetch échoue (timeout 8s, 4xx/5xx), l'imageUrl est supprimée et
le placeholder texte s'affiche à la place.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 20:57:29 +00:00
Antigravity
129d5541e6 feat(agents): refonte complète slide-generator + excalidraw-generator
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
Slide generator (generate_pptx):
- Pivot vers génération PowerPoint natif (pptxgenjs) au lieu de Reveal.js HTML
- 4 nouveaux layouts diagramme : timeline, process, comparison, metrics
- 2 nouveaux layouts image : image-content (texte + image), image-full (plein cadre)
- Redesign visuel de tous les layouts (cover split, section full-bleed, header band)
- Palettes corrigées : bg blanc sur toutes les palettes, contrastes réels
- fit:shrink systématique sur tous les textes pour éviter les débordements
- Extraction automatique des images des notes (Markdown/HTML) et injection dans le prompt IA
- Prompt IA renforcé : impose "style" et "theme" explicitement dans le JSON, impose ≥2 layouts diagramme
- Fix overlap timeline : zones de texte calculées sans collision avec les cercles
- Notification agent mise à jour : bouton download .pptx au lieu d'ouvrir HTML

Excalidraw generator:
- Layout Dagre/ELK pour graphes auto-positionnés
- Styles visuels : coloré, austère, sketch-plus (Virgil font)
- Zones/containers pour architecture-cloud
- Sanitisation du graphe et métriques de qualité de layout

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 20:55:15 +00:00
Antigravity
21fb56de3f feat(sidebar): drag-and-drop notebook reordering
- Add GripVertical handle (visible on hover) as reorder affordance
- Rows are now draggable; uses 'application/x-notebook' dataTransfer type
  to coexist with existing note-to-notebook drag ('text/plain')
- Drop indicator: thin primary-colored line above the insertion target
- Dragged item fades to 40% opacity during drag
- On drop: calls updateNotebookOrderOptimistic → POST /api/notebooks/reorder → persisted in DB
- draggingNbRef (useRef) used for stale-closure-safe detection in dragover handler
2026-05-03 21:06:34 +00:00
Antigravity
0ebf10344d feat(mcp): add all 4 note types, translate N8N docs to English, add N8N workflow examples
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 55s
- tools.js: expose type enum ['text','markdown','richtext','checklist'] in create_note & update_note
  - default changed from 'text' to 'richtext' (matches Prisma schema)
  - isMarkdown marked as deprecated in favour of type='markdown'
- N8N-CONFIG.md: full French → English translation
- N8N-WORKFLOWS.md: full French → English translation
- N8N-EXAMPLES.md: new comprehensive examples for all 22 MCP tools + workflow patterns
- n8n-workflow-mcp-reminder-bot.json: cron → get_due_reminders → Telegram → mark done
- n8n-workflow-mcp-email-to-note.json: IMAP → create_note → urgent Slack alert
- n8n-workflow-mcp-daily-digest.json: 8AM cron → notes + reminders digest → save + Slack
- n8n-workflow-mcp-webhook-to-note.json: universal webhook → create_note → respond
- notebooks-list.tsx: fix truncated notebook names (pe-24→pe-14), replace hover overlay with Tooltip
2026-05-03 20:49:11 +00:00
Antigravity
0311c97a35 fix: add /health endpoint before auth middleware for Docker healthcheck (was 401 with MCP_REQUIRE_AUTH=true)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 16s
2026-05-03 19:45:27 +00:00
Antigravity
1ed7839334 fix: notebook name overflows sidebar on hover with bold + opaque background instead of tooltip overlay
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
2026-05-03 19:43:57 +00:00
Antigravity
718f4c6900 perf: optimize MCP server (O(1) auth, compact JSON, trashedAt fix) + memento-note performance (lazy loading, server-side filtering, XSS fixes, dead code removal, security hardening)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s
MCP Server:
- Fix validateApiKey: O(1) direct lookup by shortId instead of loading all keys
- Add trashedAt:null filter to ALL note queries (trashed notes leaked in results)
- Compact JSON output (~40% smaller responses)
- Bounded session cache (Map with MAX_SESSIONS=500) to prevent memory leaks
- PostgreSQL connection pooling (connection_limit=10)
- Rewrite all 22 tool descriptions in clear English
- Fix /sse fallback to proper 307 redirect

memento-note Performance:
- loading=lazy on all note images
- Split notebooksRefreshKey from global refreshKey (note CRUD no longer re-fetches notebooks)
- Remove searchKey from trash count deps (no re-fetch on every keystroke)
- Server-side notebookId filter in getAllNotes() (biggest win)
- Skip collaborator fetch for non-shared notes (eliminates N+1 API calls)
- next/dynamic for MarkdownContent + 4 modals (code-split remark/rehype/KaTeX)
- Memoize DOMPurify sanitize with useMemo

Security:
- XSS: DOMPurify sanitize in note-card and note-history-modal
- Auth anti-enumeration: uniform errors in auth.ts
- CRON_SECRET mandatory on cron endpoints
- Rate limiting on login (5 attempts/min per email)
- Centralized API auth helpers (requireAuth/requireAdmin)
- randomize-labels changed GET→POST
- Removed debug endpoints (/api/debug/config, /api/debug/test-chat)

Cleanup:
- Removed dead code: .backup-keep, settings-backup, fix-*.js, debug-theme, fix-labels route
- Removed sensitive console.error in auth.ts
- Ollama fetchWithTimeout (30s/60s AbortController)
- i18n: full Arabic translation, Farsi missing keys
- Masonry drag-and-drop fix (localOrderMap, cross-section block)
- Sidebar notebook tooltip on truncation
2026-05-03 18:41:38 +00:00
Antigravity
aee4b17306 feat: redesign AI test page with Ethereal Precision v2 (horizontal layout, ultra-wide) and fix Dockerfile OpenSSL issue
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 58s
2026-05-03 13:09:04 +00:00
Antigravity
b611ec874d Refactor Admin and Settings UI to Ethereal Precision aesthetic and improve note import/export functionality
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m4s
2026-05-03 12:51:25 +00:00
635e516616 fix(ai): live update for translation language input + preset sync
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 46s
2026-05-03 01:42:22 +02:00
a7c3251b49 fix(build): bypass TypeScript error in BubbleMenu blocking CI
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
2026-05-03 01:38:16 +02:00
5375f874cd fix(chat): enlarge preview panel (max-h-64)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 36s
2026-05-03 01:35:03 +02:00
315 changed files with 40891 additions and 16908 deletions

10
.claude/settings.json Normal file
View File

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

View File

@@ -24,7 +24,14 @@
"Bash(do python3 -c \"import json; json.load\\(open\\(''$f''\\)\\)\")", "Bash(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)"
] ]
} }
} }

View File

@@ -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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View 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">
&copy; 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>
);
}

View 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);
}

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

View File

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

View 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">
&copy; 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View 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">
&copy; 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>
);
}

View 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);
}

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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
View 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 }}"]
}
```

View File

@@ -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

View File

@@ -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,17 +124,11 @@ 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;
}
} }
/** /**

View File

@@ -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`); }
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]);
} }
}, 600000); // Every 10 minutes }
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 Timeout: ${REQUEST_TIMEOUT}ms
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
Database: ${databaseUrl} Tools: 22
App URL: ${appBaseUrl}
User filter: per-request (from auth)
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev mode)'}
Timeout: ${REQUEST_TIMEOUT}ms
Performance Optimizations:
✅ 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));

View File

@@ -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);
});

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View File

@@ -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",

File diff suppressed because one or more lines are too long

View File

@@ -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',

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -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",

View File

@@ -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])
} }

View File

@@ -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',

View File

@@ -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,

View File

@@ -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}`);
} }
}); });
} }

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 (480767px) : 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 (7681023px) ────────────────────────── */
@media (min-width: 768px) and (max-width: 1023px) {
.masonry-css-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
}
/* ─── Desktop (10241279px) ─────────────────────── */
@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;
}
}

View File

@@ -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>
);
}

View File

@@ -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>
)
})

View File

@@ -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>
)
}

View File

@@ -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"

View 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.

View File

@@ -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 . .

View File

@@ -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">&quot;{result.chatResponse}&quot;</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">
&quot;{result.chatResponse}&quot;
</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&apos;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>

View File

@@ -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&apos;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>
) )
} }

View File

@@ -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>
) )
} }

View File

@@ -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>
) )
} }

View File

@@ -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>

View File

@@ -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>
) )
} }

View File

@@ -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>
) )
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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