feat: RTL/i18n, AI translate+undo, no-refresh saves, settings perf

- RTL: force dir=rtl on LabelFilter, NotesViewToggle, LabelManagementDialog
- i18n: add missing keys (notifications, privacy, edit/preview, AI translate/undo)
- Settings pages: convert to Server Components (general, appearance) + loading skeleton
- AI menu: add Translate option (10 languages) + Undo AI button in toolbar
- Fix: saveInline uses REST API instead of Server Action → eliminates all implicit refreshes in list mode
- Fix: NotesTabsView notes sync effect preserves selected note on content changes
- Fix: auto-tag suggestions now filter already-assigned labels
- Fix: color change in card view uses local state (no refresh)
- Fix: nav links use <Link> for prefetching (Settings, Admin)
- Fix: suppress duplicate label suggestions already on note
- Route: add /api/ai/translate endpoint
This commit is contained in:
Sepehr Ramezani
2026-04-15 23:48:28 +02:00
parent 39671c6472
commit b6a548acd8
68 changed files with 5014 additions and 485 deletions

View File

@@ -0,0 +1,635 @@
---
stepsCompleted: [1, 2, 3]
inputDocuments: []
session_topic: 'Nouvelles fonctionnalités IA pour Keep (Memento) au-delà du roadmap existant'
session_goals: 'Générer des idées innovantes et différenciantes de fonctionnalités IA non couvertes par les phases 1-4 déjà planifiées'
selected_approach: 'ai-recommended'
techniques_used: ['Cross-Pollination', 'Reversal Inversion', 'SCAMPER Method']
ideas_generated: []
context_file: ''
mode: 'autonomous'
---
# Brainstorming Session Results
**Facilitator:** Ramez
**Date:** 2026-04-13
## Session Overview
**Topic:** Nouvelles fonctionnalités IA pour Keep (Memento)
**Goals:** Générer des idées innovantes et différenciantes de fonctionnalités IA non couvertes par les phases 1-4 déjà planifiées
### Context Guidance
_Projet Keep est une application de notes self-hosted, privacy-first, avec architecture multi-provider IA (OpenAI, Ollama, DeepSeek, OpenRouter, Custom). Phase 1 MVP AI déjà implémentée : suggestions de titre, recherche sémantique, reformulation, Memory Echo, fusion de notes, auto-tagging, organisation par lot (15 langues), détection de langue (62 langues). Roadmap Phase 2-4 couvre : OCR images, résumés URLs, Chat RAG, vue graphe, Voice-to-Note, Super AI mode, templates IA, organisation autonome._
### Session Setup
_Session axée sur la découverte de fonctionnalités IA totalement nouvelles, hors du roadmap existant, qui capitalisent sur l'architecture existante et le positionnement "Zero-Friction Intelligence" du produit._
## Technique Selection
**Approach:** AI-Recommended Techniques (Mode Autonome)
**Analysis Context:** Fonctionnalités IA nouvelles pour Keep avec focus sur innovation hors roadmap
**Recommended Techniques:**
- **Cross-Pollination:** Transférer des solutions d'industries différentes (santé, jeux, musique, éducation)
- **Reversal Inversion:** Retourner le problème pour révéler des angles cachés
- **SCAMPER Method:** Affiner les meilleures idées à travers 7 lentilles systématiques
---
## Phase 1: Cross-Pollination — Idées venues d'ailleurs
_Sources d'inspiration : santé mentale, jeux vidéo, musique, éducation, fitness wearables, reseaux sociaux, AI second brain_
### Idée 1 : Mood Weaving (inspiré des apps de journaling santé mentale)
L'IA analyse le sentiment et le ton émotionnel de chaque note au fil du temps. Un "tapis émotionnel" (mood tapestry) se tisse visuellement — un graphique de couleurs qui montre vos états émotionnels récurrents. L'IA détecte les cycles ("Tu es plus anxieux le dimanche soir", "Tes notes de voyages sont systématiquement plus positives"). **Différenciateur :** Aucune app de notes ne fait d'analyse émotionnelle longitudinale. Utilise le provider IA existant + TinyLD pour corréler langue et émotion.
### Idée 2 : Knowledge Podcast Generator (inspiré de Google NotebookLM)
Transformez un notebook ou un ensemble de notes en un **podcast audio généré par IA** — deux voix AI discutent de vos notes comme une émission radio. Idéal pour réviser ses notes de cours en marchant, ou consommer sa propre base de connaissance en mobilité. **Différenciateur :** Aucune app de notes self-hosted n'offre ça. Utilise les embeddings existants pour sélectionner le contenu pertinent.
### Idée 3 : Spaced Resurfacing (inspiré d'Anki et la répétition espacée)
Au lieu de simplement connecter des notes (Memory Echo), l'IA **resurface les notes que vous êtes sur le point d'oublier** basé sur la courbe d'oubli d'Ebbinghaus. Une note revient dans votre flux exactement au moment optimal pour la revoir. **Différenciateur :** Transforme Keep d'un simple outil de notes en un **système d'apprentissage actif**. Capitalise sur les embeddings existants pour mesurer la relatedness.
### Idée 4 : Thought Trajectory (inspiré des wearables fitness)
Comme une montre qui trace votre évolution physique, l'IA trace l'**évolution de votre pensée** sur un sujet. Vous avez écrit 15 notes sur "l'IA" en 6 mois ? L'IA vous montre comment votre position a évolué, quels concepts vous avez approfondis, lesquels vous avez abandonnés. **Différenciateur :** Visualisation temporelle de l'évolution intellectuelle. Utilise la recherche sémantique existante pour clusterer par sujet.
### Idée 5 : Note DNA (inspiré de Spotify DNA / musical fingerprinting)
Chaque note reçoit un "ADN" visuel unique basé sur ses caractéristiques IA : longueur, sentiment, langue, complexité, topics, connexions. Un petit badge visuel qui permet de **reconnaître instantanément** le type de note au coup d'oeil dans le masonry grid. **Différenciateur :** Approche visuelle/biomimétique unique. Les métadonnées IA existent déjà (embedding, langue, confidence).
### Idée 6 : Ghost Writer Mode (inspiré des "digital twins" comme Personal.ai)
L'IA apprend votre **style d'écriture** à partir de toutes vos notes existantes. Quand vous commencez à écrire, elle complète les phrases dans VOTRE voix, pas une voix générique. Plus vous écrivez, plus elle vous ressemble. **Différenciateur :** Privacy-first (modèle local via Ollama) — votre clone d'écriture ne quitte jamais votre serveur. Unique vs les apps cloud.
### Idée 7 : Contextual Nudge Engine (inspiré des wearables et nudge theory)
Au lieu de suggestions IA uniquement quand l'utilisateur le demande, l'IA **détecte le bon moment** pour intervenir. Exemple : vous écrivez 3 notes sur le même sujet en 2 jours → l'IA vous suggère doucement de les fusionner ou créer un notebook. Vous n'avez pas écrit depuis 5 jours → l'IA resurface une note ancienne pertinente. **Différenciateur :** "Zero-Friction Intelligence" poussé à son paroxysme — l'IA est proactive mais non-intrusive.
### Idée 8 : Knowledge Decay Indicator (inspiré de la physique radioactive)
Chaque note a un **indicateur de "demi-vie"** — un score qui diminue avec le temps si la note n'est ni consultée, ni connectée, ni mise à jour. Les notes "mortes" apparaissent différemment visuellement. L'IA vous alerte quand un concept important est en train de "se dégrader" dans votre base de connaissance. **Différenciateur :** Transforme la gestion de notes en gestion active de connaissance.
### Idée 9 : Daily Note Cocktail (inspiré de l'industrie du gaming — daily rewards)
Chaque jour, l'IA prépare un "cocktail" personnalisé de 3 notes : une revisitée (spaced resurfacing), une connexion inattendue (memory echo), et une "nouvelle perspective" (l'IA reformule une vieille note à la lumière de vos notes récentes). Un petit rituel quotidien qui encourage l'engagement. **Différenciateur :** Mécanique de rétention empruntée au gaming, adaptée à la connaissance.
### Idée 10 : Note-to-Timeline (inspiré des timelines de projets et GitHub)
L'IA reconstruit automatiquement une **timeline narrative** à partir de vos notes sur un sujet. Au lieu de voir des notes isolées, vous voyez une histoire : "Comment votre projet X a évolué de l'idée à la réalisation" avec les notes comme chapitres. **Différenciateur :** Narrative intelligence — transforme des fragments en récit cohérent.
---
## Phase 2: Reversal Inversion — Retourner le problème
_Et si on inversait les hypothèses fondamentales d'une app de notes ?_
### Idée 11 : Reverse Search (Et si c'était la note qui vous cherchait ?)
Au lieu de l'utilisateur qui cherche une note, les notes "postulent" pour être utiles. Quand vous ouvrez l'app, les notes les plus pertinentes compte tenu de l'heure, du jour, et de votre historique récent se placent en haut. **La note vient à vous.**
### Idée 12 : Strategic Forgetting (Et si l'IA oubliait délibérément ?)
L'IA marque certaines notes comme "archivées cognitivement" — elles restent accessibles mais sont retirées du flux actif pour réduire la surcharge informationnelle. L'inverse du "tout garder". L'IA décide ce que vous n'avez plus besoin de voir régulièrement, en se basant sur vos patterns de consultation.
### Idée 13 : Anti-Organization (Et si moins de structure = plus de valeur ?)
Un mode "Zen" où l'IA supprime toutes les étiquettes, notebooks et métadonnées visuelles. Vous voyez uniquement vos notes, nues. L'IA gère toute l'organisation en arrière-plan. L'utilisateur n'a qu'à écrire. **La désorganisation visible est un choix, pas un problème.**
### Idée 14 : Note Dissolve (Et si les notes pouvaient fusionner et disparaître ?)
Quand deux notes deviennent trop similaires (seuil de similarité élevé), l'IA propose de les "dissoudre" en une seule note enrichie, en supprimant les redondances. La note résultante est meilleure que chacune individuellement. **La réduction, pas l'accumulation, crée la valeur.**
### Idée 15 : Silent AI (Et si l'IA se taisait complètement ?)
Un mode où l'IA travaille entièrement en silence — elle organise, connecte, tag, resurface — mais l'utilisateur ne voit JAMAIS de suggestions, badges, ou prompts. L'effet se ressent indirectement : les bonnes notes sont toujours au bon endroit, les connexions se font seules. **L'IA parfaite est invisible.**
### Idée 16 : Note-as-Question (Et si chaque note générait des questions au lieu de réponses ?)
L'IA lit votre note et génère 3 questions que vous n'avez PAS posées. "Tu as écrit sur X, mais as-tu considéré Y ?" "C'est intéressant, mais qu'en est-il de Z ?" **La note ne clôt pas la pensée, elle l'ouvre.**
### Idée 17 : Reverse Onboarding (Et si l'app apprenait de vous au lieu de vous apprendre ?)
Pendant les 7 premiers jours, l'IA observe silencieusement COMMENT vous prenez des notes — style, longueur, fréquence, sujets. Ensuite, l'app s'adapte à VOTRE workflow au lieu de vous forcer dans un template prédéfini. **L'utilisateur ne remplit jamais un questionnaire de setup.**
### Idée 18 : Collaborative Silence (Et si le partage de notes n'avait pas de chat ?)
Au lieu d'un système de commentaires/discussion, les collaborateurs partagent des notes et l'IA détecte les **zones de friction ou désaccord** entre les notes de différentes personnes, et génère un "rapport de tensions" productif. **Pas de chat, pas de bruit — juste de l'intelligence.**
### Idée 19 : Time-Release Notes (Et si les notes avaient une date de sortie ?)
L'utilisateur peut écrire une note et la "sceller" avec une date future. La note n'apparaît dans le flux qu'à la date prévue. Comme une capsule temporelle. L'IA peut aussi suggérer des dates : "Cette réflexion pourrait te concerner dans 3 mois." **Le temps comme filtre actif.**
### Idée 20 : Emotion-First Organization (Et si l'organisation se faisait par émotion, pas par sujet ?)
L'IA classe vos notes non pas par notebook/tag mais par état émotionnel : "Notes écrites dans un moment d'enthousiasme", "Notes de doute", "Notes de découverte". Une dimension d'organisation totalement nouvelle. **Votre base de connaissance reflète qui vous étiez quand vous l'avez écrite.**
---
## Phase 3: SCAMPER — Affinage des meilleures idées
_7 lentilles appliquées aux concepts les plus prometteurs_
### S — Substitute (Remplacer)
**Idée 21 : Visual Note Identity (substitution de l'ADN note)**
Au lieu de badges de couleur simples, chaque note reçoit un **mini-glyph unique généré par IA** — un symbole abstrait qui encode sa signature sémantique. Comme les favicons mais pour les idées. Visuellement unique et mémorable.
### C — Combine (Combiner)
**Idée 22 : Daily Cocktail + Spaced Resurfacing = "Keep Daily"**
Combiner les idées 3 et 9 en une seule feature "Keep Daily" : chaque matin, une page personnalisée avec :
- 1 note à reviser (spaced repetition)
- 1 connexion inattendue (memory echo)
- 1 perspective nouvelle (reformulation IA d'une vieille note)
- 1 insight émotionnel (mood weaving)
- Le tout dans une interface éphémère qui change chaque jour.
**Idée 23 : Ghost Writer + Note DNA = "Your Voice Profile"**
Combiner l'apprentissage de style (idée 6) avec le fingerprinting de note (idée 5) pour créer un **profil vocal d'écriture** unique. L'UI montre votre "identité d'écrivain" : ton, longueur moyenne, vocabulaire préféré, patterns récurrents. Comme Spotify Wrapped, mais pour votre écriture.
### A — Adapt (Adapter)
**Idée 24 : Knowledge Podcast → Notebook Audiobook**
Adapter l'idée du podcast (idée 2) en quelque chose de plus simple techniquement : un **résumé audio lu par TTS** d'un notebook entier. Moins ambitieux qu'un podcast dialogué, plus facile à implémenter, et tout aussi utile pour consommer ses notes en marchant.
**Idée 25 : Thought Trajectory → "Thinking Map"**
Adapter l'idée 4 en une carte visuelle style "skill tree" de jeux vidéo. Chaque topic est un noeud, et l'arbre montre comment vos pensées ont branché, mergé, ou cul-de-sac. Utilise React Flow (déjà dans le roadmap Memory Echo V2).
### M — Modify (Modifier)
**Idée 26 : Contextual Nudge Engine → "AI Concierge"**
Modifier l'idée 7 en un personnage IA visible (un petit assistant dans un coin de l'écran) qui commente intelligemment votre activité. Pas intrusif, mais présent. Comme Clippy mais intelligent et optionnel. Utilise le provider IA pour générer des micro-commentaires contextuels.
**Idée 27 : Knowledge Decay → "Note Health Score"**
Modifier l'idée 8 en un système de **score de santé** pour chaque note (0-100) basé sur : fraîcheur (quand dernière mise à jour), connectivité (combien de liens vers elle), consultation (fréquence de lecture), richesse (longueur, médias). Un dashboard "Santé de votre base de connaissance".
### P — Put to Other Uses (Autres usages)
**Idée 28 : Keep comme outil de thérapie réflexive**
Utiliser le Mood Weaving (idée 1) + les Note-as-Question (idée 16) pour créer un mode **"Reflective Journal"** — Keep devient un outil de thérapie réflexive guidée par IA. L'IA pose des questions profondes, suit vos patterns émotionnels, et vous aide à prendre du recul. Positionnement unique : ni app de notes, ni app de thérapie, mais un hybride.
**Idée 29 : Keep comme outil d'enseignement**
Utiliser le Spaced Resurfacing (idée 3) + le Knowledge Podcast (idée 2) pour créer un mode **"Study Mode"** — Keep devient un outil d'apprentissage actif. L'étudiant prend des notes de cours, Keep gère la révision espacée, génère des quiz automatiques, et produit des résumés audio pour réviser en marchant.
### E — Eliminate (Éliminer)
**Idée 30 : Silent AI + Anti-Organization = "Zen Mode"**
Combiner les idées 13 et 15 en un mode radical : **aucune interface IA visible**. L'utilisateur voit uniquement ses notes brutes. Mais en arrière-plan, l'IA organise, connecte, et optimise tout. Quand l'utilisateur a besoin d'une note, elle est toujours exactement là où il s'attend à la trouver. L'IA est un **invisible concierge**.
### R — Reverse (Inverser)
**Idée 31 : Reverse Search → "Serendipity Engine"**
Pousser l'idée 11 plus loin : au lieu de chercher, vous recevez une **"feed de sérendipité"** — un flux continu de notes que vous ne cherchiez pas mais qui sont exactement ce dont vous avez besoin. Comme un fil Instagram, mais pour vos propres notes. Alimenté par le contexte temporel, l'historique récent, et les patterns comportementaux.
---
## Top 10 — Sélection finale
_Classement par combinaison : innovation, faisabilité technique (avec l'architecture existante), et différenciation marché_
| # | Nom | Description courte | Pourquoi c'est un pépites |
|---|-----|--------------------|---------------------------|
| 1 | **Keep Daily** | Page quotidienne personnalisée : révision, connexion, perspective, insight émotionnel | Combine 4 features en 1 rituel quotidien. Mécanique de rétention gaming. Réutilise Memory Echo, reformulation, embeddings existants. |
| 2 | **Spaced Resurfacing** | Les notes réapparaissent au moment optimal basé sur la courbe d'oubli | Transforme Keep en système d'apprentissage actif. Personne ne fait ça dans les apps de notes. Utilise les embeddings existants. |
| 3 | **Mood Tapestry** | Analyse émotionnelle longitudinale des notes avec visualisation | Positionnement unique santé mentale x notes. Privacy-first = avantage compétitif vs apps cloud. |
| 4 | **Ghost Writer** | Autocomplétion dans votre style personnel appris par IA | Clone d'écriture local (Ollama) = pitch marketing puissant. Plus vous écrivez, mieux c'est. |
| 5 | **Note Health Score** | Dashboard de santé de votre base de connaissance (0-100 par note) | Gamification subtile. Encourage l'entretien actif. Utilise les métadonnées IA existantes. |
| 6 | **Thought Trajectory** | Visualisation de l'évolution de votre pensée sur un sujet dans le temps | "Spotify Wrapped pour votre cerveau". Unique sur le marché. Utilise la recherche sémantique existante. |
| 7 | **Notebook Audiobook** | Résumé audio TTS d'un notebook entier | Consommer ses notes en marchant. Simple techniquement. Forte demande mobile. |
| 8 | **Note-as-Question** | L'IA génère 3 questions que vous n'avez PAS posées après chaque note | Transforme l'écriture passive en pensée active. Aligné avec "Zero-Friction Intelligence". |
| 9 | **Serendipity Engine** | Feed de notes pertinentes que vous ne cherchiez pas | Remplace le search par la découverte. "La note vient à vous." Utilise embeddings + contexte temporel. |
| 10 | **Reflective Journal Mode** | Mode thérapie réflexive guidée par IA avec suivi émotionnel | Nouveau segment de marché. Positionnement hybride unique. Privacy-first = confiance. |
---
## Features Sélectionnées — Spécifications Détaillées
_Les 3 features retenues après validation utilisateur, avec analyse d'intégration technique dans l'architecture existante_
---
### Feature A : Mood Tapestry (Tapis Émotionnel)
**Vision :** L'IA analyse le sentiment de chaque note au fil du temps et tisse un "tapis" visuel des états émotionnels de l'utilisateur. Détection de cycles, tendances, et patterns récurrents.
#### Expérience Utilisateur
**Vue principale :** Un ruban coloré horizontal dans le sidebar ou une page dédiée `/mood`. Chaque pixel représente une note, colorée par sentiment (rouge=anxieux, bleu=calme, vert=positif, jaune=enthousiaste, gris=neutre). Le tout forme un gradient continu chronologique.
**Insights IA :** Sous le ruban, 2-3 insights générés par IA :
- "Tes notes du dimanche soir sont 40% plus anxieuses que la moyenne"
- "Depuis mars, tes notes sur le travail sont devenues plus positives"
- "Cycle détecté : pics d'anxiété tous les 14 jours environ"
**Badge note :** Chaque note reçoit un petit indicateur coloré de son sentiment dominant.
**Notification proactive :** "Cette semaine, ton Mood Tapestry montre une tendance inhabituelle vers le négatif. Voici 3 notes positives de ton passé qui pourraient t'aider."
#### Architecture Technique
**Nouveau service :** `lib/ai/services/mood-tapestry.service.ts`
```
MoodTapestryService
├── analyzeSentiment(content: string, language: string) → SentimentResult
│ → Appelle provider.generateText() avec prompt structuré
│ → Retourne: { valence: number, arousal: number, emotions: string[], confidence: number }
├── analyzeNoteBatch(userId: string) → MoodSnapshot[]
│ → Lit toutes les notes de l'utilisateur (avec langue détectée)
│ → Batch processing avec rate limiting
│ → Stocke résultats dans NoteSentiment (nouveau modèle)
├── generateInsights(userId: string) → MoodInsight[]
│ → Analyse les patterns temporels des sentiments stockés
│ → Détecte cycles, tendances, anomalies
│ → Appelle provider.generateText() pour formuler les insights en langage naturel
├── getMoodTimeline(userId: string, period: 'week'|'month'|'year') → MoodDataPoint[]
│ → Agrège les sentiments par jour/semaine pour la visualisation
```
**Nouveau modèle Prisma :**
```prisma
model NoteSentiment {
id String @id @default(cuid())
noteId String @unique
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
userId String
valence Float // -1.0 (négatif) à +1.0 (positif)
arousal Float // 0.0 (calme) à 1.0 (intensif)
dominantEmotion String // "anxious", "joyful", "calm", "frustrated", "curious", "nostalgic", "neutral"
emotions String // JSON array: ["curious", "hopeful", "anxious"]
confidence Float // 0.0-1.0
analyzedAt DateTime @default(now())
@@index([userId, analyzedAt])
@@index([dominantEmotion])
}
```
**Extensions Prisma existantes :**
```prisma
model UserAISettings {
// ... champs existants
moodTapestry Boolean @default(true) // ON/OFF toggle
moodTapestryFrequency String @default('weekly') // 'daily'|'weekly'|'monthly'
}
model AiFeedback {
// Réutiliser le modèle existant avec feature: 'mood_tapestry'
}
```
**Prompt IA (structure) :**
```
Analyze the emotional sentiment of this note. Return JSON:
{
"valence": <float -1 to 1>,
"arousal": <float 0 to 1>,
"dominant_emotion": "<one of: anxious|joyful|calm|frustrated|curious|nostalgic|sad|excited|neutral>",
"emotions": ["<emotion1>", "<emotion2>"],
"confidence": <float 0 to 1>,
"reasoning": "<brief explanation>"
}
Note content (in {language}):
"""
{content}
"""
```
**Points d'intégration :**
- **Trigger :** `analyzeSentiment()` appelé après la création/mise à jour d'une note (dans le même flow que `language-detection.service.ts`)
- **Batch :** Route admin ou cron pour analyser les notes existantes
- **UI :** Nouveau composant `components/ai/mood-tapestry.tsx` (ruban coloré + insights)
- **Settings :** Toggle dans `ai-settings-panel.tsx` + fréquence
**Faisabilité avec l'architecture existante :**
| Aspect | Compatible ? | Détail |
|--------|-------------|--------|
| Provider IA | ✅ | `provider.generateText()` — même pattern que paragraph-refactor |
| Embeddings existants | ✅ | Peut corréler sentiment avec similarité sémantique |
| Langue détectée | ✅ | `Note.language` déjà disponible pour adapter le prompt |
| Privacy-first | ✅ | Ollama local = analyse émotionnelle 100% locale |
| Feedback | ✅ | `AiFeedback` avec `feature: 'mood_tapestry'` |
**Risques :**
- L'analyse émotionnelle est subjective — le feedback utilisateur est critique pour ajuster
- Sur Ollama avec un petit modèle, la qualité sentiment peut être variable — prévoir un seuil de confiance minimum
---
### Feature B : Ghost Writer (Clone d'Écriture)
**Vision :** L'IA apprend votre style d'écriture à partir de toutes vos notes existantes. Quand vous écrivez, elle suggère des complétions dans VOTRE voix — pas une voix générique. Plus vous écrivez, plus elle vous ressemble.
#### Expérience Utilisateur
**Écriture assistée :** Dans l'éditeur de note, après chaque phrase, une suggestion fantôme (texte grisé) apparaît en inline — pas dans un panneau séparé. L'utilisateur appuie sur Tab pour accepter, Escape pour ignorer, ou continue à taper pour la remplacer.
**Voice Profile :** Dans les paramètres IA, un onglet "Mon Profil Vocal" montre :
- Votre ton dominant (formel, décontracté, technique, poétique)
- Longueur moyenne de phrases
- Vocabulaire préféré (top 20 mots distinctifs)
- Structures récurrentes (listes, questions, exclamations)
- Un bouton "Réanalyser mon style" pour recalibrer
**Apprentissage progressif :** Le profil s'améliore avec chaque note écrite. Un indicateur montre le "niveau de calibration" : "Profil basé sur 47 notes — Bonne calibration"
**Mode privacy :** Avec Ollama, le profil et les suggestions ne quittent jamais le serveur. Pitch marketing fort.
#### Architecture Technique
**Nouveau service :** `lib/ai/services/ghost-writer.service.ts`
```
GhostWriterService
├── buildVoiceProfile(userId: string) → VoiceProfile
│ → Lit les 50 dernières notes de l'utilisateur
│ → Analyse: longueur phrases, vocabulaire, structures, ton
│ → Appelle provider.generateText() pour extraire le profil stylistique
│ → Stocke dans VoiceProfile (nouveau modèle)
├── getSuggestion(userId: string, currentText: string, context: string) → string
│ → Charge le VoiceProfile de l'utilisateur
│ → Construit un prompt avec le profil + le texte en cours
│ → Appelle provider.generateText() avec maxTokens limité (50-100)
│ → Retourne la suggestion inline
├── updateProfileFromNote(userId: string, noteId: string) → void
│ → Après sauvegarde d'une note, met à jour incrémentalement le profil
│ → Recalcul: fréquence mots, patterns syntaxiques, ton
├── getVoiceProfileDisplay(userId: string) → VoiceProfileDisplay
│ → Retourne le profil formaté pour l'UI (ton, vocabulaire, stats)
```
**Nouveau modèle Prisma :**
```prisma
model VoiceProfile {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Profil stylistique extrait par IA
toneProfile String // JSON: { primary: "décontracté", secondary: "technique", formality: 0.3 }
vocabularyProfile String // JSON: { topWords: [...], avgWordLength: 5.2, uniqueRatio: 0.7 }
structureProfile String // JSON: { avgSentenceLength: 15, usesLists: true, usesQuestions: false, ... }
writingPatterns String // JSON: { commonPhrases: [...], paragraphLength: "medium", ... }
// Métadonnées
notesAnalyzed Int @default(0)
calibrationLevel String @default("low") // "low" | "medium" | "high" | "expert"
lastAnalyzedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
```
**Extensions Prisma existantes :**
```prisma
model UserAISettings {
// ... champs existants
ghostWriter Boolean @default(true) // ON/OFF toggle
}
```
**Prompt IA — Extraction de profil (buildVoiceProfile) :**
```
Analyze the writing style of these notes from the same author. Extract a writing profile as JSON:
{
"tone": { "primary": "<tone>", "secondary": "<tone>", "formality": <0-1> },
"vocabulary": { "top_words": ["<word>", ...], "avg_word_length": <number>, "unique_ratio": <0-1> },
"structure": { "avg_sentence_length": <number>, "uses_lists": <bool>, "uses_questions": <bool>, "paragraph_style": "<short|medium|long>" },
"patterns": { "common_phrases": ["<phrase>", ...], "punctuation_style": "<description>" }
}
Author's notes:
"""
{notes_concatenated}
"""
```
**Prompt IA — Suggestion inline (getSuggestion) :**
```
You are a ghost writer that perfectly mimics the author's writing style.
AUTHOR'S STYLE PROFILE:
- Tone: {toneProfile}
- Average sentence length: {avgSentenceLength} words
- Common phrases: {commonPhrases}
- The author writes in {language}
Continue this text in the author's EXACT voice. Write only the continuation (1-3 sentences), nothing else:
"""
{currentText}
"""
```
**Points d'intégration :**
- **Trigger :** `getSuggestion()` appelé après un délai d'inactivité de 1.5s dans l'éditeur (debounced)
- **Apprentissage :** `updateProfileFromNote()` appelé après sauvegarde d'une note
- **UI :** Nouveau composant `components/ai/ghost-writer-suggestion.tsx` (texte grisé inline dans l'éditeur)
- **Settings :** Toggle dans `ai-settings-panel.tsx` + page "Voice Profile"
- **API Route :** `app/api/ai/ghost-writer/route.ts` pour les suggestions en temps réel
**Faisabilité avec l'architecture existante :**
| Aspect | Compatible ? | Détail |
|--------|-------------|--------|
| Provider IA | ✅ | `provider.generateText()` — même pattern, mais nécessite low latency |
| Embeddings | ❌ pas nécessaire | Pas besoin d'embeddings pour cette feature |
| Notes existantes | ✅ | Source d'entraînement = toutes les notes de l'utilisateur |
| Privacy-first | ✅✅ | C'est LE cas d'usage parfait pour Ollama — votre style ne quitte pas le serveur |
| Feedback | ✅ | `AiFeedback` avec `feature: 'ghost_writer'` — accept/reject rate comme métrique |
**Risques :**
- **Latence critique :** Les suggestions inline doivent être < 500ms. Sur Ollama avec un petit modèle local, c'est jouable pour de courts textes. Sur OpenAI, c'est rapide mais moins privacy.
- **Qualité du profil :** Avec < 10 notes, le profil sera imprécis. Prévoir un minimum avant activation ou afficher un avertissement.
- **Sur-Ollama :** Les petits modèles (phi3-mini, etc.) peuvent avoir du mal à capturer un style subtil. Tester avec llama3.1+ pour de meilleurs résultats.
**Pitch marketing unique :**
> "Ghost Writer apprend votre voix. Votre clone d'écriture vit sur VOTRE serveur. Personne d'autre ne peut lire votre style — pas même nous. C'est le pouvoir de l'IA locale."
---
### Feature C : Thought Trajectory (Trajectoire de Pensée)
**Vision :** L'IA trace l'évolution de votre pensée sur un sujet au fil du temps. Vous visualisez comment vos idées ont branché, mergé, ou changé de direction. "Spotify Wrapped pour votre cerveau."
#### Expérience Utilisateur
**Vue Thinking Map :** Une page dédiée `/thinking-map` avec une visualisation interactive style "skill tree" ou constellation. Chaque noeud = un cluster de notes sur un sujet. Les liens montrent les connexions sémantiques. Le temps est représenté par la position ou la couleur (plus récent = plus lumineux).
**Timeline d'un sujet :** En cliquant sur un noeud, une vue détaillée montre l'évolution chronologique :
- "Mars 2025 : Première mention — curieux, exploratoire"
- "Juin 2025 : Approfondissement — 4 notes, ton plus technique"
- "Septembre 2025 : Changement — position plus critique"
- "Janvier 2026 : Consolidation — 2 notes de synthèse"
**Wrapped annuel/mensuel :** Une page "Mon Année en Pensées" style Spotify Wrapped :
- Top 5 sujets les plus explorés
- "Plus grande évolution" — sujet où votre pensée a le plus changé
- "Connexions surprises" — sujets éloignés que vous avez connectés
- Stats : nombre de notes, mots, langues utilisées, diversité émotionnelle
**Insight proactif :** "Ton opinion sur [X] a significativement changé ce mois-ci. Voir la trajectoire →"
#### Architecture Technique
**Nouveau service :** `lib/ai/services/thought-trajectory.service.ts`
```
ThoughtTrajectoryService
├── clusterNotesByTopic(userId: string) → TopicCluster[]
│ → Utilise les embeddings existants (Note.embedding)
│ → K-means ou clustering hiérarchique sur les vecteurs
│ → Identifie les topics/thèmes récurrents
│ → Appelle provider.generateText() pour nommer chaque cluster
├── buildTrajectory(userId: string, topicClusterId: string) → ThoughtTrajectory
│ → Trie les notes du cluster chronologiquement
│ → Analyse l'évolution sémantique entre les notes successives
│ → Détecte les "shifts" (changements de direction significatifs)
│ → Génère une narrative de l'évolution
├── detectEvolutionShifts(notes: Note[]) → EvolutionShift[]
│ → Compare les embeddings consécutifs dans un cluster
│ → Quand la distance cosinus dépasse un seuil → "shift" détecté
│ → provider.generateText() pour décrire le changement
├── generateWrapped(userId: string, period: 'month'|'year') → ThoughtWrapped
│ → Récapitulatif statistique + narratif
│ → Top topics, plus grande évolution, connexions surprises
│ → provider.generateText() pour la narration
├── getThinkingMap(userId: string) → ThinkingMapData
│ → Retourne les clusters + liens pour React Flow
│ → Positionnement basé sur similarité (t-SNE simplifié ou force-directed)
```
**Nouveau modèle Prisma :**
```prisma
model TopicCluster {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Nom généré par IA : "Intelligence Artificielle"
description String? // Description IA du cluster
centroidEmbedding String? // Embedding moyen du cluster (JSON number[])
noteIds String // JSON array de note IDs
// Stats
noteCount Int @default(0)
firstNoteAt DateTime
lastNoteAt DateTime
trajectorySummary String? // Résumé de l'évolution narrative
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([userId, lastNoteAt])
}
model ThoughtShift {
id String @id @default(cuid())
userId String
clusterId String
cluster TopicCluster @relation(fields: [clusterId], references: [id], onDelete: Cascade)
fromNoteId String
toNoteId String
semanticDistance Float // Distance cosinus entre les deux notes
description String // Description IA du shift : "Passage d'une position optimiste à critique"
shiftType String // "deepening" | "reversal" | "branching" | "consolidation" | "abandonment"
createdAt DateTime @default(now())
@@index([clusterId])
@@index([userId])
}
```
**Extensions Prisma existantes :**
```prisma
model UserAISettings {
// ... champs existants
thoughtTrajectory Boolean @default(true) // ON/OFF toggle
}
```
**Algorithme de clustering (simplifié) :**
```
1. Charger tous les embeddings de notes de l'utilisateur (déjà en DB)
2. Calculer la matrice de similarité cosinus (O(n²) — acceptable jusqu'à ~1000 notes)
3. Appliquer un clustering agglomératif :
- Seuil de similarité > 0.65 → même cluster
- Si un cluster dépasse 20 notes → sous-clustering
4. Pour chaque cluster, appeler provider.generateText() avec les titres/résumés pour nommer le topic
5. Stocker les résultats dans TopicCluster
```
**Algorithme de trajectoire :**
```
1. Trier les notes du cluster par date
2. Pour chaque paire de notes consécutives, calculer distance cosinus
3. Si distance > 0.3 → "shift" détecté → appeler IA pour décrire le changement
4. Classifier le shift : deepening (même direction), reversal (changement), branching (nouvel angle)
5. Générer un résumé narratif : "Vous avez commencé par X, puis évolué vers Y"
```
**Points d'intégration :**
- **Trigger :** Recalcul périodique (cron ou on-demand) — pas en temps réel
- **Embeddings :** Utilise les embeddings DÉJÀ stockés dans `Note.embedding` — zéro coût IA supplémentaire pour le clustering
- **IA générative :** Uniquement pour nommer les clusters, décrire les shifts, et générer les narrations
- **UI :** `app/thinking-map/page.tsx` avec React Flow (déjà prévu dans le roadmap Memory Echo V2)
- **Settings :** Toggle dans `ai-settings-panel.tsx`
**Faisabilité avec l'architecture existante :**
| Aspect | Compatible ? | Détail |
|--------|-------------|--------|
| Embeddings existants | ✅✅ | Cœur de la feature — `Note.embedding` déjà disponible |
| Recherche sémantique | ✅ | `cosineSimilarity()` déjà implémenté dans utils |
| Memory Echo | ✅ | Complémentaire — Echo trouve des connexions, Trajectory montre l'évolution |
| React Flow | ✅ | Déjà prévu dans le roadmap Memory Echo V2 |
| Provider IA | ✅ | Uniquement pour naming et narration — faible usage |
| Privacy-first | ✅ | Calculs de similarité entièrement locaux |
**Risques :**
- **Performance O(n²) :** La matrice de similarité sur 1000+ notes sera lente. Solution : pré-calculer et cacher les clusters, recalcul mensuel seulement.
- **Qualité du clustering :** Le seuil de similarité est empirique. Prévoir un mode "demo" avec seuil abaissé (comme Memory Echo).
- **Embeddings manquants :** Les notes sans embeddings seront invisibles. Nécessite un batch embedding préalable.
---
## Matrice de Dépendances entre Features
```
Mood Ghost Thought
Tapestry Writer Trajectory
─────── ──────── ──────────
Note.embedding - ❌ ✅✅
Note.language ✅ - -
provider.generate ✅✅ ✅✅ ✅
provider.embeddings - ❌ ✅✅
cosineSimilarity - ❌ ✅✅
React Flow ❌ ❌ ✅
Nouveau modèle DB ✅ ✅ ✅✅
UserAISettings ✅ ✅ ✅
AiFeedback ✅ ✅ ✅
```
## Ordre d'Implémentation Recommandé
1. **Mood Tapestry** en premier — le plus autonome, pas de dépendance aux embeddings, feedback rapide
2. **Ghost Writer** en second — nécessite un volume minimum de notes, mais architecture simple
3. **Thought Trajectory** en dernier — le plus complexe (clustering + React Flow), mais capitalise sur Mood Tapestry (corrélation sentiment/évolution) et les embeddings existants
---
## Références et Sources d'Inspiration
- **Google NotebookLM** — AI podcast generation from notes
- **Mem.ai** — Self-organizing AI memory
- **Limitless (Rewind)** — Ambient capture
- **Anki / SuperMemo** — Spaced repetition algorithms
- **Personal.ai** — Digital twin / personal AI clone
- **Whoop / Oura Ring** — Recovery scores, nudge theory, JITAI
- **Spotify Wrapped / DNA** — Personalized data storytelling
- **Reflect** — Frictionless AI voice notes
- **Khoj** — Open-source AI personal assistant
- **Fabric** — AI-first second brain

View File

@@ -0,0 +1,21 @@
---
stepsCompleted: [step-01-init]
inputDocuments:
- _bmad-output/brainstorming/brainstorming-session-2026-04-13-133700.md
- _bmad-output/planning-artifacts/project-context.md
- _bmad-output/planning-artifacts/prd.md
- docs/project-overview.md
workflowType: 'prd'
documentCounts:
brainstorming: 1
projectContext: 1
existingPrd: 1
projectDocs: 1
briefs: 0
research: 0
---
# Product Requirements Document - Keep
**Author:** Ramez
**Date:** 2026-04-13

View File

@@ -0,0 +1,364 @@
# Guide Utilisateur : MCP dans Keep Notes
## Table des matières
1. [Qu'est-ce que MCP ? (en termes simples)](#1-quest-ce-que-mcp--en-termes-simples)
2. [Comment ça marche dans Keep Notes](#2-comment-ca-marche-dans-keep-notes)
3. [Les clés API : à quoi ça sert](#3-les-cles-api--a-quoi-ca-sert)
4. [Gérer ses clés API depuis l'interface](#4-gerer-ses-cles-api-depuis-linterface)
5. [Configurer Claude Code](#5-configurer-claude-code)
6. [Configurer Cursor](#6-configurer-cursor)
7. [Configurer N8N](#7-configurer-n8n)
8. [Questions fréquentes](#8-questions-frequentes)
---
## 1. Qu'est-ce que MCP ? (en termes simples)
**MCP** = **Model Context Protocol** (Protocole de Contexte de Modèle).
C'est un standard qui permet à des outils IA (comme Claude, Cursor, N8N) de **parler** à votre application Keep Notes.
### Analogie simple
Imaginez que Keep Notes est un restaurant. Normalement, pour lire le menu ou commander, vous devez ouvrir l'application web dans votre navigateur.
MCP, c'est comme un **service de livraison** : il permet à d'autres applications de passer des commandes directement au restaurant, sans ouvrir le site web. Votre clé API, c'est votre **numéro de client** qui prouve que vous avez le droit de commander.
### Concrètement, que permet MCP ?
Avec MCP, vous pouvez depuis Claude Code, Cursor ou N8N :
- **Lire** vos notes existantes
- **Créer** de nouvelles notes
- **Rechercher** dans vos notes (recherche sémantique IA)
- **Organiser** vos notes dans des carnets
- **Gérer** vos étiquettes
- **Générer des titres** avec l'IA
- Et 30+ autres actions
### Les deux modes de connexion
Votre serveur MCP Keep Notes peut fonctionner de deux façons :
| Mode | Comment ça marche | Pour qui | Clé API nécessaire ? |
|------|-------------------|----------|----------------------|
| **Stdio** | L'outil IA lance directement le serveur MCP en arrière-plan | Claude Code, Cursor (local) | Non |
| **HTTP** | Le serveur MCP tourne en continu sur le port 3001, les outils s'y connectent via le réseau | N8N, Cursor (distant), tout outil réseau | Oui |
---
## 2. Comment ça marche dans Keep Notes
Votre projet Keep Notes contient **deux serveurs** qui partagent la même base de données :
```
Keep/
├── keep-notes/ ← Application web (port 3000)
│ └── prisma/dev.db ← Base de données SQLite
└── mcp-server/ ← Serveur MCP (port 3001 en mode HTTP)
├── index.js ← Mode Stdio
├── index-sse.js ← Mode HTTP
└── tools.js ← Les 37 outils disponibles
```
**Important** : Les deux serveurs accèdent à la **même base de données**. Quand vous créez une note via MCP, elle apparaît immédiatement dans l'interface web, et inversement.
---
## 3. Les clés API : à quoi ça sert
Une **clé API** est un mot de passe secret qui permet à un outil externe de prouver son identité au serveur MCP.
### Quand vous avez besoin d'une clé API
- **Mode HTTP** (N8N, Cursor distant, etc.) : **OUI**, vous devez fournir votre clé API à chaque requête.
- **Mode Stdio** (Claude Code, Cursor local) : **NON**, l'outil accède directement à la base de données.
### Format d'une clé
Les clés générées par Keep Notes ressemblent à ça :
```
mcp_sk_0f0fe746d34dabdf7370d06352a2fd5fabf5994d2a980a31
```
- Préfixe `mcp_sk_` (pour "MCP Secret Key")
- Suivi de 48 caractères hexadécimaux
### Securité
- La clé brute n'est affichée **qu'une seule fois** au moment de sa création
- En base de données, seul le **hash SHA-256** est stocké (comme un mot de passe)
- Personne (même pas vous) ne peut retrouver la clé brute après fermeture de la fenêtre
- Si vous perdez une clé, il faut la révoquer et en générer une nouvelle
---
## 4. Gérer ses clés API depuis l'interface
### Accéder à la page MCP
1. Ouvrez Keep Notes dans votre navigateur (`http://localhost:3000`)
2. Cliquez sur **Paramètres** (icône engrenage)
3. Dans le menu latéral, cliquez sur **MCP Settings** (icône clé)
### Générer une nouvelle clé
1. Cliquez sur le bouton **"Générer une nouvelle clé"**
2. Donnez un nom à votre clé (ex: "Mon Claude Code", "N8N Prod")
3. Cliquez sur **"Générer"**
4. **Copiez immédiatement la clé** — elle ne sera plus jamais visible
5. Cliquez sur **"Terminé"**
### Gérer vos clés existantes
Pour chaque clé, vous verrez :
- Le **nom** que vous lui avez donné
- La **date de création**
- La **date de dernière utilisation**
- Le **statut** : Active (verte) ou Révoquée (grise)
#### Révoquer une clé
La révocation **désactive** la clé sans la supprimer. L'outil qui l'utilisait perdra son accès.
> Utile si vous pensez qu'une clé a été compromise mais voulez garder un historique.
#### Supprimer une clé
La suppression est **définitive**. La clé et son historique disparaissent.
---
## 5. Configurer Claude Code
Claude Code est l'outil en ligne de commande de Claude (celui que vous utilisez actuellement).
### Méthode recommandée : Mode Stdio (sans clé API)
Avec le mode stdio, Claude Code lance le serveur MCP directement. Pas besoin de clé API car il accède à la base SQLite directement.
#### Étape 1 : Ouvrir la configuration MCP
Dans votre terminal Claude Code, tapez :
```
/mcp
```
#### Étape 2 : Ajouter le serveur
Choisissez d'ajouter un nouveau serveur et collez cette configuration :
```json
{
"mcpServers": {
"keep-notes": {
"command": "node",
"args": ["/Users/sepehr/dev/Keep/mcp-server/index.js"],
"env": {
"DATABASE_URL": "file:/Users/sepehr/dev/Keep/keep-notes/prisma/dev.db",
"APP_BASE_URL": "http://localhost:3000"
}
}
}
}
```
#### Explication de chaque champ
| Champ | Valeur | À quoi ça sert |
|-------|--------|----------------|
| `command` | `"node"` | Lance Node.js |
| `args` | `["/chemin/vers/mcp-server/index.js"]` | Chemin vers le serveur MCP (mode stdio) |
| `env.DATABASE_URL` | `"file:/chemin/vers/prisma/dev.db"` | Où trouver la base de données |
| `env.APP_BASE_URL` | `"http://localhost:3000"` | URL de l'app web (pour les fonctions IA) |
#### Étape 3 : Vérifier
Redémarrez Claude Code. Tapez un message demandant d'utiliser un outil Keep Notes, par exemple :
> "Liste mes notes Keep Notes"
Claude Code devrait automatiquement détecter les outils MCP disponibles.
### Méthode alternative : fichier de configuration
Vous pouvez aussi éditer directement le fichier de config :
```bash
# Configuration globale (tous les projets)
nano ~/.claude.json
# OU configuration par projet
nano /Users/sepehr/dev/Keep/.claude/settings.local.json
```
Ajoutez le bloc `mcpServers` dans le fichier JSON.
---
## 6. Configurer Cursor
Cursor est un éditeur de code avec IA intégrée qui supporte MCP.
### Mode local (Stdio — sans clé API)
Ouvrez les paramètres MCP dans Cursor (`Settings` > `MCP`) et ajoutez :
```json
{
"mcpServers": {
"keep-notes": {
"command": "node",
"args": ["/Users/sepehr/dev/Keep/mcp-server/index.js"],
"env": {
"DATABASE_URL": "file:/Users/sepehr/dev/Keep/keep-notes/prisma/dev.db",
"APP_BASE_URL": "http://localhost:3000"
}
}
}
}
```
C'est **exactement la même config** que Claude Code en mode stdio.
### Mode distant (HTTP — avec clé API)
Si le serveur MCP tourne sur une autre machine ou si vous préférez le mode HTTP :
#### 1. Démarrez le serveur MCP en mode HTTP
```bash
cd /Users/sepehr/dev/Keep/mcp-server
node index-sse.js
```
Le serveur démarre sur `http://localhost:3001`.
#### 2. Configurez Cursor
```json
{
"mcpServers": {
"keep-notes": {
"url": "http://localhost:3001/mcp",
"headers": {
"x-api-key": "mcp_sk_VOTRE_CLE_API_ICI"
}
}
}
}
```
Remplacez `mcp_sk_VOTRE_CLE_API_ICI` par votre vraie clé (générée depuis `/settings/mcp`).
---
## 7. Configurer N8N
N8N est un outil d'automatisation qui peut utiliser MCP pour interagir avec vos notes.
### Prérequis
1. Le serveur MCP doit tourner en mode HTTP :
```bash
cd /Users/sepehr/dev/Keep/mcp-server
node index-sse.js
```
2. Vous devez avoir généré une clé API depuis `/settings/mcp`
### Configuration dans N8N
#### Étape 1 : Ajouter un nœud MCP Client
Dans votre workflow N8N, ajoutez un nœud **"MCP Client"**.
#### Étape 2 : Configurer la connexion
| Paramètre | Valeur |
|-----------|--------|
| **Transport** | HTTP Streamable |
| **URL** | `http://VOTRE_IP:3001/mcp` |
| **Authentication** | API Key |
| **Header** | `x-api-key` |
| **Valeur** | `mcp_sk_VOTRE_CLE_API_ICI` |
#### Trouver votre IP locale
```bash
# macOS
ifconfig | grep "inet " | grep -v 127.0.0.1
# Résultat typique : 192.168.1.XX
```
Utilisez cette IP (pas `localhost`) si N8N tourne sur une autre machine.
#### Étape 3 : Utiliser un outil
Après connexion, N8N charge la liste des 37 outils disponibles. Choisissez celui que vous voulez utiliser, par exemple :
- `create_note` — Créer une note
- `search_notes` — Rechercher des notes
- `get_notes` — Lister les notes
### Exemple de workflow
**Workflow "Sauvegarder un article web en note"** :
```
Webhook (reçoit un article)
→ MCP Client : create_note
title = article.title
content = article.body
labels = ["article", "web"]
```
---
## 8. Questions fréquentes
### J'ai perdu ma clé API, que faire ?
Les clés ne sont jamais stockées en clair. Si vous avez perdu une clé :
1. Allez dans `/settings/mcp`
2. **Révoquez** l'ancienne clé
3. **Générez** une nouvelle clé
4. **Copiez-la** immédiatement
5. Mettez à jour la config de votre outil (Claude Code, Cursor, N8N)
### Peut-on utiliser MCP sans clé API ?
**Oui**, en mode stdio. C'est le cas pour Claude Code et Cursor configurés en local. La clé API n'est nécessaire que pour le mode HTTP distant.
### Le serveur MCP doit-il tourner en permanence ?
- **Mode Stdio** : Non. L'outil (Claude Code/Cursor) lance le serveur automatiquement quand il en a besoin.
- **Mode HTTP** : Oui. Le serveur doit tourner en continu (`node index-sse.js`) pour que N8N et les autres outils puissent s'y connecter.
### Mes notes sont-elles en sécurité ?
- Les clés API sont hashées (SHA-256) en base de données, comme des mots de passe
- Le serveur MCP accède à la même base que l'app web — les permissions utilisateur sont respectées
- En mode HTTP, le trafic peut être sécurisé avec HTTPS (configuration du reverse proxy)
### Combien de clés API puis-je créer ?
Pas de limite. Il est recommandé de créer une clé par outil (une pour Claude Code, une pour N8N, etc.) pour pouvoir les révoquer indépendamment.
### Quels sont les 37 outils disponibles ?
| Catégorie | Outils |
|-----------|--------|
| **Notes** | `create_note`, `get_note`, `get_notes`, `update_note`, `delete_note`, `search_notes`, `toggle_pin`, `toggle_archive`, `export_notes`, `import_notes`, `restore_note` |
| **Carnets** | `create_notebook`, `get_notebooks`, `get_notebook`, `update_notebook`, `delete_notebook`, `reorder_notebooks` |
| **Etiquettes** | `create_label`, `get_labels`, `update_label`, `delete_label` |
| **IA** | `generate_title_suggestions`, `reformulate_text`, `generate_tags`, `fuse_notes`, `batch_organize`, `generate_notebook_summary`, etc. |
| **Rappels** | `get_due_reminders` |
| **Clés API** | `generate_api_key`, `list_api_keys`, `revoke_api_key` |

View File

@@ -24,7 +24,7 @@ export default async function MainLayout({
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-r border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} /> <Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-r border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
{/* Main Content Area */} {/* Main Content Area */}
<main className="flex-1 overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth"> <main className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
{children} {children}
</main> </main>
</div> </div>

View File

@@ -6,7 +6,8 @@ import { Note } from '@/lib/types'
import { getAllNotes, searchNotes } from '@/app/actions/notes' import { getAllNotes, searchNotes } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings' import { getAISettings } from '@/app/actions/ai-settings'
import { NoteInput } from '@/components/note-input' import { NoteInput } from '@/components/note-input'
import { MasonryGrid } from '@/components/masonry-grid' import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
import { MemoryEchoNotification } from '@/components/memory-echo-notification' import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { NoteEditor } from '@/components/note-editor' import { NoteEditor } from '@/components/note-editor'
@@ -25,6 +26,7 @@ import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Hea
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter' import { LabelFilter } from '@/components/label-filter'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { useHomeView } from '@/context/home-view-context'
export default function HomePage() { export default function HomePage() {
@@ -36,12 +38,14 @@ export default function HomePage() {
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([]) const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
const [recentNotes, setRecentNotes] = useState<Note[]>([]) const [recentNotes, setRecentNotes] = useState<Note[]>([])
const [showRecentNotes, setShowRecentNotes] = useState(true) const [showRecentNotes, setShowRecentNotes] = useState(true)
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>('masonry')
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null) const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null) const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false) const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const { refreshKey } = useNoteRefresh() const { refreshKey } = useNoteRefresh()
const { labels } = useLabels() const { labels } = useLabels()
const { setControls } = useHomeView()
// Auto label suggestion (IA4) // Auto label suggestion (IA4)
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion() const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
@@ -159,15 +163,23 @@ export default function HomePage() {
const load = async () => { const load = async () => {
// Load settings first // Load settings first
let showRecent = true let showRecent = true
let viewMode: NotesViewMode = 'masonry'
try { try {
const settings = await getAISettings() const settings = await getAISettings()
if (cancelled) return if (cancelled) return
showRecent = settings?.showRecentNotes !== false showRecent = settings?.showRecentNotes !== false
viewMode =
settings?.notesViewMode === 'masonry'
? 'masonry'
: settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list'
? 'tabs'
: 'masonry'
} catch { } catch {
// Default to true on error // Default to true on error
} }
if (cancelled) return if (cancelled) return
setShowRecentNotes(showRecent) setShowRecentNotes(showRecent)
setNotesViewMode(viewMode)
// Then load notes // Then load notes
setIsLoading(true) setIsLoading(true)
@@ -247,6 +259,14 @@ export default function HomePage() {
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook')) const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
const [showNoteInput, setShowNoteInput] = useState(false) const [showNoteInput, setShowNoteInput] = useState(false)
useEffect(() => {
setControls({
isTabsMode: notesViewMode === 'tabs',
openNoteComposer: () => setShowNoteInput(true),
})
return () => setControls(null)
}, [notesViewMode, setControls])
// Get icon component for header // Get icon component for header
const getNotebookIcon = (iconName: string) => { const getNotebookIcon = (iconName: string) => {
const ICON_MAP: Record<string, any> = { const ICON_MAP: Record<string, any> = {
@@ -282,11 +302,23 @@ export default function HomePage() {
</div> </div>
) )
const isTabs = notesViewMode === 'tabs'
return ( return (
<main className="w-full px-8 py-6 flex flex-col h-full"> <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 */} {/* Notebook Specific Header */}
{currentNotebook ? ( {currentNotebook ? (
<div className="flex flex-col gap-6 mb-8 animate-in fade-in slide-in-from-top-2 duration-300"> <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 */} {/* Breadcrumbs */}
<Breadcrumbs notebookName={currentNotebook.name} /> <Breadcrumbs notebookName={currentNotebook.name} />
@@ -308,7 +340,8 @@ export default function HomePage() {
</div> </div>
{/* Actions Section */} {/* Actions Section */}
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter <LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []} selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => { onFilterChange={(newLabels) => {
@@ -319,21 +352,28 @@ export default function HomePage() {
}} }}
className="border-gray-200" className="border-gray-200"
/> />
{!isTabs && (
<Button <Button
onClick={() => setShowNoteInput(!showNoteInput)} 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" 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" /> <Plus className="w-5 h-5" />
Add Note {t('notes.addNote') || 'Add Note'}
</Button> </Button>
)}
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
/* Default Header for Home/Inbox */ /* Default Header for Home/Inbox */
<div className="flex flex-col gap-6 mb-8 animate-in fade-in slide-in-from-top-2 duration-300"> <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 Placeholder or just spacing */} {/* Breadcrumbs Placeholder or just spacing */}
<div className="h-5 mb-1"></div> {!isTabs && <div className="mb-1 h-5" />}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
{/* Title Section */} {/* Title Section */}
@@ -345,7 +385,8 @@ export default function HomePage() {
</div> </div>
{/* Actions Section */} {/* Actions Section */}
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter <LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []} selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => { onFilterChange={(newLabels) => {
@@ -370,6 +411,7 @@ export default function HomePage() {
</Button> </Button>
)} )}
{!isTabs && (
<Button <Button
onClick={() => setShowNoteInput(!showNoteInput)} 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" className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
@@ -377,18 +419,23 @@ export default function HomePage() {
<Plus className="w-5 h-5" /> <Plus className="w-5 h-5" />
{t('notes.newNote')} {t('notes.newNote')}
</Button> </Button>
)}
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* Note Input - Conditionally Visible or Always Visible on Home */}
{/* Note Input - Conditionally Rendered */}
{showNoteInput && ( {showNoteInput && (
<div className="mb-8 animate-in fade-in slide-in-from-top-4 duration-300"> <div
className={cn(
'animate-in fade-in slide-in-from-top-4 duration-300',
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
)}
>
<NoteInput <NoteInput
onNoteCreated={handleNoteCreatedWrapper} onNoteCreated={handleNoteCreatedWrapper}
forceExpanded={true} forceExpanded={true}
fullWidth={isTabs}
/> />
</div> </div>
)} )}
@@ -397,26 +444,25 @@ export default function HomePage() {
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div> <div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : ( ) : (
<> <>
{/* Favorites Section - Pinned Notes */}
<FavoritesSection <FavoritesSection
pinnedNotes={pinnedNotes} pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })} onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/> />
{/* Recent Notes Section - Only shown if enabled in settings */} {!isTabs && showRecentNotes && (
{showRecentNotes && (
<RecentNotesSection <RecentNotesSection
recentNotes={recentNotes} recentNotes={recentNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })} onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/> />
)} )}
{/* Main Notes Grid - Unpinned Notes Only */} {notes.filter((note) => !note.isPinned).length > 0 && (
{notes.filter(note => !note.isPinned).length > 0 && ( <div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<div data-testid="notes-grid"> <NotesMainSection
<MasonryGrid viewMode={notesViewMode}
notes={notes.filter(note => !note.isPinned)} notes={notes.filter((note) => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })} onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
currentNotebookId={searchParams.get('notebook')}
/> />
</div> </div>
)} )}
@@ -473,6 +519,6 @@ export default function HomePage() {
onClose={() => setEditingNote(null)} onClose={() => setEditingNote(null)}
/> />
)} )}
</main> </div>
) )
} }

View File

@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
interface AppearanceSettingsClientProps {
initialFontSize: string
initialTheme: string
initialNotesViewMode: 'masonry' | 'tabs'
}
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode }: AppearanceSettingsClientProps) {
const { t } = useLanguage()
const [theme, setTheme] = useState(initialTheme || 'light')
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
root.classList.add('dark')
} else {
root.setAttribute('data-theme', value)
if (['midnight'].includes(value)) root.classList.add('dark')
}
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleFontSizeChange = async (value: string) => {
setFontSize(value)
const fontSizeMap: Record<string, string> = {
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
}
document.documentElement.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
await updateAISettings({ fontSize: value as any })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleNotesViewChange = async (value: string) => {
const mode = value === 'tabs' ? 'tabs' : 'masonry'
setNotesViewMode(mode)
await updateAISettings({ notesViewMode: mode })
toast.success(t('settings.settingsSaved') || 'Saved')
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('appearance.description')}
</p>
</div>
<SettingsSection
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label={t('settings.theme')}
description={t('appearance.selectTheme')}
value={theme}
options={[
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
value={fontSize}
options={[
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
<SettingsSection
title={t('appearance.notesViewLabel')}
icon={<span className="text-2xl">📋</span>}
description={t('appearance.notesViewDescription')}
>
<SettingSelect
label={t('appearance.notesViewLabel')}
description={t('appearance.notesViewDescription')}
value={notesViewMode}
options={[
{ value: 'masonry', label: t('appearance.notesViewMasonry') },
{ value: 'tabs', label: t('appearance.notesViewTabs') },
]}
onChange={handleNotesViewChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -1,113 +1,25 @@
'use client' import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { getAISettings } from '@/app/actions/ai-settings'
import { getUserSettings } from '@/app/actions/user-settings'
import { AppearanceSettingsClient } from './appearance-settings-client'
import { useState, useEffect } from 'react' export default async function AppearanceSettingsPage() {
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings' const session = await auth()
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings' if (!session?.user) {
import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings' redirect('/api/auth/signin')
import { useLanguage } from '@/lib/i18n' }
export default function AppearanceSettingsPage() {
const { t } = useLanguage()
const [theme, setTheme] = useState('auto')
const [fontSize, setFontSize] = useState('medium')
// Load settings on mount
useEffect(() => {
async function loadSettings() {
try {
const [aiSettings, userSettings] = await Promise.all([ const [aiSettings, userSettings] = await Promise.all([
getAISettings(), getAISettings(),
getUserSettings() getUserSettings()
]) ])
if (aiSettings.fontSize) setFontSize(aiSettings.fontSize)
if (userSettings.theme) setTheme(userSettings.theme)
} catch (error) {
console.error('Error loading settings:', error)
}
}
loadSettings()
}, [])
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
// Instant visual update
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
root.classList.add('dark')
} else {
root.setAttribute('data-theme', value)
if (['midnight'].includes(value)) root.classList.add('dark')
}
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
}
const handleFontSizeChange = async (value: string) => {
setFontSize(value)
// Instant visual update
const fontSizeMap: Record<string, string> = {
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
}
const root = document.documentElement
root.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
await updateAISettings({ fontSize: value as any })
}
return ( return (
<div className="space-y-6"> <AppearanceSettingsClient
<div> initialFontSize={aiSettings.fontSize}
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1> initialTheme={userSettings.theme}
<p className="text-gray-600 dark:text-gray-400"> initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
{t('appearance.description')}
</p>
</div>
<SettingsSection
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label={t('settings.theme')}
description={t('settings.selectLanguage')}
value={theme}
options={[
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
onChange={handleThemeChange}
/> />
</SettingsSection>
<SettingsSection
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
value={fontSize}
options={[
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
</div>
) )
} }

View File

@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
interface GeneralSettingsClientProps {
initialSettings: {
preferredLanguage: string
emailNotifications: boolean
desktopNotifications: boolean
anonymousAnalytics: boolean
}
}
export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) {
const { t, setLanguage: setContextLanguage } = useLanguage()
const router = useRouter()
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
const [anonymousAnalytics, setAnonymousAnalytics] = useState(initialSettings.anonymousAnalytics ?? false)
const handleLanguageChange = async (value: string) => {
setLanguage(value)
await updateAISettings({ preferredLanguage: value as any })
if (value === 'auto') {
localStorage.removeItem('user-language')
toast.success(t('settings.languageAuto') || 'Language set to Auto')
} else {
localStorage.setItem('user-language', value)
setContextLanguage(value as any)
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
}
setTimeout(() => router.refresh(), 300)
}
const handleEmailNotificationsChange = async (enabled: boolean) => {
setEmailNotifications(enabled)
await updateAISettings({ emailNotifications: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleDesktopNotificationsChange = async (enabled: boolean) => {
setDesktopNotifications(enabled)
await updateAISettings({ desktopNotifications: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleAnonymousAnalyticsChange = async (enabled: boolean) => {
setAnonymousAnalytics(enabled)
await updateAISettings({ anonymousAnalytics: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('generalSettings.description')}
</p>
</div>
<SettingsSection
title={t('settings.language')}
icon={<span className="text-2xl">🌍</span>}
description={t('profile.languagePreferencesDescription')}
>
<SettingSelect
label={t('settings.language')}
description={t('settings.selectLanguage')}
value={language}
options={[
{ value: 'auto', label: t('profile.autoDetect') },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fa', label: 'فارسی' },
{ value: 'it', label: 'Italiano' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'ar', label: 'العربية' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
]}
onChange={handleLanguageChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.notifications')}
icon={<span className="text-2xl">🔔</span>}
description={t('settings.notificationsDesc')}
>
<SettingToggle
label={t('settings.emailNotifications')}
description={t('settings.emailNotificationsDesc')}
checked={emailNotifications}
onChange={handleEmailNotificationsChange}
/>
<SettingToggle
label={t('settings.desktopNotifications')}
description={t('settings.desktopNotificationsDesc')}
checked={desktopNotifications}
onChange={handleDesktopNotificationsChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.privacy')}
icon={<span className="text-2xl">🔒</span>}
description={t('settings.privacyDesc')}
>
<SettingToggle
label={t('settings.anonymousAnalytics')}
description={t('settings.anonymousAnalyticsDesc')}
checked={anonymousAnalytics}
onChange={handleAnonymousAnalyticsChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -1,142 +1,15 @@
'use client' import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { getAISettings } from '@/app/actions/ai-settings'
import { GeneralSettingsClient } from './general-settings-client'
import { useState, useEffect } from 'react' export default async function GeneralSettingsPage() {
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings' const session = await auth()
import { useLanguage } from '@/lib/i18n' if (!session?.user) {
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings' redirect('/api/auth/signin')
import { toast } from 'sonner' }
import { useRouter } from 'next/navigation'
export default function GeneralSettingsPage() {
const { t, setLanguage: setContextLanguage } = useLanguage()
const router = useRouter()
const [language, setLanguage] = useState('auto')
const [emailNotifications, setEmailNotifications] = useState(false)
const [desktopNotifications, setDesktopNotifications] = useState(false)
const [anonymousAnalytics, setAnonymousAnalytics] = useState(false)
// Load settings on mount
useEffect(() => {
async function loadSettings() {
try {
const settings = await getAISettings() const settings = await getAISettings()
if (settings.preferredLanguage) setLanguage(settings.preferredLanguage)
if (settings.emailNotifications !== undefined) setEmailNotifications(settings.emailNotifications) return <GeneralSettingsClient initialSettings={settings} />
if (settings.desktopNotifications !== undefined) setDesktopNotifications(settings.desktopNotifications)
if (settings.anonymousAnalytics !== undefined) setAnonymousAnalytics(settings.anonymousAnalytics)
} catch (error) {
console.error('Error loading settings:', error)
}
}
loadSettings()
}, [])
const handleLanguageChange = async (value: string) => {
setLanguage(value)
// 1. Update database settings
await updateAISettings({ preferredLanguage: value as any })
// 2. Update local storage and application state
if (value === 'auto') {
localStorage.removeItem('user-language')
toast.success("Language set to Auto")
} else {
localStorage.setItem('user-language', value)
setContextLanguage(value as any)
toast.success(t('profile.languageUpdateSuccess') || "Language updated")
}
// 3. Refresh server components to ensure all components update (metadata, etc.)
setTimeout(() => router.refresh(), 500)
}
const handleEmailNotificationsChange = async (enabled: boolean) => {
setEmailNotifications(enabled)
await updateAISettings({ emailNotifications: enabled })
}
const handleDesktopNotificationsChange = async (enabled: boolean) => {
setDesktopNotifications(enabled)
await updateAISettings({ desktopNotifications: enabled })
}
const handleAnonymousAnalyticsChange = async (enabled: boolean) => {
setAnonymousAnalytics(enabled)
await updateAISettings({ anonymousAnalytics: enabled })
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('generalSettings.description')}
</p>
</div>
<SettingsSection
title={t('settings.language')}
icon={<span className="text-2xl">🌍</span>}
description={t('profile.languagePreferencesDescription')}
>
<SettingSelect
label={t('settings.language')}
description={t('settings.selectLanguage')}
value={language}
options={[
{ value: 'auto', label: t('profile.autoDetect') },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fa', label: 'فارسی' },
{ value: 'it', label: 'Italiano' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'ar', label: 'العربية' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
]}
onChange={handleLanguageChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.notifications')}
icon={<span className="text-2xl">🔔</span>}
description={t('settings.notifications')}
>
<SettingToggle
label={t('settings.notifications')}
description={t('settings.notifications')}
checked={emailNotifications}
onChange={handleEmailNotificationsChange}
/>
<SettingToggle
label={t('settings.notifications')}
description={t('settings.notifications')}
checked={desktopNotifications}
onChange={handleDesktopNotificationsChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.privacy')}
icon={<span className="text-2xl">🔒</span>}
description={t('settings.privacy')}
>
<SettingToggle
label={t('settings.privacy')}
description={t('settings.privacy')}
checked={anonymousAnalytics}
onChange={handleAnonymousAnalyticsChange}
/>
</SettingsSection>
</div>
)
} }

View File

@@ -0,0 +1,26 @@
export default function SettingsLoading() {
return (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-9 w-64 bg-muted rounded-md mb-2" />
<div className="h-4 w-96 bg-muted rounded-md" />
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border border-border p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 bg-muted rounded-full" />
<div className="h-5 w-40 bg-muted rounded-md" />
</div>
<div className="h-px bg-border" />
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30">
<div className="space-y-2">
<div className="h-4 w-32 bg-muted rounded" />
<div className="h-3 w-56 bg-muted rounded" />
</div>
<div className="h-6 w-11 bg-muted rounded-full" />
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
export default async function McpSettingsPage() {
const session = await auth()
if (!session?.user) {
redirect('/api/auth/signin')
}
const [keys, serverStatus] = await Promise.all([
listMcpKeys(),
getMcpServerStatus(),
])
return (
<div className="space-y-6">
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
</div>
)
}

View File

@@ -14,13 +14,54 @@ export type UserAISettingsData = {
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl' preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: 'masonry' | 'tabs' | 'list'
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
theme?: 'light' | 'dark' | 'auto'
fontSize?: 'small' | 'medium' | 'large' fontSize?: 'small' | 'medium' | 'large'
} }
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
const USER_AI_SETTINGS_PRISMA_KEYS = [
'titleSuggestions',
'semanticSearch',
'paragraphRefactor',
'memoryEcho',
'memoryEchoFrequency',
'aiProvider',
'preferredLanguage',
'fontSize',
'demoMode',
'showRecentNotes',
'notesViewMode',
'emailNotifications',
'desktopNotifications',
'anonymousAnalytics',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
function pickUserAISettingsForDb(input: UserAISettingsData): Partial<Record<UserAISettingsPrismaKey, unknown>> {
const out: Partial<Record<UserAISettingsPrismaKey, unknown>> = {}
for (const key of USER_AI_SETTINGS_PRISMA_KEYS) {
const v = input[key]
if (v !== undefined) {
out[key] = v
}
}
if (out.notesViewMode === 'list') {
out.notesViewMode = 'tabs'
}
if (
out.notesViewMode != null &&
out.notesViewMode !== 'masonry' &&
out.notesViewMode !== 'tabs'
) {
delete out.notesViewMode
}
return out
}
/** /**
* Update AI settings for the current user * Update AI settings for the current user
*/ */
@@ -35,24 +76,41 @@ export async function updateAISettings(settings: UserAISettingsData) {
} }
try { try {
const data = pickUserAISettingsForDb(settings)
if (Object.keys(data).length === 0) {
return { success: true }
}
// Valeurs scalaires uniquement (pickUserAISettingsForDb) — cast pour éviter UpdateOperations vs create.
const payload = data as Record<string, string | boolean | undefined>
// Upsert settings (create if not exists, update if exists) // Upsert settings (create if not exists, update if exists)
const result = await prisma.userAISettings.upsert({ await prisma.userAISettings.upsert({
where: { userId: session.user.id }, where: { userId: session.user.id },
create: { create: {
userId: session.user.id, userId: session.user.id,
...settings ...payload,
}, },
update: settings update: payload,
}) })
revalidatePath('/settings/ai', 'page') revalidatePath('/settings/ai', 'page')
revalidatePath('/settings/appearance', 'page')
revalidatePath('/', 'layout') revalidatePath('/', 'layout')
updateTag('ai-settings') updateTag('ai-settings')
return { success: true } return { success: true }
} catch (error) { } catch (error) {
console.error('Error updating AI settings:', error) console.error('Error updating AI settings:', error)
const raw = error instanceof Error ? error.message : String(error)
const isSchema =
/no such column|notesViewMode|Unknown column|does not exist/i.test(raw) ||
(typeof raw === 'string' && raw.includes('UserAISettings') && raw.includes('column'))
if (isSchema) {
throw new Error(
'Schéma base de données obsolète : colonne notesViewMode manquante. Dans le dossier keep-notes, exécutez : npx prisma db push (ou appliquez les migrations Prisma).'
)
}
throw new Error('Failed to update AI settings') throw new Error('Failed to update AI settings')
} }
} }
@@ -81,6 +139,7 @@ const getCachedAISettings = unstable_cache(
preferredLanguage: 'auto' as const, preferredLanguage: 'auto' as const,
demoMode: false, demoMode: false,
showRecentNotes: false, showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false, emailNotifications: false,
desktopNotifications: false, desktopNotifications: false,
anonymousAnalytics: false, anonymousAnalytics: false,
@@ -89,6 +148,14 @@ const getCachedAISettings = unstable_cache(
} }
} }
const raw = settings.notesViewMode
const viewMode =
raw === 'masonry'
? ('masonry' as const)
: raw === 'list' || raw === 'tabs'
? ('tabs' as const)
: ('masonry' as const)
return { return {
titleSuggestions: settings.titleSuggestions, titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch, semanticSearch: settings.semanticSearch,
@@ -99,6 +166,7 @@ const getCachedAISettings = unstable_cache(
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl', preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode, demoMode: settings.demoMode,
showRecentNotes: settings.showRecentNotes, showRecentNotes: settings.showRecentNotes,
notesViewMode: viewMode,
emailNotifications: settings.emailNotifications, emailNotifications: settings.emailNotifications,
desktopNotifications: settings.desktopNotifications, desktopNotifications: settings.desktopNotifications,
anonymousAnalytics: settings.anonymousAnalytics, anonymousAnalytics: settings.anonymousAnalytics,
@@ -118,6 +186,7 @@ const getCachedAISettings = unstable_cache(
preferredLanguage: 'auto' as const, preferredLanguage: 'auto' as const,
demoMode: false, demoMode: false,
showRecentNotes: false, showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false, emailNotifications: false,
desktopNotifications: false, desktopNotifications: false,
anonymousAnalytics: false, anonymousAnalytics: false,
@@ -150,6 +219,7 @@ export async function getAISettings(userId?: string) {
preferredLanguage: 'auto' as const, preferredLanguage: 'auto' as const,
demoMode: false, demoMode: false,
showRecentNotes: false, showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false, emailNotifications: false,
desktopNotifications: false, desktopNotifications: false,
anonymousAnalytics: false, anonymousAnalytics: false,

View File

@@ -0,0 +1,167 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { createHash, randomBytes } from 'crypto'
const KEY_PREFIX = 'mcp_key_'
function hashKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex')
}
export type McpKeyInfo = {
shortId: string
name: string
userId: string
userName: string
active: boolean
createdAt: string
lastUsedAt: string | null
}
/**
* List all MCP API keys for the current user.
*/
export async function listMcpKeys(): Promise<McpKeyInfo[]> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const allKeys = await prisma.systemConfig.findMany({
where: { key: { startsWith: KEY_PREFIX } },
})
const keys: McpKeyInfo[] = []
for (const entry of allKeys) {
try {
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) continue
keys.push({
shortId: info.shortId,
name: info.name,
userId: info.userId,
userName: info.userName,
active: info.active,
createdAt: info.createdAt,
lastUsedAt: info.lastUsedAt,
})
} catch {
// skip invalid JSON
}
}
return keys
}
/**
* Generate a new MCP API key for the current user.
* Returns the raw key (shown only once) and key info.
*/
export async function generateMcpKey(name: string): Promise<{ rawKey: string; info: { shortId: string; name: string } }> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true },
})
if (!user) throw new Error('User not found')
const rawBytes = randomBytes(24)
const shortId = rawBytes.toString('hex').substring(0, 8)
const rawKey = `mcp_sk_${rawBytes.toString('hex')}`
const keyHash = hashKey(rawKey)
const keyInfo = {
shortId,
name: name || `Key for ${user.name}`,
userId: user.id,
userName: user.name,
userEmail: user.email,
keyHash,
createdAt: new Date().toISOString(),
lastUsedAt: null,
active: true,
}
await prisma.systemConfig.create({
data: {
key: `${KEY_PREFIX}${shortId}`,
value: JSON.stringify(keyInfo),
},
})
revalidatePath('/settings/mcp')
return {
rawKey,
info: { shortId, name: keyInfo.name },
}
}
/**
* Revoke (deactivate) an MCP API key. Only the owner can revoke.
*/
export async function revokeMcpKey(shortId: string): Promise<boolean> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const configKey = `${KEY_PREFIX}${shortId}`
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } })
if (!entry) throw new Error('Key not found')
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) throw new Error('Forbidden')
if (!info.active) return false
info.active = false
info.revokedAt = new Date().toISOString()
await prisma.systemConfig.update({
where: { key: configKey },
data: { value: JSON.stringify(info) },
})
revalidatePath('/settings/mcp')
return true
}
/**
* Permanently delete an MCP API key. Only the owner can delete.
*/
export async function deleteMcpKey(shortId: string): Promise<boolean> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const configKey = `${KEY_PREFIX}${shortId}`
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } })
if (!entry) throw new Error('Key not found')
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) throw new Error('Forbidden')
try {
await prisma.systemConfig.delete({ where: { key: configKey } })
revalidatePath('/settings/mcp')
return true
} catch {
return false
}
}
export type McpServerStatus = {
mode: 'stdio' | 'sse' | 'unknown'
url: string | null
}
/**
* Get MCP server status — mode and URL.
*/
export async function getMcpServerStatus(): Promise<McpServerStatus> {
// Check if SSE mode is configured via env
const mode = process.env.MCP_SERVER_MODE === 'sse' ? 'sse' : 'stdio'
const url = process.env.MCP_SERVER_URL || null
return { mode, url }
}

View File

@@ -385,9 +385,9 @@ export async function createNote(data: {
reminder?: Date | null reminder?: Date | null
isMarkdown?: boolean isMarkdown?: boolean
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
sharedWith?: string[]
autoGenerated?: boolean autoGenerated?: boolean
notebookId?: string | undefined // Assign note to a notebook if provided notebookId?: string | undefined // Assign note to a notebook if provided
skipRevalidation?: boolean // Option to prevent full page refresh for smooth optimistic UI updates
}) { }) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized'); if (!session?.user?.id) throw new Error('Unauthorized');
@@ -421,8 +421,10 @@ export async function createNote(data: {
await syncLabels(session.user.id, data.labels, data.notebookId ?? null) await syncLabels(session.user.id, data.labels, data.notebookId ?? null)
} }
if (!data.skipRevalidation) {
// Revalidate main page (handles both inbox and notebook views via query params) // Revalidate main page (handles both inbox and notebook views via query params)
revalidatePath('/') revalidatePath('/')
}
// Fire-and-forget: run AI operations in background without blocking the response // Fire-and-forget: run AI operations in background without blocking the response
const userId = session.user.id const userId = session.user.id
@@ -470,9 +472,11 @@ export async function createNote(data: {
data: { labels: JSON.stringify(appliedLabels) } data: { labels: JSON.stringify(appliedLabels) }
}) })
await syncLabels(userId, appliedLabels, notebookId ?? null) await syncLabels(userId, appliedLabels, notebookId ?? null)
if (!data.skipRevalidation) {
revalidatePath('/') revalidatePath('/')
} }
} }
}
} catch (error) { } catch (error) {
console.error('[BG] Auto-labeling failed:', error) console.error('[BG] Auto-labeling failed:', error)
} }
@@ -503,7 +507,7 @@ export async function updateNote(id: string, data: {
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
autoGenerated?: boolean | null autoGenerated?: boolean | null
notebookId?: string | null notebookId?: string | null
}) { }, options?: { skipContentTimestamp?: boolean; skipRevalidation?: boolean }) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized'); if (!session?.user?.id) throw new Error('Unauthorized');
@@ -556,9 +560,10 @@ export async function updateNote(id: string, data: {
// Only update contentUpdatedAt for actual content changes, NOT for property changes // Only update contentUpdatedAt for actual content changes, NOT for property changes
// (size, color, isPinned, isArchived are properties, not content) // (size, color, isPinned, isArchived are properties, not content)
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
const contentFields = ['title', 'content', 'checkItems', 'images', 'links'] const contentFields = ['title', 'content', 'checkItems', 'images', 'links']
const isContentChange = contentFields.some(field => field in data) const isContentChange = contentFields.some(field => field in data)
if (isContentChange) { if (isContentChange && !options?.skipContentTimestamp) {
updateData.contentUpdatedAt = new Date() updateData.contentUpdatedAt = new Date()
} }
@@ -582,7 +587,7 @@ export async function updateNote(id: string, data: {
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId'] const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
const isStructuralChange = structuralFields.some(field => field in data) const isStructuralChange = structuralFields.some(field => field in data)
if (isStructuralChange) { if (isStructuralChange && !options?.skipRevalidation) {
revalidatePath('/') revalidatePath('/')
revalidatePath(`/note/${id}`) revalidatePath(`/note/${id}`)

View File

@@ -45,7 +45,7 @@ export async function POST(req: NextRequest) {
// Otherwise, use legacy auto-tagging (generates new tags) // Otherwise, use legacy auto-tagging (generates new tags)
const config = await getSystemConfig(); const config = await getSystemConfig();
const provider = getAIProvider(config); const provider = getAIProvider(config);
const tags = await provider.generateTags(content); const tags = await provider.generateTags(content, language);
return NextResponse.json({ tags }); return NextResponse.json({ tags });
} catch (error: any) { } catch (error: any) {

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { text, targetLanguage } = await request.json()
if (!text || !targetLanguage) {
return NextResponse.json({ error: 'text and targetLanguage are required' }, { status: 400 })
}
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const prompt = `Translate the following text to ${targetLanguage}. Return ONLY the translated text, no explanation, no preamble, no quotes:\n\n${text}`
const translatedText = await provider.generateText(prompt)
return NextResponse.json({ translatedText: translatedText.trim() })
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 })
}
}

View File

@@ -101,10 +101,11 @@ export async function PUT(
const newName = name ? name.trim() : currentLabel.name const newName = name ? name.trim() : currentLabel.name
// For backward compatibility, update old label field in notes if renaming // For backward compatibility, update old label field in notes if renaming
if (name && name.trim() !== currentLabel.name && currentLabel.userId) { const targetUserIdPut = currentLabel.userId || currentLabel.notebook?.userId || session.user.id;
if (name && name.trim() !== currentLabel.name && targetUserIdPut) {
const allNotes = await prisma.note.findMany({ const allNotes = await prisma.note.findMany({
where: { where: {
userId: currentLabel.userId, userId: targetUserIdPut,
labels: { not: null } labels: { not: null }
}, },
select: { id: true, labels: true } select: { id: true, labels: true }
@@ -197,10 +198,11 @@ export async function DELETE(
} }
// For backward compatibility, remove from old label field in notes // For backward compatibility, remove from old label field in notes
if (label.userId) { const targetUserIdDel = label.userId || label.notebook?.userId || session.user.id;
if (targetUserIdDel) {
const allNotes = await prisma.note.findMany({ const allNotes = await prisma.note.findMany({
where: { where: {
userId: label.userId, userId: targetUserIdDel,
labels: { not: null } labels: { not: null }
}, },
select: { id: true, labels: true } select: { id: true, labels: true }

View File

@@ -64,21 +64,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
} }
} }
const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => {
setSettings(prev => ({ ...prev, aiProvider: value }))
try {
setIsPending(true)
await updateAISettings({ aiProvider: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating provider:', error)
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => { const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => {
setSettings(prev => ({ ...prev, preferredLanguage: value })) setSettings(prev => ({ ...prev, preferredLanguage: value }))
@@ -188,54 +174,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
/> />
</div> </div>
{/* AI Provider Selection */}
<Card className="p-4">
<Label className="text-base font-medium mb-1">{t('aiSettings.provider')}</Label>
<p className="text-sm text-gray-500 mb-4">
{t('aiSettings.providerDesc')}
</p>
<RadioGroup
value={settings.aiProvider}
onValueChange={handleProviderChange}
>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="auto" id="auto" />
<div className="grid gap-1.5">
<Label htmlFor="auto" className="font-medium">
{t('aiSettings.providerAuto')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerAutoDesc')}
</p>
</div>
</div>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="ollama" id="ollama" />
<div className="grid gap-1.5">
<Label htmlFor="ollama" className="font-medium">
{t('aiSettings.providerOllama')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerOllamaDesc')}
</p>
</div>
</div>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="openai" id="openai" />
<div className="grid gap-1.5">
<Label htmlFor="openai" className="font-medium">
{t('aiSettings.providerOpenAI')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerOpenAIDesc')}
</p>
</div>
</div>
</RadioGroup>
</Card>
</div> </div>
) )
} }

View File

@@ -330,12 +330,12 @@ export function Header({
<div className="flex flex-1 justify-end gap-4 items-center"> <div className="flex flex-1 justify-end gap-4 items-center">
{/* Settings Button */} {/* Settings Button */}
<button <Link
onClick={() => router.push('/settings')} href="/settings"
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors" className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
> >
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
</button> </Link>
{/* User Avatar Menu */} {/* User Avatar Menu */}
<DropdownMenu> <DropdownMenu>
@@ -356,13 +356,17 @@ export function Header({
{currentUser?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{currentUser.email}</p>} {currentUser?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{currentUser.email}</p>}
</div> </div>
</div> </div>
<DropdownMenuItem onClick={() => router.push('/settings/profile')} className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/profile">
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
<span>{t('settings.profile') || 'Profile'}</span> <span>{t('settings.profile') || 'Profile'}</span>
</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/admin')} className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link href="/admin">
<Shield className="mr-2 h-4 w-4" /> <Shield className="mr-2 h-4 w-4" />
<span>{t('nav.adminDashboard')}</span> <span>{t('nav.adminDashboard')}</span>
</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600"> <DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />

View File

@@ -24,7 +24,7 @@ interface LabelFilterProps {
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) { export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
const { labels, loading } = useLabels() const { labels, loading } = useLabels()
const { t } = useLanguage() const { t, language } = useLanguage()
const [allLabelNames, setAllLabelNames] = useState<string[]>([]) const [allLabelNames, setAllLabelNames] = useState<string[]>([])
useEffect(() => { useEffect(() => {
@@ -47,10 +47,11 @@ export function LabelFilter({ selectedLabels, onFilterChange, className }: Label
if (loading || allLabelNames.length === 0) return null if (loading || allLabelNames.length === 0) return null
return ( return (
<div className={cn("flex items-center gap-2", className ? "" : "")}> <div dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'} className={cn("flex items-center gap-2", className ? "" : "")}>
<DropdownMenu> <DropdownMenu dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
variant="outline" variant="outline"
size="sm" size="sm"
className={cn( className={cn(

View File

@@ -16,6 +16,7 @@ import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext' import { useLabels } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
export interface LabelManagementDialogProps { export interface LabelManagementDialogProps {
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */ /** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
@@ -26,7 +27,9 @@ export interface LabelManagementDialogProps {
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) { export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
const { open, onOpenChange } = props const { open, onOpenChange } = props
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels() const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
const { t } = useLanguage() const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [newLabel, setNewLabel] = useState('') const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState<string | null>(null) const [editingColorId, setEditingColorId] = useState<string | null>(null)
@@ -37,6 +40,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
if (trimmed) { if (trimmed) {
try { try {
await addLabel(trimmed, 'gray') await addLabel(trimmed, 'gray')
triggerRefresh()
setNewLabel('') setNewLabel('')
} catch (error) { } catch (error) {
console.error('Failed to add label:', error) console.error('Failed to add label:', error)
@@ -45,18 +49,19 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
} }
const handleDeleteLabel = async (id: string) => { const handleDeleteLabel = async (id: string) => {
if (confirm(t('labels.confirmDelete'))) {
try { try {
await deleteLabel(id) await deleteLabel(id)
triggerRefresh()
setConfirmDeleteId(null)
} catch (error) { } catch (error) {
console.error('Failed to delete label:', error) console.error('Failed to delete label:', error)
} }
} }
}
const handleChangeColor = async (id: string, color: LabelColorName) => { const handleChangeColor = async (id: string, color: LabelColorName) => {
try { try {
await updateLabel(id, { color }) await updateLabel(id, { color })
triggerRefresh()
setEditingColorId(null) setEditingColorId(null)
} catch (error) { } catch (error) {
console.error('Failed to update label color:', error) console.error('Failed to update label color:', error)
@@ -157,6 +162,17 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
)} )}
</div> </div>
{confirmDeleteId === label.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500 font-medium">{t('labels.confirmDeleteShort') || 'Confirmer ?'}</span>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setConfirmDeleteId(null)}>
{t('common.cancel') || 'Annuler'}
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={() => handleDeleteLabel(label.id)}>
{t('common.delete') || 'Supprimer'}
</Button>
</div>
) : (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button <Button
variant="ghost" variant="ghost"
@@ -171,12 +187,13 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20" className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleDeleteLabel(label.id)} onClick={() => setConfirmDeleteId(label.id)}
title={t('labels.deleteTooltip')} title={t('labels.deleteTooltip')}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
)}
</div> </div>
) )
}) })
@@ -188,14 +205,14 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
if (controlled) { if (controlled) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange} dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
{dialogContent} {dialogContent}
</Dialog> </Dialog>
) )
} }
return ( return (
<Dialog> <Dialog dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="icon" title={t('labels.manage')}> <Button variant="ghost" size="icon" title={t('labels.manage')}>
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />

View File

@@ -0,0 +1,478 @@
'use client'
import { useState, useTransition } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import {
Info,
Key,
Server,
Plus,
Copy,
Check,
Trash2,
Ban,
Loader2,
ExternalLink,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import {
generateMcpKey,
revokeMcpKey,
deleteMcpKey,
type McpKeyInfo,
type McpServerStatus,
} from '@/app/actions/mcp-keys'
interface McpSettingsPanelProps {
initialKeys: McpKeyInfo[]
serverStatus: McpServerStatus
}
export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanelProps) {
const [keys, setKeys] = useState<McpKeyInfo[]>(initialKeys)
const [createOpen, setCreateOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const { t } = useLanguage()
const handleGenerate = async (name: string) => {
startTransition(async () => {
try {
const result = await generateMcpKey(name)
setCreateOpen(false)
// Show the raw key in a new dialog
setShowRawKey(result.rawKey)
setRawKeyName(result.info.name)
// Refresh keys
setKeys(prev => [
{
shortId: result.info.shortId,
name: result.info.name,
userId: '',
userName: '',
active: true,
createdAt: new Date().toISOString(),
lastUsedAt: null,
},
...prev,
])
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to generate key')
}
})
}
const handleRevoke = (shortId: string) => {
if (!confirm(t('mcpSettings.apiKeys.confirmRevoke'))) return
startTransition(async () => {
try {
await revokeMcpKey(shortId)
setKeys(prev =>
prev.map(k => (k.shortId === shortId ? { ...k, active: false } : k))
)
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to revoke key')
}
})
}
const handleDelete = (shortId: string) => {
if (!confirm(t('mcpSettings.apiKeys.confirmDelete'))) return
startTransition(async () => {
try {
await deleteMcpKey(shortId)
setKeys(prev => prev.filter(k => k.shortId !== shortId))
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete key')
}
})
}
// Raw key display state
const [showRawKey, setShowRawKey] = useState<string | null>(null)
const [rawKeyName, setRawKeyName] = useState('')
const [copied, setCopied] = useState(false)
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-6">
{/* Section 1: What is MCP */}
<Card className="p-6">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">{t('mcpSettings.whatIsMcp.title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t('mcpSettings.whatIsMcp.description')}
</p>
<a
href="https://modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2"
>
{t('mcpSettings.whatIsMcp.learnMore')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</Card>
{/* Section 2: Server Status */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<Server className="h-5 w-5 shrink-0" />
<h2 className="text-lg font-semibold">{t('mcpSettings.serverStatus.title')}</h2>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-500">{t('mcpSettings.serverStatus.mode')}:</span>
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
</div>
{serverStatus.mode === 'sse' && serverStatus.url && (
<div className="flex items-center gap-2">
<span className="text-gray-500">{t('mcpSettings.serverStatus.url')}:</span>
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
{serverStatus.url}
</code>
</div>
)}
</div>
</Card>
{/* Section 3: API Keys */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">{t('mcpSettings.apiKeys.title')}</h2>
<p className="text-sm text-gray-500">
{t('mcpSettings.apiKeys.description')}
</p>
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1.5">
<Plus className="h-4 w-4" />
{t('mcpSettings.apiKeys.generate')}
</Button>
</DialogTrigger>
<CreateKeyDialog
onGenerate={handleGenerate}
isPending={isPending}
/>
</Dialog>
</div>
{keys.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('mcpSettings.apiKeys.empty')}</p>
</div>
) : (
<div className="space-y-3">
{keys.map(k => (
<KeyCard
key={k.shortId}
keyInfo={k}
onRevoke={handleRevoke}
onDelete={handleDelete}
isPending={isPending}
/>
))}
</div>
)}
</Card>
{/* Section 4: Configuration Instructions */}
<ConfigInstructions serverStatus={serverStatus} />
{/* Raw Key Display Dialog */}
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcpSettings.createDialog.successTitle')}</DialogTitle>
<DialogDescription>
{t('mcpSettings.createDialog.successDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-gray-500">{rawKeyName}</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded break-all font-mono">
{showRawKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(showRawKey!)}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setShowRawKey(null)}>
{t('mcpSettings.createDialog.done')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ── Sub-components ──────────────────────────────────────────────────────────────
function CreateKeyDialog({
onGenerate,
isPending,
}: {
onGenerate: (name: string) => void
isPending: boolean
}) {
const [name, setName] = useState('')
const { t } = useLanguage()
return (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcpSettings.createDialog.title')}</DialogTitle>
<DialogDescription>
{t('mcpSettings.createDialog.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="key-name">{t('mcpSettings.createDialog.nameLabel')}</Label>
<Input
id="key-name"
placeholder={t('mcpSettings.createDialog.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() => onGenerate(name)}
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-1" />
{t('mcpSettings.createDialog.generating')}
</>
) : (
<>
<Key className="h-4 w-4 mr-1" />
{t('mcpSettings.createDialog.generate')}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
)
}
function KeyCard({
keyInfo,
onRevoke,
onDelete,
isPending,
}: {
keyInfo: McpKeyInfo
onRevoke: (shortId: string) => void
onDelete: (shortId: string) => void
isPending: boolean
}) {
const { t } = useLanguage()
const formatDate = (iso: string | null) => {
if (!iso) return t('mcpSettings.apiKeys.never')
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<div className="flex items-center justify-between p-4 rounded-lg border bg-gray-50 dark:bg-gray-900">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{keyInfo.name}</span>
<Badge variant={keyInfo.active ? 'default' : 'secondary'} className="text-xs">
{keyInfo.active
? t('mcpSettings.apiKeys.active')
: t('mcpSettings.apiKeys.revoked')}
</Badge>
</div>
<div className="flex gap-4 text-xs text-gray-500">
<span>
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
</span>
<span>
{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}
</span>
</div>
</div>
<div>
{keyInfo.active ? (
<Button
size="sm"
variant="outline"
onClick={() => onRevoke(keyInfo.shortId)}
disabled={isPending}
className="gap-1"
>
<Ban className="h-3.5 w-3.5" />
{t('mcpSettings.apiKeys.revoke')}
</Button>
) : (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(keyInfo.shortId)}
disabled={isPending}
className="gap-1"
>
<Trash2 className="h-3.5 w-3.5" />
{t('mcpSettings.apiKeys.delete')}
</Button>
)}
</div>
</div>
)
}
function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus }) {
const { t } = useLanguage()
const [expanded, setExpanded] = useState<string | null>(null)
const baseUrl = serverStatus.url || 'http://localhost:3001'
const configs = [
{
id: 'claude-code',
title: t('mcpSettings.configInstructions.claudeCode.title'),
description: t('mcpSettings.configInstructions.claudeCode.description'),
snippet: JSON.stringify(
{
mcpServers: {
'keep-notes': {
command: 'node',
args: ['path/to/mcp-server/index.js'],
env: {
DATABASE_URL: 'file:path/to/keep-notes/prisma/dev.db',
APP_BASE_URL: 'http://localhost:3000',
},
},
},
},
null,
2
),
},
{
id: 'cursor',
title: t('mcpSettings.configInstructions.cursor.title'),
description: t('mcpSettings.configInstructions.cursor.description'),
snippet: JSON.stringify(
{
mcpServers: {
'keep-notes': {
url: baseUrl + '/mcp',
headers: {
'x-api-key': 'YOUR_API_KEY',
},
},
},
},
null,
2
),
},
{
id: 'n8n',
title: t('mcpSettings.configInstructions.n8n.title'),
description: t('mcpSettings.configInstructions.n8n.description'),
snippet: `MCP Server URL: ${baseUrl}/mcp
Header: x-api-key: YOUR_API_KEY
Transport: Streamable HTTP`,
},
]
return (
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<ExternalLink className="h-5 w-5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">
{t('mcpSettings.configInstructions.title')}
</h2>
<p className="text-sm text-gray-500">
{t('mcpSettings.configInstructions.description')}
</p>
</div>
</div>
<div className="space-y-2">
{configs.map(cfg => (
<div key={cfg.id} className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
>
<span className="font-medium text-sm">{cfg.title}</span>
{expanded === cfg.id ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{expanded === cfg.id && (
<div className="px-4 pb-4">
<p className="text-sm text-gray-500 mb-2">{cfg.description}</p>
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
<code>{cfg.snippet}</code>
</pre>
</div>
)}
</div>
))}
</div>
</Card>
)
}

View File

@@ -169,7 +169,10 @@ export const NoteCard = memo(function NoteCard({
(state, newProps: Partial<Note>) => ({ ...state, ...newProps }) (state, newProps: Partial<Note>) => ({ ...state, ...newProps })
) )
const colorClasses = NOTE_COLORS[optimisticNote.color as NoteColor] || NOTE_COLORS.default // 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 // Check if this note is currently open in the editor
const isNoteOpenInEditor = searchParams.get('note') === note.id const isNoteOpenInEditor = searchParams.get('note') === note.id
@@ -263,10 +266,10 @@ export const NoteCard = memo(function NoteCard({
} }
const handleColorChange = async (color: string) => { const handleColorChange = async (color: string) => {
setLocalColor(color) // instant visual update, survives transition
startTransition(async () => { startTransition(async () => {
addOptimisticNote({ color }) addOptimisticNote({ color })
await updateColor(note.id, color) await updateNote(note.id, { color }, { skipRevalidation: false })
router.refresh()
}) })
} }

View File

@@ -142,10 +142,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
} }
// Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur // Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur
// (On garde celles déjà ajoutées pour les afficher en mode "validé") // ni celles déjà présentes sur la note
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(s => { const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false if (!s || !s.tag) return false
return !dismissedTags.includes(s.tag) return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
}) })
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -0,0 +1,896 @@
'use client'
import { useState, useEffect, useRef, useCallback, useTransition } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { LabelBadge } from '@/components/label-badge'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import {
updateNote,
togglePin,
toggleArchive,
updateColor,
deleteNote,
} from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import {
Pin,
Palette,
Archive,
ArchiveRestore,
Trash2,
ImageIcon,
Link as LinkIcon,
X,
Plus,
CheckSquare,
FileText,
Eye,
Sparkles,
Loader2,
Check,
Wand2,
AlignLeft,
Minimize2,
Lightbulb,
RotateCcw,
Languages,
ChevronRight,
} from 'lucide-react'
import { toast } from 'sonner'
import { MarkdownContent } from '@/components/markdown-content'
import { EditorImages } from '@/components/editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from '@/components/ghost-tags'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { TitleSuggestions } from '@/components/title-suggestions'
import { useLabels } from '@/context/LabelContext'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NoteInlineEditorProps {
note: Note
onDelete?: (noteId: string) => void
onArchive?: (noteId: string) => void
onChange?: (noteId: string, fields: Partial<Note>) => void
colorKey: NoteColor
/** If true and the note is a Markdown note, open directly in preview mode */
defaultPreviewMode?: boolean
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
}
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
async function saveInline(
id: string,
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean }
) {
await fetch(`/api/notes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
export function NoteInlineEditor({
note,
onDelete,
onArchive,
onChange,
colorKey,
defaultPreviewMode = false,
}: NoteInlineEditorProps) {
const { t, language } = useLanguage()
const { labels: globalLabels, addLabel } = useLabels()
const [, startTransition] = useTransition()
// ── Local edit state ──────────────────────────────────────────────────────
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content || '')
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
defaultPreviewMode && (note.isMarkdown || false)
)
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
// Link dialog
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const [isAddingLink, setIsAddingLink] = useState(false)
// AI popover
const [aiOpen, setAiOpen] = useState(false)
const [isProcessingAI, setIsProcessingAI] = useState(false)
// Undo after AI: saves content before transformation
const [previousContent, setPreviousContent] = useState<string | null>(null)
// Translate sub-panel
const [showTranslate, setShowTranslate] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingRef = useRef({ title, content, checkItems, isMarkdown })
const noteIdRef = useRef(note.id)
// Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: note.type === 'text' ? content : '',
enabled: note.type === 'text' && !title
})
// Keep pending ref in sync for unmount save
useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown }
}, [title, content, checkItems, isMarkdown])
// ── Sync when selected note switches ─────────────────────────────────────
useEffect(() => {
// Flush unsaved changes for the PREVIOUS note before switching
if (isDirty && noteIdRef.current !== note.id) {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
noteIdRef.current = note.id
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setIsMarkdown(note.isMarkdown || false)
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
setIsDirty(false)
setDismissedTitleSuggestions(false)
clearTimeout(saveTimerRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id])
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
const scheduleSave = useCallback(() => {
setIsDirty(true)
clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
setIsSaving(true)
try {
await saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
})
setIsDirty(false)
} catch {
// silent — retry on next keystroke
} finally {
setIsSaving(false)
}
}, 1500)
}, [note.type])
// Flush on unmount
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current)
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text',
})
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(
(s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
)
const handleSelectGhostTag = async (tag: string) => {
const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase())
if (!exists) {
const newLabels = [...(note.labels || []), tag]
// Optimistic UI — update sidebar immediately, no page refresh needed
onChange?.(note.id, { labels: newLabels })
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try { await addLabel(tag) } catch {}
}
toast.success(t('ai.tagAdded', { tag }))
}
}
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
const handleTogglePin = () => {
startTransition(async () => {
// Optimitistic update
onChange?.(note.id, { isPinned: !note.isPinned })
// Call with skipRevalidation to avoid server layout refresh interfering with optimistic state
await updateNote(note.id, { isPinned: !note.isPinned }, { skipRevalidation: true })
toast.success(note.isPinned ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée')
})
}
const handleToggleArchive = () => {
startTransition(async () => {
onArchive?.(note.id)
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
})
}
const handleColorChange = (color: string) => {
startTransition(async () => {
onChange?.(note.id, { color })
await updateNote(note.id, { color }, { skipRevalidation: true })
})
}
const handleDelete = () => {
if (!confirm(t('notes.confirmDelete'))) return
startTransition(async () => {
await deleteNote(note.id)
onDelete?.(note.id)
})
}
// ── Image upload ──────────────────────────────────────────────────────────
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) throw new Error('Upload failed')
const data = await res.json()
const newImages = [...(note.images || []), data.url]
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
} catch {
toast.error(t('notes.uploadFailed', { filename: file.name }))
}
}
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleRemoveImage = async (index: number) => {
const newImages = (note.images || []).filter((_, i) => i !== index)
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
}
// ── Link ──────────────────────────────────────────────────────────────────
const handleAddLink = async () => {
if (!linkUrl) return
setIsAddingLink(true)
try {
const metadata = await fetchLinkMetadata(linkUrl)
const newLink = metadata || { url: linkUrl, title: linkUrl }
const newLinks = [...(note.links || []), newLink]
onChange?.(note.id, { links: newLinks })
await updateNote(note.id, { links: newLinks })
toast.success(t('notes.linkAdded'))
} catch {
toast.error(t('notes.linkAddFailed'))
} finally {
setLinkUrl('')
setShowLinkInput(false)
setIsAddingLink(false)
}
}
const handleRemoveLink = async (index: number) => {
const newLinks = (note.links || []).filter((_, i) => i !== index)
onChange?.(note.id, { links: newLinks })
await updateNote(note.id, { links: newLinks })
}
// ── AI actions (called from Popover in toolbar) ───────────────────────────
const callAI = async (option: 'clarify' | 'shorten' | 'improve') => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 10) {
toast.error(t('ai.reformulationMinWords', { count: wc }))
return
}
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content) // save for undo
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to reformulate')
changeContent(data.reformulatedText || data.text)
scheduleSave()
toast.success(t('ai.reformulationApplied'))
} catch {
toast.error(t('ai.reformulationFailed'))
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
const callTranslate = async (targetLanguage: string) => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 3) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content)
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, targetLanguage }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Translation failed')
changeContent(data.translatedText)
scheduleSave()
toast.success(t('ai.translationApplied') || `Traduit en ${targetLanguage}`)
} catch {
toast.error(t('ai.translationFailed') || 'Traduction échouée')
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
const handleTransformMarkdown = async () => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 10) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content)
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/transform-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
changeContent(data.transformedText)
setIsMarkdown(true)
scheduleSave()
toast.success(t('ai.transformSuccess'))
} catch {
toast.error(t('ai.transformError'))
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
// ── Checklist helpers ─────────────────────────────────────────────────────
const handleToggleCheckItem = (id: string) => {
const updated = checkItems.map((ci) =>
ci.id === id ? { ...ci, checked: !ci.checked } : ci
)
setCheckItems(updated)
scheduleSave()
}
const handleUpdateCheckText = (id: string, text: string) => {
const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci))
setCheckItems(updated)
scheduleSave()
}
const handleAddCheckItem = () => {
const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }]
setCheckItems(updated)
scheduleSave()
}
const handleRemoveCheckItem = (id: string) => {
const updated = checkItems.filter((ci) => ci.id !== id)
setCheckItems(updated)
scheduleSave()
}
const dateLocale = getDateLocale(language)
return (
<div className="flex h-full flex-col overflow-hidden">
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
<div className="flex items-center gap-1">
{/* Image upload */}
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
title={t('notes.addImage') || 'Ajouter une image'}
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon className="h-4 w-4" />
</Button>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
{/* Link */}
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
title={t('notes.addLink') || 'Ajouter un lien'}
onClick={() => setShowLinkInput(!showLinkInput)}
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Markdown toggle */}
<Button
variant="ghost" size="sm"
className={cn('h-8 gap-1 px-2 text-xs', isMarkdown && 'text-primary')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false); scheduleSave() }}
title="Markdown"
>
<FileText className="h-3.5 w-3.5" />
<span className="hidden sm:inline">MD</span>
</Button>
{isMarkdown && (
<Button
variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
>
<Eye className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{showMarkdownPreview ? t('notes.edit') || 'Éditer' : t('notes.preview') || 'Aperçu'}</span>
</Button>
)}
{/* ── AI Popover (in toolbar, non-intrusive) ─────────────────────── */}
{note.type === 'text' && (
<Popover open={aiOpen} onOpenChange={(o) => { setAiOpen(o); if (!o) setShowTranslate(false) }}>
<PopoverTrigger asChild>
<Button
variant="ghost" size="sm"
className={cn(
'h-8 gap-1.5 px-2 text-xs transition-colors',
isProcessingAI && 'text-primary',
aiOpen && 'bg-muted text-primary',
)}
disabled={isProcessingAI}
title="Assistant IA"
>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />
}
<span className="hidden sm:inline">IA</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{!showTranslate ? (
<div className="flex flex-col gap-0.5">
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('clarify')}
>
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.clarify') || 'Clarifier'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.clarifyDesc') || 'Rendre plus clair'}</p>
</div>
</button>
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('shorten')}
>
<Minimize2 className="h-4 w-4 text-blue-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.shorten') || 'Raccourcir'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.shortenDesc') || 'Version concise'}</p>
</div>
</button>
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('improve')}
>
<AlignLeft className="h-4 w-4 text-emerald-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.improve') || 'Améliorer'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.improveDesc') || 'Meilleure rédaction'}</p>
</div>
</button>
<button type="button"
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left w-full"
onClick={() => setShowTranslate(true)}
>
<div className="flex items-center gap-2">
<Languages className="h-4 w-4 text-sky-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.translate') || 'Traduire'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.translateDesc') || 'Changer la langue'}</p>
</div>
</div>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
</button>
<div className="my-0.5 border-t border-border/40" />
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={handleTransformMarkdown}
>
<Wand2 className="h-4 w-4 text-violet-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.toMarkdown') || 'En Markdown'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.toMarkdownDesc') || 'Formater en MD'}</p>
</div>
</button>
</div>
) : (
<div className="flex flex-col gap-0.5">
<button type="button"
className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowTranslate(false)}
>
<RotateCcw className="h-3 w-3" />
{t('ai.translateBack') || 'Retour'}
</button>
<div className="my-0.5 border-t border-border/40" />
{[
{ code: 'French', label: 'Français 🇫🇷' },
{ code: 'English', label: 'English 🇬🇧' },
{ code: 'Persian', label: 'فارسی 🇮🇷' },
{ code: 'Spanish', label: 'Español 🇪🇸' },
{ code: 'German', label: 'Deutsch 🇩🇪' },
{ code: 'Italian', label: 'Italiano 🇮🇹' },
{ code: 'Portuguese', label: 'Português 🇵🇹' },
{ code: 'Arabic', label: 'العربية 🇸🇦' },
{ code: 'Chinese', label: '中文 🇨🇳' },
{ code: 'Japanese', label: '日本語 🇯🇵' },
].map(({ code, label }) => (
<button key={code} type="button"
className="w-full rounded-md px-3 py-1.5 text-sm hover:bg-muted text-left"
onClick={() => callTranslate(code)}
>
{label}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
)}
{/* ── Undo AI button ─────────────────────────────────────────────── */}
{previousContent !== null && (
<Button
variant="ghost" size="sm"
className="h-8 gap-1.5 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
title={t('ai.undoAI') || 'Annuler transformation IA'}
onClick={() => {
changeContent(previousContent)
setPreviousContent(null)
scheduleSave()
toast.info(t('ai.undoApplied') || 'Texte original restauré')
}}
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('ai.undo') || 'Annuler IA'}</span>
</Button>
)}
</div>
<div className="flex items-center gap-1">
{/* Save status indicator */}
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> Sauvegarde</>
) : isDirty ? (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> Modifié</>
) : (
<><Check className="h-3 w-3 text-emerald-500" /> Sauvegardé</>
)}
</span>
{/* Pin */}
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
title={note.isPinned ? t('notes.unpin') : t('notes.pin')} onClick={handleTogglePin}>
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current text-primary')} />
</Button>
{/* Color picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
<button type="button"
key={name}
className={cn(
'h-7 w-7 rounded-full border-2 transition-transform hover:scale-110',
cls.bg,
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => handleColorChange(name)}
title={name}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* More actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.moreOptions')}>
<span className="text-base leading-none text-muted-foreground"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleArchive}>
{note.isArchived
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
{showLinkInput && (
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
<input
type="url"
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
autoFocus
/>
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Ajouter'}
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
{/* Existing labels */}
{(note.labels ?? []).map((label) => (
<LabelBadge key={label} label={label} />
))}
{/* AI-suggested tags inline with labels */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={note.labels || []}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
/>
</div>
)}
{/* ── Scrollable editing area (takes all remaining height) ─────────── */}
<div className="flex flex-1 flex-col overflow-y-auto px-8 py-5">
{/* Title row with optional AI suggest button */}
<div className="group relative flex items-start gap-2 shrink-0">
<input
type="text"
dir="auto"
className="flex-1 bg-transparent text-2xl font-bold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title}
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
/>
{/* AI title suggestion — show when title is empty and there's content */}
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button"
onClick={async (e) => {
e.preventDefault()
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/suggest-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
if (res.ok) {
const data = await res.json()
const suggested = data.title || data.suggestedTitle || ''
if (suggested) { changeTitle(suggested); scheduleSave() }
}
} catch { /* silent */ } finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="mt-1.5 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
title="Suggestion de titre par IA"
>
{isProcessingAI
? <Loader2 className="h-4 w-4 animate-spin" />
: <Sparkles className="h-4 w-4" />}
</button>
)}
</div>
{/* Title Suggestions Dropdown / Inline list */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="mt-2 text-sm shrink-0">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
</div>
)}
{/* Images */}
{note.images && note.images.length > 0 && (
<div className="mt-4">
<EditorImages images={note.images} onRemove={handleRemoveImage} />
</div>
)}
{/* Link previews */}
{note.links && note.links.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{note.links.map((link, idx) => (
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
{link.imageUrl && (
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
</a>
</div>
<button type="button"
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* ── Text / Checklist content ───────────────────────────────────── */}
<div className="mt-4 flex flex-1 flex-col">
{note.type === 'text' ? (
<div className="flex flex-1 flex-col">
{showMarkdownPreview && isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
<MarkdownContent content={content || ''} />
</div>
) : (
<textarea
dir="auto"
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={isMarkdown
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
: t('notes.takeNote') || 'Écris quelque chose…'
}
value={content}
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
style={{ minHeight: '200px' }}
/>
)}
{/* Ghost tag suggestions are now shown in the top labels strip */}
</div>
) : (
/* Checklist */
<div className="space-y-1">
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
onClick={() => handleToggleCheckItem(ci.id)}
/>
<input
dir="auto"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
value={ci.text}
placeholder={t('notes.listItem') || 'Élément…'}
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
/>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
</div>
))}
<button type="button"
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
onClick={handleAddCheckItem}
>
<Plus className="h-4 w-4" />
{t('notes.addItem') || 'Ajouter un élément'}
</button>
{checkItems.filter((ci) => ci.checked).length > 0 && (
<div className="mt-3">
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
Complétés ({checkItems.filter((ci) => ci.checked).length})
</p>
{checkItems.filter((ci) => ci.checked).map((ci) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
onClick={() => handleToggleCheckItem(ci.id)}
>
<CheckSquare className="h-3 w-3 opacity-60" />
</button>
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span>{t('notes.modified') || 'Modifiée'} {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
</div>
</div>
)
}

View File

@@ -68,9 +68,16 @@ interface NoteInputProps {
onNoteCreated?: (note: Note) => void onNoteCreated?: (note: Note) => void
defaultExpanded?: boolean defaultExpanded?: boolean
forceExpanded?: boolean forceExpanded?: boolean
/** Mode onglets : occupe toute la largeur du contenu principal (plus de carte étroite centrée) */
fullWidth?: boolean
} }
export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpanded = false }: NoteInputProps) { export function NoteInput({
onNoteCreated,
defaultExpanded = false,
forceExpanded = false,
fullWidth = false,
}: NoteInputProps) {
const { labels: globalLabels, addLabel } = useLabels() const { labels: globalLabels, addLabel } = useLabels()
const { data: session } = useSession() const { data: session } = useSession()
const { t } = useLanguage() const { t } = useLanguage()
@@ -109,7 +116,8 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
// Auto-tagging hook // Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({ const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? fullContentForAI : '', content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded enabled: type === 'text' && isExpanded,
notebookId: currentNotebookId
}) })
// Title suggestions // Title suggestions
@@ -559,11 +567,13 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
setDismissedTitleSuggestions(false) setDismissedTitleSuggestions(false)
} }
const widthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
if (!isExpanded) { if (!isExpanded) {
return ( return (
<Card className="p-4 max-w-2xl mx-auto mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow"> <Card className={cn('p-4 mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow', widthClass)}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Input <Input dir="auto"
placeholder={t('notes.placeholder')} placeholder={t('notes.placeholder')}
onClick={() => setIsExpanded(true)} onClick={() => setIsExpanded(true)}
readOnly readOnly
@@ -590,12 +600,9 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
return ( return (
<> <>
<Card className={cn( <Card className={cn('p-4 mb-8 shadow-lg border', widthClass, colorClasses.card)}>
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
colorClasses.card
)}>
<div className="space-y-3"> <div className="space-y-3">
<Input <Input dir="auto"
placeholder={t('notes.titlePlaceholder')} placeholder={t('notes.titlePlaceholder')}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
@@ -707,7 +714,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50" className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
/> />
) : ( ) : (
<Textarea <Textarea dir="auto"
placeholder={isMarkdown ? t('notes.markdownPlaceholder') : t('notes.placeholder')} placeholder={isMarkdown ? t('notes.markdownPlaceholder') : t('notes.placeholder')}
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
@@ -743,7 +750,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
{checkItems.map((item) => ( {checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group"> <div key={item.id} className="flex items-start gap-2 group">
<Checkbox className="mt-2" /> <Checkbox className="mt-2" />
<Input <Input dir="auto"
value={item.text} value={item.text}
onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)} onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')} placeholder={t('notes.listItem')}
@@ -1015,7 +1022,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<label htmlFor="reminder-date" className="text-sm font-medium"> <label htmlFor="reminder-date" className="text-sm font-medium">
{t('notes.date')} {t('notes.date')}
</label> </label>
<Input <Input dir="auto"
id="reminder-date" id="reminder-date"
type="date" type="date"
value={reminderDate} value={reminderDate}
@@ -1027,7 +1034,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<label htmlFor="reminder-time" className="text-sm font-medium"> <label htmlFor="reminder-time" className="text-sm font-medium">
{t('notes.time')} {t('notes.time')}
</label> </label>
<Input <Input dir="auto"
id="reminder-time" id="reminder-time"
type="time" type="time"
value={reminderTime} value={reminderTime}
@@ -1077,7 +1084,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<DialogTitle>{t('notes.addLink')}</DialogTitle> <DialogTitle>{t('notes.addLink')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<Input <Input dir="auto"
placeholder="https://example.com" placeholder="https://example.com"
value={linkUrl} value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)} onChange={(e) => setLinkUrl(e.target.value)}

View File

@@ -44,7 +44,7 @@ export function NotebooksList() {
const pathname = usePathname() const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const { t } = useLanguage() const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks() const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag() const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels() const { labels } = useLabels()
@@ -160,7 +160,7 @@ export function NotebooksList() {
onDragOver={(e) => handleDragOver(e, notebook.id)} onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
className={cn( className={cn(
"flex flex-col mr-2 rounded-r-full transition-all relative", "flex flex-col me-2 rounded-e-full transition-all relative",
!notebook.color && "bg-primary/10 dark:bg-primary/20", !notebook.color && "bg-primary/10 dark:bg-primary/20",
isDragOver && "ring-2 ring-primary ring-dashed" isDragOver && "ring-2 ring-primary ring-dashed"
)} )}
@@ -211,7 +211,7 @@ export function NotebooksList() {
{isExpanded && ( {isExpanded && (
<div className="flex flex-col pb-2"> <div className="flex flex-col pb-2">
{labels.length === 0 ? ( {labels.length === 0 ? (
<p className="pointer-events-none pl-12 pr-4 py-2 text-xs text-muted-foreground"> <p className="pointer-events-none ps-12 pe-4 py-2 text-xs text-muted-foreground">
{t('sidebar.noLabelsInNotebook')} {t('sidebar.noLabelsInNotebook')}
</p> </p>
) : ( ) : (
@@ -221,7 +221,7 @@ export function NotebooksList() {
type="button" type="button"
onClick={() => handleLabelFilter(label.name, notebook.id)} onClick={() => handleLabelFilter(label.name, notebook.id)}
className={cn( className={cn(
'pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 rounded-r-full mr-2 transition-colors', 'pointer-events-auto flex items-center gap-4 ps-12 pe-4 py-2 rounded-e-full me-2 transition-colors',
'hover:bg-accent/60 text-muted-foreground hover:text-foreground', 'hover:bg-accent/60 text-muted-foreground hover:text-foreground',
searchParams.get('labels')?.includes(label.name) && searchParams.get('labels')?.includes(label.name) &&
'font-semibold text-foreground' 'font-semibold text-foreground'
@@ -235,7 +235,7 @@ export function NotebooksList() {
<button <button
type="button" type="button"
onClick={() => setLabelsDialogOpen(true)} onClick={() => setLabelsDialogOpen(true)}
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 rounded-r-full mr-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label" className="pointer-events-auto flex items-center gap-2 ps-12 pe-4 py-2 mt-1 rounded-e-full me-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
> >
<Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" /> <Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" />
<span className="text-xs font-medium">{t('sidebar.editLabels')}</span> <span className="text-xs font-medium">{t('sidebar.editLabels')}</span>
@@ -251,25 +251,25 @@ export function NotebooksList() {
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
className={cn( className={cn(
"flex items-center relative", "flex items-center relative",
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-r-full mr-2" isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-e-full me-2"
)} )}
> >
<button <button
onClick={() => handleSelectNotebook(notebook.id)} onClick={() => handleSelectNotebook(notebook.id)}
className={cn( className={cn(
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pr-24", "pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-24",
isDragOver && "opacity-50" isDragOver && "opacity-50"
)} )}
> >
<NotebookIcon className="w-5 h-5 flex-shrink-0" /> <NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-left">{notebook.name}</span> <span className="text-sm font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
{(notebook as any).notesCount > 0 && ( {(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ml-2 flex-shrink-0">({(notebook as any).notesCount})</span> <span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)} )}
</button> </button>
{/* Actions + expand on the right — always rendered, visible on hover */} {/* Actions + expand on the right — always rendered, visible on hover */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10"> <div className="absolute end-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<NotebookActions <NotebookActions
notebook={notebook} notebook={notebook}
onEdit={() => setEditingNotebook(notebook)} onEdit={() => setEditingNotebook(notebook)}

View File

@@ -0,0 +1,43 @@
'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
currentNotebookId?: string | null
}
export function NotesMainSection({ notes, viewMode, onEdit, 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} />
</div>
)
}

View File

@@ -0,0 +1,435 @@
'use client'
import { useCallback, useEffect, useState, useTransition } from 'react'
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { updateFullOrderWithoutRevalidation, createNote } from '@/app/actions/notes'
import {
GripVertical,
Hash,
ListChecks,
Pin,
FileText,
Clock,
Plus,
Loader2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NotesTabsViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
currentNotebookId?: string | null
}
// Color accent strip for each note
const COLOR_ACCENT: Record<NoteColor, string> = {
default: 'bg-primary',
red: 'bg-red-400',
orange: 'bg-orange-400',
yellow: 'bg-amber-400',
green: 'bg-emerald-400',
teal: 'bg-teal-400',
blue: 'bg-sky-400',
purple: 'bg-violet-400',
pink: 'bg-fuchsia-400',
gray: 'bg-gray-400',
}
// Background tint gradient for selected note panel
const COLOR_PANEL_BG: Record<NoteColor, string> = {
default: 'from-background to-background',
red: 'from-red-50/60 dark:from-red-950/20 to-background',
orange: 'from-orange-50/60 dark:from-orange-950/20 to-background',
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
}
const COLOR_ICON: Record<NoteColor, string> = {
default: 'text-primary',
red: 'text-red-500',
orange: 'text-orange-500',
yellow: 'text-amber-500',
green: 'text-emerald-500',
teal: 'text-teal-500',
blue: 'text-sky-500',
purple: 'text-violet-500',
pink: 'text-fuchsia-500',
gray: 'text-gray-500',
}
function getColorKey(note: Note): NoteColor {
return (typeof note.color === 'string' && note.color in NOTE_COLORS
? note.color
: 'default') as NoteColor
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
}
// ─── Sortable List Item ───────────────────────────────────────────────────────
function SortableNoteListItem({
note,
selected,
onSelect,
reorderLabel,
language,
untitledLabel,
}: {
note: Note
selected: boolean
onSelect: () => void
reorderLabel: string
language: string
untitledLabel: string
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: note.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
}
const ck = getColorKey(note)
const title = getNoteDisplayTitle(note, untitledLabel)
const snippet =
note.type === 'checklist'
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150)
: (note.content || '').substring(0, 150)
const dateLocale = getDateLocale(language)
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: dateLocale,
})
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch gap-0 rounded-xl transition-all duration-150',
'border',
selected
? 'border-primary/20 bg-primary/5 dark:bg-primary/10 shadow-sm'
: 'border-transparent hover:border-border/60 hover:bg-muted/50',
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30'
)}
onClick={onSelect}
role="option"
aria-selected={selected}
>
{/* Color accent bar */}
<div
className={cn(
'w-1 shrink-0 rounded-s-xl transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
)}
/>
{/* Drag handle */}
<button
type="button"
className="flex cursor-grab items-center px-1.5 text-muted-foreground/30 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
{/* Note type icon */}
<div className="flex items-center py-4 pe-1">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
) : (
<FileText
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
)}
</div>
{/* Text content */}
<div className="min-w-0 flex-1 py-3.5 pe-3">
<div className="flex items-center gap-2">
<p
className={cn(
'truncate text-sm font-medium transition-colors',
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
)}
>
{title}
</p>
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary" aria-label="Épinglée" />
)}
</div>
{snippet && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">{snippet}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
<Clock className="h-2.5 w-2.5" />
{timeAgo}
</span>
{note.labels && note.labels.length > 0 && (
<>
<span className="text-muted-foreground/30">·</span>
<div className="flex items-center gap-1">
<Hash className="h-2.5 w-2.5 text-muted-foreground/40" />
<span className="truncate text-[11px] text-muted-foreground/50">
{note.labels.slice(0, 2).join(', ')}
{note.labels.length > 2 && ` +${note.labels.length - 2}`}
</span>
</div>
</>
)}
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
const { t, language } = useLanguage()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
useEffect(() => {
// Only reset when notes are added or removed, NOT on content/field changes
// Field changes arrive through onChange -> setItems already
setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
if (prevIds === incomingIds) {
// Same set of notes: merge only structural fields (pin, color, archive)
return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id)
if (!fresh) return p
return { ...fresh, title: p.title, content: p.content, labels: p.labels }
})
}
// Different set (add/remove): full sync
return notes
})
}, [notes])
useEffect(() => {
if (items.length === 0) {
setSelectedId(null)
return
}
setSelectedId((prev) =>
prev && items.some((n) => n.id === prev) ? prev : items[0].id
)
}, [items])
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.findIndex((n) => n.id === active.id)
const newIndex = items.findIndex((n) => n.id === over.id)
if (oldIndex < 0 || newIndex < 0) return
const reordered = arrayMove(items, oldIndex, newIndex)
setItems(reordered)
try {
await updateFullOrderWithoutRevalidation(reordered.map((n) => n.id))
} catch {
setItems(notes)
toast.error(t('notes.moveFailed'))
}
},
[items, notes, t]
)
const selected = items.find((n) => n.id === selectedId) ?? null
const colorKey = selected ? getColorKey(selected) : 'default'
/** Create a new blank note, add it to the sidebar and select it immediately */
const handleCreateNote = () => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
title: null,
notebookId: currentNotebookId || null,
skipRevalidation: true
})
if (!newNote) return
setItems((prev) => [newNote, ...prev])
setSelectedId(newNote.id)
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
}
})
}
if (items.length === 0) {
return (
<div
className="flex min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-12 text-center"
data-testid="notes-grid-tabs-empty"
>
<p className="max-w-md text-sm text-muted-foreground">{t('notes.emptyStateTabs')}</p>
</div>
)
}
return (
<div
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-2xl border border-border/60 shadow-sm"
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
data-testid="notes-grid-tabs"
>
{/* ── Left sidebar: note list ── */}
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 bg-muted/20">
{/* Sidebar header with note count + new note button */}
<div className="border-b border-border/40 px-3 py-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
{t('notes.title')}
<span className="ms-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{items.length}
</span>
</span>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote') || 'Nouvelle note'}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{/* Scrollable note list */}
<div
className="flex-1 overflow-y-auto overscroll-contain p-2"
role="listbox"
aria-label={t('notes.viewTabs')}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{items.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
selected={note.id === selectedId}
onSelect={() => setSelectedId(note.id)}
reorderLabel={t('notes.reorderTabs')}
language={language}
untitledLabel={t('notes.untitled')}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
{/* ── Right content panel — always in edit mode ── */}
{selected ? (
<div
className={cn(
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
COLOR_PANEL_BG[colorKey]
)}
>
<NoteInlineEditor
key={selected.id}
note={selected}
colorKey={colorKey}
defaultPreviewMode={true}
onChange={(noteId, fields) => {
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
)
}}
onDelete={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
onArchive={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
/>
</div>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground/40">
<p className="text-sm">{t('notes.selectNote') || 'Sélectionnez une note'}</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { useTransition } from 'react'
import { LayoutGrid, PanelsTopLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { updateAISettings } from '@/app/actions/ai-settings'
import { useLanguage } from '@/lib/i18n'
import type { NotesViewMode } from '@/components/notes-main-section'
interface NotesViewToggleProps {
mode: NotesViewMode
onModeChange: (mode: NotesViewMode) => void
className?: string
}
export function NotesViewToggle({ mode, onModeChange, className }: NotesViewToggleProps) {
const { t, language } = useLanguage()
const [pending, startTransition] = useTransition()
const setMode = (next: NotesViewMode) => {
if (next === mode) return
const previous = mode
onModeChange(next)
startTransition(async () => {
try {
await updateAISettings({ notesViewMode: next })
} catch {
onModeChange(previous)
}
})
}
return (
<TooltipProvider delayDuration={300}>
<div
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
className={cn(
'inline-flex rounded-full border border-border bg-muted/40 p-0.5 shadow-sm',
className
)}
role="group"
aria-label={t('notes.viewModeGroup')}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
className={cn(
'h-9 rounded-full px-3 gap-1.5',
mode === 'masonry' && 'bg-background shadow-sm text-foreground'
)}
onClick={() => setMode('masonry')}
aria-pressed={mode === 'masonry'}
>
<LayoutGrid className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewCards')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewCardsTooltip')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
className={cn(
'h-9 rounded-full px-3 gap-1.5',
mode === 'tabs' && 'bg-background shadow-sm text-foreground'
)}
onClick={() => setMode('tabs')}
aria-pressed={mode === 'tabs'}
>
<PanelsTopLeft className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewTabs')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewTabsTooltip')}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)
}

View File

@@ -5,6 +5,7 @@ import { LabelProvider } from '@/context/LabelContext'
import { NotebooksProvider } from '@/context/notebooks-context' import { NotebooksProvider } from '@/context/notebooks-context'
import { NotebookDragProvider } from '@/context/notebook-drag-context' import { NotebookDragProvider } from '@/context/notebook-drag-context'
import { NoteRefreshProvider } from '@/context/NoteRefreshContext' import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
import { HomeViewProvider } from '@/context/home-view-context'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
interface ProvidersWrapperProps { interface ProvidersWrapperProps {
@@ -19,7 +20,7 @@ export function ProvidersWrapper({ children, initialLanguage = 'en' }: Providers
<NotebooksProvider> <NotebooksProvider>
<NotebookDragProvider> <NotebookDragProvider>
<LanguageProvider initialLanguage={initialLanguage as any}> <LanguageProvider initialLanguage={initialLanguage as any}>
{children} <HomeViewProvider>{children}</HomeViewProvider>
</LanguageProvider> </LanguageProvider>
</NotebookDragProvider> </NotebookDragProvider>
</NotebooksProvider> </NotebooksProvider>

View File

@@ -2,7 +2,7 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react' import { Settings, Sparkles, Palette, User, Database, Info, Check, Key } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
@@ -52,6 +52,12 @@ export function SettingsNav({ className }: SettingsNavProps) {
icon: <Database className="h-5 w-5" />, icon: <Database className="h-5 w-5" />,
href: '/settings/data' href: '/settings/data'
}, },
{
id: 'mcp',
label: t('mcpSettings.title'),
icon: <Key className="h-5 w-5" />,
href: '/settings/mcp'
},
{ {
id: 'about', id: 'about',
label: t('about.title'), label: t('about.title'),

View File

@@ -8,14 +8,25 @@ import {
Bell, Bell,
Archive, Archive,
Trash2, Trash2,
Plus,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { NotebooksList } from './notebooks-list' import { NotebooksList } from './notebooks-list'
import { useHomeViewOptional } from '@/context/home-view-context'
export function Sidebar({ className, user }: { className?: string, user?: any }) { export function Sidebar({ className, user }: { className?: string, user?: any }) {
const pathname = usePathname() const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { t } = useLanguage() const { t } = useLanguage()
const homeBridge = useHomeViewOptional()
// Helper to determine if a link is active // Helper to determine if a link is active
const isActive = (href: string, exact = false) => { const isActive = (href: string, exact = false) => {
@@ -43,7 +54,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
<Link <Link
href={href} href={href}
className={cn( className={cn(
"flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 transition-colors", "flex items-center gap-4 px-6 py-3 rounded-e-full me-2 transition-colors",
"text-sm font-medium tracking-wide", "text-sm font-medium tracking-wide",
active active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground" ? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
@@ -61,7 +72,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
className className
)}> )}>
{/* Main Navigation */} {/* Main Navigation */}
<div className="flex flex-col"> <div className="flex flex-col gap-1 px-3">
<NavItem <NavItem
href="/" href="/"
icon={Lightbulb} icon={Lightbulb}
@@ -74,6 +85,26 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
label={t('sidebar.reminders') || 'Rappels'} label={t('sidebar.reminders') || 'Rappels'}
active={isActive('/reminders')} active={isActive('/reminders')}
/> />
{pathname === '/' && homeBridge?.controls?.isTabsMode && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
className="w-full justify-start gap-3 rounded-e-full ps-4 pe-3 font-medium shadow-sm"
onClick={() => homeBridge.controls?.openNoteComposer()}
>
<Plus className="h-5 w-5 shrink-0" />
<span className="truncate">{t('sidebar.newNoteTabs')}</span>
<Sparkles className="ml-auto h-4 w-4 shrink-0 text-primary" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[240px]">
{t('sidebar.newNoteTabsHint')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
{/* Notebooks Section */} {/* Notebooks Section */}

View File

@@ -22,6 +22,7 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
<span>{t('titleSuggestions.title')}</span> <span>{t('titleSuggestions.title')}</span>
</div> </div>
<button <button
type="button"
onClick={onDismiss} onClick={onDismiss}
className="text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 transition-colors" className="text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 transition-colors"
> >
@@ -33,6 +34,7 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
{suggestions.map((suggestion, index) => ( {suggestions.map((suggestion, index) => (
<button <button
key={index} key={index}
type="button"
onClick={() => onSelect(suggestion.title)} onClick={() => onSelect(suggestion.title)}
className={cn( className={cn(
"w-full text-left px-3 py-2 rounded-md transition-all", "w-full text-left px-3 py-2 rounded-md transition-all",

View File

@@ -0,0 +1,36 @@
'use client'
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
export type HomeUiControls = {
isTabsMode: boolean
openNoteComposer: () => void
}
type Ctx = {
controls: HomeUiControls | null
setControls: (c: HomeUiControls | null) => void
}
const HomeViewContext = createContext<Ctx | null>(null)
export function HomeViewProvider({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<HomeUiControls | null>(null)
const value = useMemo(() => ({ controls, setControls }), [controls])
return <HomeViewContext.Provider value={value}>{children}</HomeViewContext.Provider>
}
/** Enregistré par la page daccueil ; la sidebar lit `controls` */
export function useHomeView() {
const ctx = useContext(HomeViewContext)
if (!ctx) {
throw new Error('useHomeView must be used within HomeViewProvider')
}
return ctx
}
/** Sidebar / shells : ne pas planter si hors provider */
export function useHomeViewOptional(): Ctx | null {
return useContext(HomeViewContext)
}

View File

@@ -1,3 +1,4 @@
import { useLanguage } from '@/lib/i18n'
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useDebounce } from './use-debounce'; import { useDebounce } from './use-debounce';
import { TagSuggestion } from '@/lib/ai/types'; import { TagSuggestion } from '@/lib/ai/types';
@@ -9,6 +10,7 @@ interface UseAutoTaggingProps {
} }
export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoTaggingProps) { export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoTaggingProps) {
const { language } = useLanguage();
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]); const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -42,7 +44,7 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
body: JSON.stringify({ body: JSON.stringify({
content: contentToAnalyze, content: contentToAnalyze,
notebookId: notebookId || undefined, notebookId: notebookId || undefined,
language: document.documentElement.lang || 'en', language: language || document.documentElement.lang || 'en',
}), }),
}); });

View File

@@ -15,24 +15,40 @@ export class OllamaProvider implements AIProvider {
this.embeddingModelName = embeddingModelName || modelName; this.embeddingModelName = embeddingModelName || modelName;
} }
async generateTags(content: string): Promise<TagSuggestion[]> { async generateTags(content: string, language: string = "en"): Promise<TagSuggestion[]> {
try { try {
const promptText = language === 'fa'
? `متن زیر را تحلیل کن و مفاهیم کلیدی را به عنوان برچسب استخراج کن (حداکثر ۱-۳ کلمه).
قوانین:
- کلمات ربط را حذف کن.
- عبارات ترکیبی را حفظ کن.
- حداکثر ۵ برچسب.
پاسخ فقط به صورت لیست JSON با فرمت [{"tag": "string", "confidence": number}]
متن: "${content}"`
: language === 'fr'
? `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
Règles:
- Pas de mots de liaison.
- Garde les expressions composées ensemble.
- Normalise en minuscules sauf noms propres.
- Maximum 5 tags.
Réponds UNIQUEMENT sous forme de liste JSON d'objets : [{"tag": "string", "confidence": number}].
Contenu de la note: "${content}"`
: `Analyze the following note and extract key concepts as short tags (1-3 words max).
Rules:
- No stop words.
- Keep compound expressions together.
- Lowercase unless proper noun.
- Max 5 tags.
Respond ONLY as a JSON list of objects: [{"tag": "string", "confidence": number}].
Note content: "${content}"`;
const response = await fetch(`${this.baseUrl}/generate`, { const response = await fetch(`${this.baseUrl}/generate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: this.modelName, model: this.modelName,
prompt: `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max). prompt: promptText,
Règles:
- Pas de mots de liaison (le, la, pour, et...).
- Garde les expressions composées ensemble (ex: "semaine prochaine", "New York").
- Normalise en minuscules sauf noms propres.
- Maximum 5 tags.
Réponds UNIQUEMENT sous forme de liste JSON d'objets : [{"tag": "string", "confidence": number}].
Contenu de la note: "${content}"`,
stream: false, stream: false,
}), }),
}); });
@@ -88,9 +104,7 @@ export class OllamaProvider implements AIProvider {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: this.modelName, model: this.modelName,
prompt: `${prompt} prompt: `${prompt}\n\nRéponds UNIQUEMENT sous forme de tableau JSON : [{"title": "string", "confidence": number}]`,
Réponds UNIQUEMENT sous forme de tableau JSON : [{"title": "string", "confidence": number}]`,
stream: false, stream: false,
}), }),
}); });

View File

@@ -12,7 +12,7 @@ export interface AIProvider {
/** /**
* Analyse le contenu et suggère des tags pertinents. * Analyse le contenu et suggère des tags pertinents.
*/ */
generateTags(content: string): Promise<TagSuggestion[]>; generateTags(content: string, language?: string): Promise<TagSuggestion[]>;
/** /**
* Génère un vecteur d'embeddings pour la recherche sémantique. * Génère un vecteur d'embeddings pour la recherche sémantique.

View File

@@ -41,6 +41,8 @@ export interface Translations {
editLabels: string editLabels: string
archive: string archive: string
trash: string trash: string
newNoteTabs: string
newNoteTabsHint: string
} }
notes: { notes: {
title: string title: string
@@ -103,6 +105,7 @@ export interface Translations {
noNotes: string noNotes: string
noNotesFound: string noNotesFound: string
createFirstNote: string createFirstNote: string
emptyStateTabs: string
size: string size: string
small: string small: string
medium: string medium: string
@@ -126,6 +129,12 @@ export interface Translations {
markdownOff: string markdownOff: string
undo: string undo: string
redo: string redo: string
viewCards: string
viewTabs: string
viewCardsTooltip: string
viewTabsTooltip: string
viewModeGroup: string
reorderTabs: string
} }
pagination: { pagination: {
previous: string previous: string
@@ -782,6 +791,10 @@ export interface Translations {
appearance: { appearance: {
title: string title: string
description: string description: string
notesViewDescription: string
notesViewLabel: string
notesViewTabs: string
notesViewMasonry: string
} }
generalSettings: { generalSettings: {
title: string title: string

View File

@@ -0,0 +1,33 @@
/**
* Plain-text preview for list view (light markdown stripping).
*/
export function stripMarkdownPreview(raw: string, maxLen = 180): string {
if (!raw?.trim()) return ''
let t = raw
.replace(/^#{1,6}\s+/gm, '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[(.+?)]\([^)]+\)/g, '$1')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (t.length > maxLen) {
return `${t.slice(0, maxLen).trim()}`
}
return t
}
export function getNoteDisplayTitle(note: { title: string | null; content: string; type: string }, untitled: string): string {
const title = note.title?.trim()
if (title) return title
if (note.type === 'checklist') {
const line = note.content?.split('\n').find((l) => l.trim())
if (line) return stripMarkdownPreview(line, 80) || untitled
}
const preview = stripMarkdownPreview(note.content || '', 100)
return preview || untitled
}

View File

@@ -997,5 +997,65 @@
"collapse": "طي", "collapse": "طي",
"expand": "توسيع", "expand": "توسيع",
"open": "فتح" "open": "فتح"
},
"mcpSettings": {
"title": "إعدادات MCP",
"description": "إدارة مفاتيح API وتكوين الأدوات الخارجية",
"whatIsMcp": {
"title": "ما هو MCP؟",
"description": "بروتوكول سياق النموذج (MCP) هو بروتوكول مفتوح يمكّن نماذج الذكاء الاصطناعي من التفاعل بأمان مع الأدوات ومصادر البيانات الخارجية. باستخدام MCP، يمكنك ربط أدوات مثل Claude Code و Cursor و N8N بمثيل Keep Notes الخاص بك لقراءة ملاحظاتك وإنشائها وتنظيمها برمجيًا.",
"learnMore": "معرفة المزيد عن MCP"
},
"serverStatus": {
"title": "حالة الخادم",
"running": "قيد التشغيل",
"stopped": "متوقف",
"mode": "الوضع",
"url": "URL"
},
"apiKeys": {
"title": "مفاتيح API",
"description": "تسمح مفاتيح API للأدوات الخارجية بالوصول إلى ملاحظاتك عبر MCP. حافظ على سرية مفاتيحك.",
"generate": "إنشاء مفتاح جديد",
"empty": "لا توجد مفاتيح API بعد. أنشئ واحدًا للبدء.",
"active": "نشط",
"revoked": "ملغى",
"revoke": "إلغاء",
"delete": "حذف",
"createdAt": "تاريخ الإنشاء",
"lastUsed": "آخر استخدام",
"never": "أبدًا",
"confirmRevoke": "هل أنت متأكد من إلغاء هذا المفتاح؟ ستفقد الأدوات التي تستخدمه صلاحية الوصول.",
"confirmDelete": "هل أنت متأكد من حذف هذا المفتاح نهائيًا؟"
},
"createDialog": {
"title": "إنشاء مفتاح API",
"description": "أنشئ مفتاح API جديدًا لربط الأدوات الخارجية بملاحظاتك.",
"nameLabel": "اسم المفتاح",
"namePlaceholder": "مثال: Claude Code، Cursor، N8N",
"generating": "جاري الإنشاء...",
"generate": "إنشاء",
"successTitle": "تم إنشاء مفتاح API",
"successDescription": "انسخ مفتاح API الآن. لن تتمكن من رؤيته مرة أخرى.",
"copy": "نسخ",
"copied": "تم النسخ!",
"done": "تم"
},
"configInstructions": {
"title": "تعليمات التكوين",
"description": "استخدم مفتاح API الخاص بك لتكوين هذه الأدوات.",
"claudeCode": {
"title": "Claude Code",
"description": "أضف هذا إلى ملف تكوين MCP الخاص بـ Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "أضف هذا إلى إعدادات MCP الخاصة بـ Cursor:"
},
"n8n": {
"title": "N8N",
"description": "استخدم بيانات الاعتماد هذه في عقدة N8N MCP:"
}
}
} }
} }

View File

@@ -997,5 +997,65 @@
"collapse": "Zusammenklappen", "collapse": "Zusammenklappen",
"expand": "Erweitern", "expand": "Erweitern",
"open": "Öffnen" "open": "Öffnen"
},
"mcpSettings": {
"title": "MCP-Einstellungen",
"description": "API-Schlüssel verwalten und externe Tools konfigurieren",
"whatIsMcp": {
"title": "Was ist MCP?",
"description": "Das Model Context Protocol (MCP) ist ein offenes Protokoll, das es KI-Modellen ermöglicht, sicher mit externen Tools und Datenquellen zu interagieren. Mit MCP können Sie Tools wie Claude Code, Cursor oder N8N mit Ihrer Keep Notes-Instanz verbinden, um Ihre Notes programmgesteuert zu lesen, zu erstellen und zu organisieren.",
"learnMore": "Mehr über MCP erfahren"
},
"serverStatus": {
"title": "Serverstatus",
"running": "Aktiv",
"stopped": "Gestoppt",
"mode": "Modus",
"url": "URL"
},
"apiKeys": {
"title": "API-Schlüssel",
"description": "API-Schlüssel ermöglichen externen Tools den Zugriff auf Ihre Notes über MCP. Halten Sie Ihre Schlüssel geheim.",
"generate": "Neuen Schlüssel generieren",
"empty": "Noch keine API-Schlüssel. Generieren Sie einen, um zu beginnen.",
"active": "Aktiv",
"revoked": "Widerrufen",
"revoke": "Widerrufen",
"delete": "Löschen",
"createdAt": "Erstellt",
"lastUsed": "Zuletzt verwendet",
"never": "Nie",
"confirmRevoke": "Sind Sie sicher, dass Sie diesen Schlüssel widerrufen möchten? Tools, die ihn verwenden, verlieren den Zugriff.",
"confirmDelete": "Sind Sie sicher, dass Sie diesen Schlüssel dauerhaft löschen möchten?"
},
"createDialog": {
"title": "API-Schlüssel generieren",
"description": "Erstellen Sie einen neuen API-Schlüssel, um externe Tools mit Ihren Notes zu verbinden.",
"nameLabel": "Schlüsselname",
"namePlaceholder": "z.B. Claude Code, Cursor, N8N",
"generating": "Wird generiert...",
"generate": "Generieren",
"successTitle": "API-Schlüssel generiert",
"successDescription": "Kopieren Sie Ihren API-Schlüssel jetzt. Sie können ihn später nicht mehr einsehen.",
"copy": "Kopieren",
"copied": "Kopiert!",
"done": "Fertig"
},
"configInstructions": {
"title": "Konfigurationsanleitung",
"description": "Verwenden Sie Ihren API-Schlüssel zur Konfiguration dieser Tools.",
"claudeCode": {
"title": "Claude Code",
"description": "Fügen Sie dies zur MCP-Konfigurationsdatei von Claude Code hinzu:"
},
"cursor": {
"title": "Cursor",
"description": "Fügen Sie dies zu Ihren Cursor MCP-Einstellungen hinzu:"
},
"n8n": {
"title": "N8N",
"description": "Verwenden Sie diese Anmeldeinformationen in Ihrem N8N MCP-Knoten:"
}
}
} }
} }

View File

@@ -33,6 +33,8 @@
"reminders": "Reminders", "reminders": "Reminders",
"labels": "Labels", "labels": "Labels",
"editLabels": "Edit labels", "editLabels": "Edit labels",
"newNoteTabs": "New Note",
"newNoteTabsHint": "Create note in this notebook",
"noLabelsInNotebook": "No labels in this notebook yet", "noLabelsInNotebook": "No labels in this notebook yet",
"archive": "Archive", "archive": "Archive",
"trash": "Trash" "trash": "Trash"
@@ -104,7 +106,7 @@
"large": "Large", "large": "Large",
"shareWithCollaborators": "Share with collaborators", "shareWithCollaborators": "Share with collaborators",
"view": "View Note", "view": "View Note",
"edit": "Edit Note", "edit": "Edit",
"readOnly": "Read Only", "readOnly": "Read Only",
"preview": "Preview", "preview": "Preview",
"noContent": "No content", "noContent": "No content",
@@ -128,6 +130,7 @@
"dragToReorder": "Drag to reorder", "dragToReorder": "Drag to reorder",
"more": "More options", "more": "More options",
"emptyState": "No notes yet. Create your first note!", "emptyState": "No notes yet. Create your first note!",
"emptyStateTabs": "No notes here yet. Use \"New note\" in the sidebar to add one (AI title suggestions appear in the composer).",
"inNotebook": "In notebook", "inNotebook": "In notebook",
"moveFailed": "Failed to move note. Please try again.", "moveFailed": "Failed to move note. Please try again.",
"clarifyFailed": "Failed to clarify text", "clarifyFailed": "Failed to clarify text",
@@ -137,7 +140,15 @@
"markdown": "Markdown", "markdown": "Markdown",
"unpinned": "Unpinned", "unpinned": "Unpinned",
"redoShortcut": "Redo (Ctrl+Y)", "redoShortcut": "Redo (Ctrl+Y)",
"undoShortcut": "Undo (Ctrl+Z)" "undoShortcut": "Undo (Ctrl+Z)",
"viewCards": "Cards View",
"viewCardsTooltip": "Card grid with drag-and-drop reorder",
"viewTabs": "Tabs View",
"viewTabsTooltip": "Tabs on top, note below — drag tabs to reorder",
"viewModeGroup": "Notes display mode",
"reorderTabs": "Reorder tab",
"modified": "Modified",
"created": "Created"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",
@@ -175,7 +186,8 @@
"loading": "Loading...", "loading": "Loading...",
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first.", "notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first.",
"count": "{count} labels", "count": "{count} labels",
"noLabels": "No labels" "noLabels": "No labels",
"confirmDeleteShort": "Confirm?"
}, },
"search": { "search": {
"placeholder": "Search", "placeholder": "Search",
@@ -290,7 +302,21 @@
"notebookSummary": { "notebookSummary": {
"regenerate": "Regenerate Summary", "regenerate": "Regenerate Summary",
"regenerating": "Regenerating summary..." "regenerating": "Regenerating summary..."
} },
"clarifyDesc": "Make the text clearer and easier to understand",
"shortenDesc": "Summarize the text and get to the point",
"improve": "Improve writing",
"improveDesc": "Fix grammar and enhance style",
"toMarkdown": "Format as Markdown",
"toMarkdownDesc": "Add headings, bullet points and structure the text",
"translate": "Translate",
"translateDesc": "Change the text language",
"translateBack": "Back",
"translationApplied": "Translation applied",
"translationFailed": "Translation failed",
"undo": "Undo AI",
"undoAI": "Undo AI transformation",
"undoApplied": "Original text restored"
}, },
"titleSuggestions": { "titleSuggestions": {
"available": "Title suggestions", "available": "Title suggestions",
@@ -397,7 +423,7 @@
"nav": { "nav": {
"home": "Home", "home": "Home",
"notes": "Notes", "notes": "Notes",
"notebooks": "Notebooks", "notebooks": "NOTEBOOKS",
"generalNotes": "General Notes", "generalNotes": "General Notes",
"archive": "Archive", "archive": "Archive",
"settings": "Settings", "settings": "Settings",
@@ -461,7 +487,15 @@
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search", "semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
"profile": "Profile", "profile": "Profile",
"searchNoResults": "No settings found", "searchNoResults": "No settings found",
"languageAuto": "Language set to Auto" "languageAuto": "Language set to Auto",
"emailNotifications": "Email notifications",
"emailNotificationsDesc": "Receive important notifications by email",
"desktopNotifications": "Desktop notifications",
"desktopNotificationsDesc": "Receive notifications in your browser",
"anonymousAnalytics": "Anonymous analytics",
"anonymousAnalyticsDesc": "Share anonymous usage data to help improve the app",
"notificationsDesc": "Manage your notification preferences",
"privacyDesc": "Control your data and privacy"
}, },
"profile": { "profile": {
"title": "Profile", "title": "Profile",
@@ -907,7 +941,11 @@
}, },
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"description": "Customize how the app looks" "description": "Customize how the app looks",
"notesViewDescription": "Choose how notes are shown on home and in notebooks.",
"notesViewLabel": "Notes layout",
"notesViewTabs": "Tabs (OneNote-style)",
"notesViewMasonry": "Cards (grid)"
}, },
"generalSettings": { "generalSettings": {
"title": "General Settings", "title": "General Settings",
@@ -1022,5 +1060,65 @@
"open": "Open", "open": "Open",
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse" "collapse": "Collapse"
},
"mcpSettings": {
"title": "MCP Settings",
"description": "Manage API keys and configure external tools",
"whatIsMcp": {
"title": "What is MCP?",
"description": "The Model Context Protocol (MCP) is an open protocol that enables AI models to securely interact with external tools and data sources. With MCP, you can connect tools like Claude Code, Cursor, or N8N to your Keep Notes instance to read, create, and organize your notes programmatically.",
"learnMore": "Learn more about MCP"
},
"serverStatus": {
"title": "Server Status",
"running": "Running",
"stopped": "Stopped",
"mode": "Mode",
"url": "URL"
},
"apiKeys": {
"title": "API Keys",
"description": "API keys allow external tools to access your notes via MCP. Keep your keys secret.",
"generate": "Generate a new key",
"empty": "No API keys yet. Generate one to get started.",
"active": "Active",
"revoked": "Revoked",
"revoke": "Revoke",
"delete": "Delete",
"createdAt": "Created",
"lastUsed": "Last used",
"never": "Never",
"confirmRevoke": "Are you sure you want to revoke this key? Tools using it will lose access.",
"confirmDelete": "Are you sure you want to permanently delete this key?"
},
"createDialog": {
"title": "Generate API Key",
"description": "Create a new API key to connect external tools to your notes.",
"nameLabel": "Key name",
"namePlaceholder": "e.g. Claude Code, Cursor, N8N",
"generating": "Generating...",
"generate": "Generate",
"successTitle": "API Key Generated",
"successDescription": "Copy your API key now. You won't be able to see it again.",
"copy": "Copy",
"copied": "Copied!",
"done": "Done"
},
"configInstructions": {
"title": "Configuration Instructions",
"description": "Use your API key to configure these tools.",
"claudeCode": {
"title": "Claude Code",
"description": "Add this to your Claude Code MCP configuration file:"
},
"cursor": {
"title": "Cursor",
"description": "Add this to your Cursor MCP settings:"
},
"n8n": {
"title": "N8N",
"description": "Use these credentials in your N8N MCP node:"
}
}
} }
} }

View File

@@ -992,5 +992,65 @@
"collapse": "Colapsar", "collapse": "Colapsar",
"expand": "Expandir", "expand": "Expandir",
"open": "Abrir" "open": "Abrir"
},
"mcpSettings": {
"title": "Configuración MCP",
"description": "Gestiona tus claves API y configura herramientas externas",
"whatIsMcp": {
"title": "¿Qué es MCP?",
"description": "El Model Context Protocol (MCP) es un protocolo abierto que permite a los modelos de IA interactuar de forma segura con herramientas y fuentes de datos externas. Con MCP, puedes conectar herramientas como Claude Code, Cursor o N8N a tu instancia de Keep Notes para leer, crear y organizar tus notas mediante programación.",
"learnMore": "Más información sobre MCP"
},
"serverStatus": {
"title": "Estado del servidor",
"running": "En ejecución",
"stopped": "Detenido",
"mode": "Modo",
"url": "URL"
},
"apiKeys": {
"title": "Claves API",
"description": "Las claves API permiten a las herramientas externas acceder a tus notas mediante MCP. Mantén tus claves en secreto.",
"generate": "Generar nueva clave",
"empty": "Aún no hay claves API. Genera una para empezar.",
"active": "Activa",
"revoked": "Revocada",
"revoke": "Revocar",
"delete": "Eliminar",
"createdAt": "Creada",
"lastUsed": "Último uso",
"never": "Nunca",
"confirmRevoke": "¿Estás seguro de que quieres revocar esta clave? Las herramientas que la usen perderán el acceso.",
"confirmDelete": "¿Estás seguro de que quieres eliminar permanentemente esta clave?"
},
"createDialog": {
"title": "Generar clave API",
"description": "Crea una nueva clave API para conectar herramientas externas a tus notas.",
"nameLabel": "Nombre de la clave",
"namePlaceholder": "ej. Claude Code, Cursor, N8N",
"generating": "Generando...",
"generate": "Generar",
"successTitle": "Clave API generada",
"successDescription": "Copia tu clave API ahora. No podrás volver a verla.",
"copy": "Copiar",
"copied": "¡Copiada!",
"done": "Listo"
},
"configInstructions": {
"title": "Instrucciones de configuración",
"description": "Usa tu clave API para configurar estas herramientas.",
"claudeCode": {
"title": "Claude Code",
"description": "Añade esto a tu archivo de configuración MCP de Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Añade esto a tus ajustes MCP de Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Usa estas credenciales en tu nodo MCP de N8N:"
}
}
} }
} }

View File

@@ -229,7 +229,15 @@
"transformError": "خطا در تبدیل", "transformError": "خطا در تبدیل",
"transformMarkdown": "تبدیل به مارک‌داون", "transformMarkdown": "تبدیل به مارک‌داون",
"transformSuccess": "متن با موفقیت به مارک‌داون تبدیل شد!", "transformSuccess": "متن با موفقیت به مارک‌داون تبدیل شد!",
"transforming": "در حال تبدیل..." "transforming": "در حال تبدیل...",
"translate": "ترجمه",
"translateDesc": "تغییر زبان متن",
"translateBack": "بازگشت",
"translationApplied": "ترجمه اعمال شد",
"translationFailed": "ترجمه ناموفق بود",
"undo": "لغو هوش مصنوعی",
"undoAI": "لغو تبدیل هوش مصنوعی",
"undoApplied": "متن اصلی بازگردانده شد"
}, },
"aiSettings": { "aiSettings": {
"description": "ویژگی‌ها و ترجیحات هوش مصنوعی خود را پیکربندی کنید", "description": "ویژگی‌ها و ترجیحات هوش مصنوعی خود را پیکربندی کنید",
@@ -568,7 +576,7 @@
"delete": "حذف", "delete": "حذف",
"deleteTooltip": "حذف برچسب", "deleteTooltip": "حذف برچسب",
"editLabels": "ویرایش برچسب‌ها", "editLabels": "ویرایش برچسب‌ها",
"editLabelsDescription": "ایجاد، ویرایش رنگ‌ها یا حذف برچسب‌ها.", "editLabelsDescription": "برچسب‌های خود را مدیریت کنید",
"filter": "فیلتر بر اساس برچسب", "filter": "فیلتر بر اساس برچسب",
"filterByLabel": "فیلتر بر اساس برچسب", "filterByLabel": "فیلتر بر اساس برچسب",
"labelColor": "رنگ برچسب", "labelColor": "رنگ برچسب",
@@ -579,15 +587,16 @@
"manageLabelsDescription": "Add or remove labels for this note. Click on a label to change its color.", "manageLabelsDescription": "Add or remove labels for this note. Click on a label to change its color.",
"manageTooltip": "مدیریت برچسب‌ها", "manageTooltip": "مدیریت برچسب‌ها",
"namePlaceholder": "نام برچسب", "namePlaceholder": "نام برچسب",
"newLabelPlaceholder": "ایجاد برچسب جدید", "newLabelPlaceholder": "برچسب جدید...",
"noLabels": "بدون برچسب", "noLabels": "بدون برچسب",
"noLabelsFound": "برچسبی یافت نشد.", "noLabelsFound": "برچسبی یافت نشد",
"notebookRequired": "⚠️ برچسب‌ها فقط در دفترچه‌ها در دسترس هستند. این یادداشت را ابتدا به یک دفترچه منتقل کنید.", "notebookRequired": "⚠️ برچسب‌ها فقط در دفترچه‌ها در دسترس هستند. این یادداشت را ابتدا به یک دفترچه منتقل کنید.",
"selectedLabels": "Selected Labels", "selectedLabels": "Selected Labels",
"showLess": "نمایش کمتر", "showLess": "نمایش کمتر",
"showMore": "نمایش بیشتر", "showMore": "نمایش بیشتر",
"tagAdded": "برچسب \"{tag}\" اضافه شد", "tagAdded": "برچسب \"{tag}\" اضافه شد",
"title": "برچسب‌ها" "title": "برچسب‌ها",
"confirmDeleteShort": "تایید؟"
}, },
"memoryEcho": { "memoryEcho": {
"clickToView": "برای مشاهده یادداشت کلیک کنید", "clickToView": "برای مشاهده یادداشت کلیک کنید",
@@ -684,7 +693,7 @@
"logout": "خروج", "logout": "خروج",
"manageAISettings": "Manage AI Settings", "manageAISettings": "Manage AI Settings",
"myLibrary": "کتابخانه من", "myLibrary": "کتابخانه من",
"notebooks": "Notebooks", "notebooks": "دفترچه‌ها",
"notes": "یادداشت‌ها", "notes": "یادداشت‌ها",
"proPlan": "پلن پرو", "proPlan": "پلن پرو",
"profile": "پروفایل", "profile": "پروفایل",
@@ -765,7 +774,7 @@
"delete": "حذف", "delete": "حذف",
"dragToReorder": "بکشید تا مرتب کنید", "dragToReorder": "بکشید تا مرتب کنید",
"duplicate": "تکثیر", "duplicate": "تکثیر",
"edit": "Edit Note", "edit": "ویرایش",
"emptyState": "یادداشتی نیست", "emptyState": "یادداشتی نیست",
"fileTooLarge": "فایل خیلی بزرگ است: {fileName}. حداکثر اندازه {maxSize}.", "fileTooLarge": "فایل خیلی بزرگ است: {fileName}. حداکثر اندازه {maxSize}.",
"improveFailed": "بهبود شکست خورد", "improveFailed": "بهبود شکست خورد",
@@ -801,7 +810,7 @@
"pinned": "سنجاق شده", "pinned": "سنجاق شده",
"pinnedNotes": "یادداشت‌های سنجاق شده", "pinnedNotes": "یادداشت‌های سنجاق شده",
"placeholder": "یادداشت بگیرید...", "placeholder": "یادداشت بگیرید...",
"preview": "Preview", "preview": "پیش‌نمایش",
"readOnly": "Read Only", "readOnly": "Read Only",
"recent": "اخیر", "recent": "اخیر",
"redo": "انجام مجدد", "redo": "انجام مجدد",
@@ -837,7 +846,11 @@
"unpinned": "سنجاق نشده", "unpinned": "سنجاق نشده",
"untitled": "بدون عنوان", "untitled": "بدون عنوان",
"uploadFailed": "آپلود {fileName} شکست خورد", "uploadFailed": "آپلود {fileName} شکست خورد",
"view": "View Note" "view": "View Note",
"modified": "ویرایش شده",
"created": "ایجاد شده",
"viewTabs": "نمایش زبانه‌ای",
"viewCards": "نمایش کارتی"
}, },
"pagination": { "pagination": {
"next": "→", "next": "→",
@@ -962,15 +975,26 @@
"themeLight": "روشن", "themeLight": "روشن",
"themeSystem": "سیستم", "themeSystem": "سیستم",
"title": "تنظیمات", "title": "تنظیمات",
"version": "نسخه" "version": "نسخه",
"emailNotifications": "اعلان‌های ایمیل",
"emailNotificationsDesc": "دریافت اعلان‌های مهم از طریق ایمیل",
"desktopNotifications": "اعلان‌های مرورگر",
"desktopNotificationsDesc": "دریافت اعلان‌ها در مرورگر",
"anonymousAnalytics": "تحلیل‌های ناشناس",
"anonymousAnalyticsDesc": "اشتراک داده‌های استفاده ناشناس برای بهبود برنامه",
"notificationsDesc": "مدیریت تنظیمات اعلان",
"privacyDesc": "کنترل داده‌ها و حریم خصوصی شما"
}, },
"sidebar": { "sidebar": {
"archive": "Archive", "archive": "بایگانی",
"editLabels": "Edit labels", "editLabels": "ویرایش برچسب‌ها",
"labels": "Labels", "labels": "Labels",
"notes": "Notes", "notes": "یادداشت‌ها",
"reminders": "Reminders", "reminders": "یادآورها",
"trash": "Trash" "trash": "زباله‌دان",
"newNoteTabs": "یادداشت جدید",
"newNoteTabsHint": "ایجاد یادداشت جدید در این دفترچه",
"edit": "ویرایش یادداشت"
}, },
"support": { "support": {
"aiApiCosts": "هزینه‌های AI API:", "aiApiCosts": "هزینه‌های AI API:",
@@ -1050,5 +1074,65 @@
"collapse": "جمع کردن", "collapse": "جمع کردن",
"expand": "بسط دادن", "expand": "بسط دادن",
"open": "باز کردن" "open": "باز کردن"
},
"mcpSettings": {
"title": "تنظیمات MCP",
"description": "مدیریت کلیدهای API و پیکربندی ابزارهای خارجی",
"whatIsMcp": {
"title": "MCP چیست؟",
"description": "پروتکل زمینه مدل (MCP) یک پروتکل باز است که به مدل‌های هوش مصنوعی امکان تعامل امن با ابزارها و منابع داده خارجی را می‌دهد. با MCP می‌توانید ابزارهایی مانند Claude Code، Cursor یا N8N را به نمونه Keep Notes خود متصل کنید تا یادداشت‌های خود را به صورت برنامه‌نویسی بخوانید، ایجاد کنید و سازماندهی کنید.",
"learnMore": "بیشتر درباره MCP بدانید"
},
"serverStatus": {
"title": "وضعیت سرور",
"running": "در حال اجرا",
"stopped": "متوقف",
"mode": "حالت",
"url": "URL"
},
"apiKeys": {
"title": "کلیدهای API",
"description": "کلیدهای API به ابزارهای خارجی اجازه می‌دهند از طریق MCP به یادداشت‌های شما دسترسی پیدا کنند. کلیدهای خود را محرمانه نگه دارید.",
"generate": "ایجاد کلید جدید",
"empty": "هنوز کلید API وجود ندارد. برای شروع یکی ایجاد کنید.",
"active": "فعال",
"revoked": "لغو شده",
"revoke": "لغو",
"delete": "حذف",
"createdAt": "تاریخ ایجاد",
"lastUsed": "آخرین استفاده",
"never": "هرگز",
"confirmRevoke": "آیا مطمئن هستید که می‌خواهید این کلید را لغو کنید؟ ابزارهایی که از آن استفاده می‌کنند دسترسی خود را از دست می‌دهند.",
"confirmDelete": "آیا مطمئن هستید که می‌خواهید این کلید را برای همیشه حذف کنید؟"
},
"createDialog": {
"title": "ایجاد کلید API",
"description": "یک کلید API جدید برای اتصال ابزارهای خارجی به یادداشت‌های خود ایجاد کنید.",
"nameLabel": "نام کلید",
"namePlaceholder": "مثال: Claude Code، Cursor، N8N",
"generating": "در حال ایجاد...",
"generate": "ایجاد",
"successTitle": "کلید API ایجاد شد",
"successDescription": "کلید API خود را همین حالا کپی کنید. دیگر نمی‌توانید آن را ببینید.",
"copy": "کپی",
"copied": "کپی شد!",
"done": "انجام شد"
},
"configInstructions": {
"title": "دستورالعمل پیکربندی",
"description": "از کلید API خود برای پیکربندی این ابزارها استفاده کنید.",
"claudeCode": {
"title": "Claude Code",
"description": "این را به فایل پیکربندی MCP Claude Code خود اضافه کنید:"
},
"cursor": {
"title": "Cursor",
"description": "این را به تنظیمات MCP Cursor خود اضافه کنید:"
},
"n8n": {
"title": "N8N",
"description": "از این اعتبارنامه‌ها در گره N8N MCP خود استفاده کنید:"
}
}
} }
} }

View File

@@ -254,7 +254,21 @@
"transformError": "Erreur lors de la transformation", "transformError": "Erreur lors de la transformation",
"transformMarkdown": "Transformer en Markdown", "transformMarkdown": "Transformer en Markdown",
"transformSuccess": "Texte transformé en Markdown avec succès !", "transformSuccess": "Texte transformé en Markdown avec succès !",
"transforming": "Transformation..." "transforming": "Transformation...",
"clarifyDesc": "Rendre le propos plus clair et compréhensible",
"shortenDesc": "Résumer le texte et aller à l'essentiel",
"improve": "Améliorer la rédaction",
"improveDesc": "Corriger les fautes et le style",
"toMarkdown": "Formater en Markdown",
"toMarkdownDesc": "Ajouter des titres, des puces et structurer le texte",
"translate": "Traduire",
"translateDesc": "Changer la langue du texte",
"translateBack": "Retour",
"translationApplied": "Traduction appliquée",
"translationFailed": "Traduction échouée",
"undo": "Annuler IA",
"undoAI": "Annuler la transformation IA",
"undoApplied": "Texte original restauré"
}, },
"aiSettings": { "aiSettings": {
"description": "Configurez vos fonctionnalités IA et préférences", "description": "Configurez vos fonctionnalités IA et préférences",
@@ -280,6 +294,10 @@
}, },
"appearance": { "appearance": {
"description": "Personnaliser l'apparence de l'application", "description": "Personnaliser l'apparence de l'application",
"notesViewDescription": "Choisissez comment afficher les notes sur l'accueil et dans les carnets.",
"notesViewLabel": "Affichage des notes",
"notesViewTabs": "Onglets (type OneNote)",
"notesViewMasonry": "Cartes (grille)",
"title": "Apparence" "title": "Apparence"
}, },
"auth": { "auth": {
@@ -557,7 +575,8 @@
"showLess": "Voir moins", "showLess": "Voir moins",
"showMore": "Voir plus", "showMore": "Voir plus",
"tagAdded": "Étiquette \"{tag}\" ajoutée", "tagAdded": "Étiquette \"{tag}\" ajoutée",
"title": "Étiquettes" "title": "Étiquettes",
"confirmDeleteShort": "Confirmer ?"
}, },
"memoryEcho": { "memoryEcho": {
"clickToView": "Cliquer pour voir la note →", "clickToView": "Cliquer pour voir la note →",
@@ -659,7 +678,7 @@
"logout": "Déconnexion", "logout": "Déconnexion",
"manageAISettings": "Gérer les paramètres IA", "manageAISettings": "Gérer les paramètres IA",
"myLibrary": "Ma bibliothèque", "myLibrary": "Ma bibliothèque",
"notebooks": "Cahiers", "notebooks": "CARNETS",
"notes": "Notes", "notes": "Notes",
"proPlan": "Pro Plan", "proPlan": "Pro Plan",
"profile": "Profil", "profile": "Profil",
@@ -749,8 +768,9 @@
"delete": "Supprimer", "delete": "Supprimer",
"dragToReorder": "Glisser pour réorganiser", "dragToReorder": "Glisser pour réorganiser",
"duplicate": "Dupliquer", "duplicate": "Dupliquer",
"edit": "Modifier la note", "edit": "Éditer",
"emptyState": "Aucune note encore. Créez votre première note !", "emptyState": "Aucune note encore. Créez votre première note !",
"emptyStateTabs": "Aucune note dans cette vue. Utilisez « Nouvelle note » dans la barre latérale pour en ajouter une (titres suggérés par lIA disponibles dans le compositeur).",
"fileTooLarge": "Fichier trop volumineux : {fileName}. Taille maximale : {maxSize}.", "fileTooLarge": "Fichier trop volumineux : {fileName}. Taille maximale : {maxSize}.",
"improveFailed": "Échec de l'amélioration du texte", "improveFailed": "Échec de l'amélioration du texte",
"inNotebook": "Dans le carnet", "inNotebook": "Dans le carnet",
@@ -821,7 +841,15 @@
"unpinned": "Désépinglées", "unpinned": "Désépinglées",
"untitled": "Sans titre", "untitled": "Sans titre",
"uploadFailed": "Échec du téléchargement", "uploadFailed": "Échec du téléchargement",
"view": "Voir la note" "view": "Voir la note",
"viewCards": "Vue par cartes",
"viewCardsTooltip": "Grille de cartes et réorganisation par glisser-déposer",
"viewTabs": "Vue par onglets",
"viewTabsTooltip": "Onglets en haut, contenu dessous — glisser les onglets pour réordonner",
"viewModeGroup": "Mode d'affichage des notes",
"reorderTabs": "Réordonner l'onglet",
"modified": "Modifiée",
"created": "Créée"
}, },
"pagination": { "pagination": {
"next": "→", "next": "→",
@@ -952,7 +980,15 @@
"themeLight": "Clair", "themeLight": "Clair",
"themeSystem": "Système", "themeSystem": "Système",
"title": "Paramètres", "title": "Paramètres",
"version": "Version" "version": "Version",
"emailNotifications": "Notifications par email",
"emailNotificationsDesc": "Recevoir des notifications importantes par email",
"desktopNotifications": "Notifications bureau",
"desktopNotificationsDesc": "Recevoir des notifications dans votre navigateur",
"anonymousAnalytics": "Analyses anonymes",
"anonymousAnalyticsDesc": "Partager des données d'utilisation anonymes pour améliorer l'application",
"notificationsDesc": "Gérez vos préférences de notifications",
"privacyDesc": "Contrôlez vos données et votre confidentialité"
}, },
"reminders": { "reminders": {
"title": "Rappels", "title": "Rappels",
@@ -970,6 +1006,8 @@
"archive": "Archives", "archive": "Archives",
"editLabels": "Modifier les étiquettes", "editLabels": "Modifier les étiquettes",
"labels": "Étiquettes", "labels": "Étiquettes",
"newNoteTabs": "Nouvelle note",
"newNoteTabsHint": "Créer une note dans ce carnet",
"noLabelsInNotebook": "Aucune étiquette dans ce carnet", "noLabelsInNotebook": "Aucune étiquette dans ce carnet",
"notes": "Notes", "notes": "Notes",
"reminders": "Rappels", "reminders": "Rappels",
@@ -1053,5 +1091,65 @@
"collapse": "Réduire", "collapse": "Réduire",
"expand": "Développer", "expand": "Développer",
"open": "Ouvrir" "open": "Ouvrir"
},
"mcpSettings": {
"title": "Paramètres MCP",
"description": "Gérez vos clés API et configurez les outils externes",
"whatIsMcp": {
"title": "Qu'est-ce que MCP ?",
"description": "Le Model Context Protocol (MCP) est un protocole ouvert qui permet aux modèles IA d'interagir de manière sécurisée avec des outils et sources de données externes. Avec MCP, vous pouvez connecter des outils comme Claude Code, Cursor ou N8N à votre instance Keep Notes pour lire, créer et organiser vos notes par programmation.",
"learnMore": "En savoir plus sur MCP"
},
"serverStatus": {
"title": "État du serveur",
"running": "En cours",
"stopped": "Arrêté",
"mode": "Mode",
"url": "URL"
},
"apiKeys": {
"title": "Clés API",
"description": "Les clés API permettent aux outils externes d'accéder à vos notes via MCP. Gardez vos clés secrètes.",
"generate": "Générer une nouvelle clé",
"empty": "Aucune clé API. Générez-en une pour commencer.",
"active": "Active",
"revoked": "Révoquée",
"revoke": "Révoquer",
"delete": "Supprimer",
"createdAt": "Créée le",
"lastUsed": "Dernière utilisation",
"never": "Jamais",
"confirmRevoke": "Êtes-vous sûr de vouloir révoquer cette clé ? Les outils qui l'utilisent perdront leur accès.",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer définitivement cette clé ?"
},
"createDialog": {
"title": "Générer une clé API",
"description": "Créez une nouvelle clé API pour connecter des outils externes à vos notes.",
"nameLabel": "Nom de la clé",
"namePlaceholder": "ex. Claude Code, Cursor, N8N",
"generating": "Génération...",
"generate": "Générer",
"successTitle": "Clé API générée",
"successDescription": "Copiez votre clé API maintenant. Vous ne pourrez plus la voir ensuite.",
"copy": "Copier",
"copied": "Copiée !",
"done": "Terminé"
},
"configInstructions": {
"title": "Instructions de configuration",
"description": "Utilisez votre clé API pour configurer ces outils.",
"claudeCode": {
"title": "Claude Code",
"description": "Ajoutez ceci à votre fichier de configuration MCP de Claude Code :"
},
"cursor": {
"title": "Cursor",
"description": "Ajoutez ceci à vos paramètres MCP de Cursor :"
},
"n8n": {
"title": "N8N",
"description": "Utilisez ces identifiants dans votre nœud MCP N8N :"
}
}
} }
} }

View File

@@ -997,5 +997,65 @@
"collapse": "संकुचित करें", "collapse": "संकुचित करें",
"expand": "विस्तार करें", "expand": "विस्तार करें",
"open": "खोलें" "open": "खोलें"
},
"mcpSettings": {
"title": "MCP सेटिंग्स",
"description": "API कुंजियाँ प्रबंधित करें और बाहरी टूल कॉन्फ़िगर करें",
"whatIsMcp": {
"title": "MCP क्या है?",
"description": "मॉडल कॉन्टेक्स्ट प्रोटोकॉल (MCP) एक खुला प्रोटोकॉल है जो AI मॉडल को बाहरी टूल और डेटा स्रोतों के साथ सुरक्षित रूप से इंटरैक्ट करने में सक्षम बनाता है। MCP के साथ, आप Claude Code, Cursor या N8N जैसे टूल को अपने Keep Notes इंस्टेंस से कनेक्ट करके प्रोग्रामेटिक रूप से अपने नोट्स को पढ़ सकते हैं, बना सकते हैं और व्यवस्थित कर सकते हैं।",
"learnMore": "MCP के बारे में और जानें"
},
"serverStatus": {
"title": "सर्वर स्थिति",
"running": "चल रहा है",
"stopped": "रुका हुआ",
"mode": "मोड",
"url": "URL"
},
"apiKeys": {
"title": "API कुंजियाँ",
"description": "API कुंजियाँ बाहरी टूल को MCP के माध्यम से आपके नोट्स तक पहुँचने की अनुमति देती हैं। अपनी कुंजियों को गोपनीय रखें।",
"generate": "नई कुंजी जनरेट करें",
"empty": "अभी तक कोई API कुंजी नहीं। शुरू करने के लिए एक जनरेट करें।",
"active": "सक्रिय",
"revoked": "निरस्त",
"revoke": "निरस्त करें",
"delete": "हटाएँ",
"createdAt": "बनाई गई",
"lastUsed": "अंतिम उपयोग",
"never": "कभी नहीं",
"confirmRevoke": "क्या आप वाकई इस कुंजी को निरस्त करना चाहते हैं? इसका उपयोग करने वाले टूल की पहुँच हट जाएगी।",
"confirmDelete": "क्या आप वाकई इस कुंजी को स्थायी रूप से हटाना चाहते हैं?"
},
"createDialog": {
"title": "API कुंजी जनरेट करें",
"description": "बाहरी टूल को अपने नोट्स से कनेक्ट करने के लिए एक नई API कुंजी बनाएँ।",
"nameLabel": "कुंजी का नाम",
"namePlaceholder": "उदा. Claude Code, Cursor, N8N",
"generating": "जनरेट हो रहा है...",
"generate": "जनरेट करें",
"successTitle": "API कुंजी जनरेट हो गई",
"successDescription": "अपनी API कुंजी अभी कॉपी करें। आप इसे दोबारा नहीं देख पाएँगे।",
"copy": "कॉपी करें",
"copied": "कॉपी हो गई!",
"done": "हो गया"
},
"configInstructions": {
"title": "कॉन्फ़िगरेशन निर्देश",
"description": "इन टूल को कॉन्फ़िगर करने के लिए अपनी API कुंजी का उपयोग करें।",
"claudeCode": {
"title": "Claude Code",
"description": "इसे अपनी Claude Code MCP कॉन्फ़िगरेशन फ़ाइल में जोड़ें:"
},
"cursor": {
"title": "Cursor",
"description": "इसे अपनी Cursor MCP सेटिंग्स में जोड़ें:"
},
"n8n": {
"title": "N8N",
"description": "अपने N8N MCP नोड में इन क्रेडेंशियल का उपयोग करें:"
}
}
} }
} }

View File

@@ -1041,5 +1041,65 @@
"collapse": "Comprimi", "collapse": "Comprimi",
"expand": "Espandi", "expand": "Espandi",
"open": "Apri" "open": "Apri"
},
"mcpSettings": {
"title": "Impostazioni MCP",
"description": "Gestisci le chiavi API e configura gli strumenti esterni",
"whatIsMcp": {
"title": "Cos'è MCP?",
"description": "Il Model Context Protocol (MCP) è un protocollo aperto che consente ai modelli di IA di interagire in modo sicuro con strumenti e fonti di dati esterni. Con MCP puoi collegare strumenti come Claude Code, Cursor o N8N alla tua istanza Keep Notes per leggere, creare e organizzare le tue note a livello di programmazione.",
"learnMore": "Scopri di più su MCP"
},
"serverStatus": {
"title": "Stato del server",
"running": "In esecuzione",
"stopped": "Arrestato",
"mode": "Modalità",
"url": "URL"
},
"apiKeys": {
"title": "Chiavi API",
"description": "Le chiavi API consentono agli strumenti esterni di accedere alle tue note tramite MCP. Mantieni le tue chiavi segrete.",
"generate": "Genera una nuova chiave",
"empty": "Nessuna chiave API. Generane una per iniziare.",
"active": "Attiva",
"revoked": "Revocata",
"revoke": "Revoca",
"delete": "Elimina",
"createdAt": "Creata",
"lastUsed": "Ultimo utilizzo",
"never": "Mai",
"confirmRevoke": "Sei sicuro di voler revocare questa chiave? Gli strumenti che la utilizzano perderanno l'accesso.",
"confirmDelete": "Sei sicuro di voler eliminare definitivamente questa chiave?"
},
"createDialog": {
"title": "Genera chiave API",
"description": "Crea una nuova chiave API per collegare strumenti esterni alle tue note.",
"nameLabel": "Nome della chiave",
"namePlaceholder": "es. Claude Code, Cursor, N8N",
"generating": "Generazione...",
"generate": "Genera",
"successTitle": "Chiave API generata",
"successDescription": "Copia la tua chiave API ora. Non potrai vederla di nuovo.",
"copy": "Copia",
"copied": "Copiata!",
"done": "Fatto"
},
"configInstructions": {
"title": "Istruzioni di configurazione",
"description": "Usa la tua chiave API per configurare questi strumenti.",
"claudeCode": {
"title": "Claude Code",
"description": "Aggiungi questo al tuo file di configurazione MCP di Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Aggiungi questo alle tue impostazioni MCP di Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Usa queste credenziali nel tuo nodo MCP N8N:"
}
}
} }
} }

View File

@@ -997,5 +997,65 @@
"collapse": "折りたたむ", "collapse": "折りたたむ",
"expand": "展開", "expand": "展開",
"open": "開く" "open": "開く"
},
"mcpSettings": {
"title": "MCP設定",
"description": "APIキーの管理と外部ツールの設定",
"whatIsMcp": {
"title": "MCPとは",
"description": "Model Context ProtocolMCPは、AIモデルが外部ツールやデータソースと安全にやり取りできるようにするオープンプロトコルです。MCPを使用すると、Claude Code、Cursor、N8NなどのツールをKeep Notesインスタンスに接続し、プログラムでートの読み取り、作成、整理を行うことができます。",
"learnMore": "MCPについて詳しく知る"
},
"serverStatus": {
"title": "サーバーステータス",
"running": "実行中",
"stopped": "停止",
"mode": "モード",
"url": "URL"
},
"apiKeys": {
"title": "APIキー",
"description": "APIキーにより、外部ツールがMCP経由でートにアクセスできます。キーは秘密にしてください。",
"generate": "新しいキーを生成",
"empty": "APIキーはまだありません。生成して始めましょう。",
"active": "有効",
"revoked": "無効化",
"revoke": "無効化",
"delete": "削除",
"createdAt": "作成日",
"lastUsed": "最終使用",
"never": "未使用",
"confirmRevoke": "このキーを無効化してよろしいですか?使用中のツールがアクセスを失います。",
"confirmDelete": "このキーを永久に削除してよろしいですか?"
},
"createDialog": {
"title": "APIキーを生成",
"description": "外部ツールをートに接続するための新しいAPIキーを作成します。",
"nameLabel": "キー名",
"namePlaceholder": "例Claude Code、Cursor、N8N",
"generating": "生成中...",
"generate": "生成",
"successTitle": "APIキーが生成されました",
"successDescription": "今すぐAPIキーをコピーしてください。後で再度表示することはできません。",
"copy": "コピー",
"copied": "コピーしました!",
"done": "完了"
},
"configInstructions": {
"title": "設定手順",
"description": "APIキーを使用してこれらのツールを設定してください。",
"claudeCode": {
"title": "Claude Code",
"description": "Claude CodeのMCP設定ファイルに以下を追加してください"
},
"cursor": {
"title": "Cursor",
"description": "CursorのMCP設定に以下を追加してください"
},
"n8n": {
"title": "N8N",
"description": "N8N MCPードで以下の認証情報を使用してください"
}
}
} }
} }

View File

@@ -997,5 +997,65 @@
"collapse": "접기", "collapse": "접기",
"expand": "펼치기", "expand": "펼치기",
"open": "열기" "open": "열기"
},
"mcpSettings": {
"title": "MCP 설정",
"description": "API 키 관리 및 외부 도구 구성",
"whatIsMcp": {
"title": "MCP란 무엇인가요?",
"description": "Model Context Protocol(MCP)은 AI 모델이 외부 도구 및 데이터 소스와 안전하게 상호 작용할 수 있게 하는 오픈 프로토콜입니다. MCP를 사용하면 Claude Code, Cursor, N8N 등의 도구를 Keep Notes 인스턴스에 연결하여 프로그래밍 방식으로 노트를 읽고, 만들고, 정리할 수 있습니다.",
"learnMore": "MCP에 대해 자세히 알아보기"
},
"serverStatus": {
"title": "서버 상태",
"running": "실행 중",
"stopped": "중지됨",
"mode": "모드",
"url": "URL"
},
"apiKeys": {
"title": "API 키",
"description": "API 키를 통해 외부 도구가 MCP를 통해 노트에 접근할 수 있습니다. 키를 비밀로 유지하세요.",
"generate": "새 키 생성",
"empty": "API 키가 없습니다. 하나를 생성하여 시작하세요.",
"active": "활성",
"revoked": "취소됨",
"revoke": "취소",
"delete": "삭제",
"createdAt": "생성일",
"lastUsed": "마지막 사용",
"never": "없음",
"confirmRevoke": "이 키를 취소하시겠습니까? 이 키를 사용하는 도구의 접근이 차단됩니다.",
"confirmDelete": "이 키를 영구적으로 삭제하시겠습니까?"
},
"createDialog": {
"title": "API 키 생성",
"description": "외부 도구를 노트에 연결할 새 API 키를 만듭니다.",
"nameLabel": "키 이름",
"namePlaceholder": "예: Claude Code, Cursor, N8N",
"generating": "생성 중...",
"generate": "생성",
"successTitle": "API 키가 생성되었습니다",
"successDescription": "지금 API 키를 복사하세요. 나중에 다시 볼 수 없습니다.",
"copy": "복사",
"copied": "복사됨!",
"done": "완료"
},
"configInstructions": {
"title": "구성 안내",
"description": "API 키를 사용하여 이 도구들을 구성하세요.",
"claudeCode": {
"title": "Claude Code",
"description": "Claude Code MCP 구성 파일에 다음을 추가하세요:"
},
"cursor": {
"title": "Cursor",
"description": "Cursor MCP 설정에 다음을 추가하세요:"
},
"n8n": {
"title": "N8N",
"description": "N8N MCP 노드에서 다음 자격 증명을 사용하세요:"
}
}
} }
} }

View File

@@ -1041,5 +1041,65 @@
"collapse": "Inklappen", "collapse": "Inklappen",
"expand": "Uitvouwen", "expand": "Uitvouwen",
"open": "Openen" "open": "Openen"
},
"mcpSettings": {
"title": "MCP-instellingen",
"description": "Beheer uw API-sleutels en configureer externe tools",
"whatIsMcp": {
"title": "Wat is MCP?",
"description": "Het Model Context Protocol (MCP) is een open protocol waarmee AI-modellen veilig kunnen communiceren met externe tools en gegevensbronnen. Met MCP kunt u tools zoals Claude Code, Cursor of N8N koppelen aan uw Keep Notes-instantie om uw notities programmatisch te lezen, maken en organiseren.",
"learnMore": "Meer informatie over MCP"
},
"serverStatus": {
"title": "Serverstatus",
"running": "Actief",
"stopped": "Gestopt",
"mode": "Modus",
"url": "URL"
},
"apiKeys": {
"title": "API-sleutels",
"description": "API-sleutels geven externe tools toegang tot uw notities via MCP. Houd uw sleutels geheim.",
"generate": "Nieuwe sleutel genereren",
"empty": "Nog geen API-sleutels. Genereer er een om te beginnen.",
"active": "Actief",
"revoked": "Ingetrokken",
"revoke": "Intrekken",
"delete": "Verwijderen",
"createdAt": "Aangemaakt",
"lastUsed": "Laatst gebruikt",
"never": "Nooit",
"confirmRevoke": "Weet u zeker dat u deze sleutel wilt intrekken? Tools die deze gebruiken, verliezen toegang.",
"confirmDelete": "Weet u zeker dat u deze sleutel permanent wilt verwijderen?"
},
"createDialog": {
"title": "API-sleutel genereren",
"description": "Maak een nieuwe API-sleutel aan om externe tools met uw notities te verbinden.",
"nameLabel": "Sleutelnaam",
"namePlaceholder": "bijv. Claude Code, Cursor, N8N",
"generating": "Bezig met genereren...",
"generate": "Genereren",
"successTitle": "API-sleutel gegenereerd",
"successDescription": "Kopieer uw API-sleutel nu. U kunt deze later niet meer bekijken.",
"copy": "Kopiëren",
"copied": "Gekopieerd!",
"done": "Klaar"
},
"configInstructions": {
"title": "Configuratie-instructies",
"description": "Gebruik uw API-sleutel om deze tools te configureren.",
"claudeCode": {
"title": "Claude Code",
"description": "Voeg dit toe aan uw Claude Code MCP-configuratiebestand:"
},
"cursor": {
"title": "Cursor",
"description": "Voeg dit toe aan uw Cursor MCP-instellingen:"
},
"n8n": {
"title": "N8N",
"description": "Gebruik deze referenties in uw N8N MCP-node:"
}
}
} }
} }

View File

@@ -1063,5 +1063,65 @@
"collapse": "Zwiń", "collapse": "Zwiń",
"expand": "Rozwiń", "expand": "Rozwiń",
"open": "Otwórz" "open": "Otwórz"
},
"mcpSettings": {
"title": "Ustawienia MCP",
"description": "Zarządzaj kluczami API i konfiguruj narzędzia zewnętrzne",
"whatIsMcp": {
"title": "Czym jest MCP?",
"description": "Model Context Protocol (MCP) to otwarty protokół umożliwiający modelom AI bezpieczną interakcję z zewnętrznymi narzędziami i źródłami danych. Dzięki MCP możesz połączyć narzędzia takie jak Claude Code, Cursor czy N8N ze swoją instancją Keep Notes, aby programowo czytać, tworzyć i organizować notatki.",
"learnMore": "Dowiedz się więcej o MCP"
},
"serverStatus": {
"title": "Status serwera",
"running": "Uruchomiony",
"stopped": "Zatrzymany",
"mode": "Tryb",
"url": "URL"
},
"apiKeys": {
"title": "Klucze API",
"description": "Klucze API pozwalają narzędziom zewnętrznym uzyskiwać dostęp do Twoich notatek przez MCP. Zachowaj klucze w tajemnicy.",
"generate": "Wygeneruj nowy klucz",
"empty": "Brak kluczy API. Wygeneruj jeden, aby zacząć.",
"active": "Aktywny",
"revoked": "Unieważniony",
"revoke": "Unieważnij",
"delete": "Usuń",
"createdAt": "Utworzono",
"lastUsed": "Ostatnio użyty",
"never": "Nigdy",
"confirmRevoke": "Czy na pewno chcesz unieważnić ten klucz? Narzędzia korzystające z niego stracą dostęp.",
"confirmDelete": "Czy na pewno chcesz trwale usunąć ten klucz?"
},
"createDialog": {
"title": "Wygeneruj klucz API",
"description": "Utwórz nowy klucz API, aby połączyć narzędzia zewnętrzne ze swoimi notatkami.",
"nameLabel": "Nazwa klucza",
"namePlaceholder": "np. Claude Code, Cursor, N8N",
"generating": "Generowanie...",
"generate": "Wygeneruj",
"successTitle": "Klucz API wygenerowany",
"successDescription": "Skopiuj swój klucz API teraz. Nie będziesz mógł go ponownie zobaczyć.",
"copy": "Kopiuj",
"copied": "Skopiowano!",
"done": "Gotowe"
},
"configInstructions": {
"title": "Instrukcje konfiguracji",
"description": "Użyj swojego klucza API do konfiguracji tych narzędzi.",
"claudeCode": {
"title": "Claude Code",
"description": "Dodaj to do pliku konfiguracyjnego MCP Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Dodaj to do ustawień MCP Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Użyj tych danych logowania w węźle N8N MCP:"
}
}
} }
} }

View File

@@ -991,5 +991,65 @@
"collapse": "Recolher", "collapse": "Recolher",
"expand": "Expandir", "expand": "Expandir",
"open": "Abrir" "open": "Abrir"
},
"mcpSettings": {
"title": "Configurações MCP",
"description": "Gerencie suas chaves API e configure ferramentas externas",
"whatIsMcp": {
"title": "O que é MCP?",
"description": "O Model Context Protocol (MCP) é um protocolo aberto que permite que modelos de IA interajam de forma segura com ferramentas e fontes de dados externas. Com o MCP, você pode conectar ferramentas como Claude Code, Cursor ou N8N à sua instância do Keep Notes para ler, criar e organizar suas notas programaticamente.",
"learnMore": "Saiba mais sobre o MCP"
},
"serverStatus": {
"title": "Status do servidor",
"running": "Em execução",
"stopped": "Parado",
"mode": "Modo",
"url": "URL"
},
"apiKeys": {
"title": "Chaves API",
"description": "As chaves API permitem que ferramentas externas acessem suas notas via MCP. Mantenha suas chaves em segredo.",
"generate": "Gerar nova chave",
"empty": "Nenhuma chave API ainda. Gere uma para começar.",
"active": "Ativa",
"revoked": "Revogada",
"revoke": "Revogar",
"delete": "Excluir",
"createdAt": "Criada",
"lastUsed": "Último uso",
"never": "Nunca",
"confirmRevoke": "Tem certeza de que deseja revogar esta chave? As ferramentas que a usam perderão o acesso.",
"confirmDelete": "Tem certeza de que deseja excluir permanentemente esta chave?"
},
"createDialog": {
"title": "Gerar chave API",
"description": "Crie uma nova chave API para conectar ferramentas externas às suas notas.",
"nameLabel": "Nome da chave",
"namePlaceholder": "ex. Claude Code, Cursor, N8N",
"generating": "Gerando...",
"generate": "Gerar",
"successTitle": "Chave API gerada",
"successDescription": "Copie sua chave API agora. Você não poderá vê-la novamente.",
"copy": "Copiar",
"copied": "Copiada!",
"done": "Concluído"
},
"configInstructions": {
"title": "Instruções de configuração",
"description": "Use sua chave API para configurar estas ferramentas.",
"claudeCode": {
"title": "Claude Code",
"description": "Adicione isto ao seu arquivo de configuração MCP do Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Adicione isto às suas configurações MCP do Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Use estas credenciais no seu nó MCP do N8N:"
}
}
} }
} }

View File

@@ -991,5 +991,65 @@
"collapse": "Свернуть", "collapse": "Свернуть",
"expand": "Развернуть", "expand": "Развернуть",
"open": "Открыть" "open": "Открыть"
},
"mcpSettings": {
"title": "Настройки MCP",
"description": "Управление ключами API и настройка внешних инструментов",
"whatIsMcp": {
"title": "Что такое MCP?",
"description": "Model Context Protocol (MCP) — это открытый протокол, позволяющий моделям ИИ безопасно взаимодействовать с внешними инструментами и источниками данных. С помощью MCP вы можете подключить такие инструменты, как Claude Code, Cursor или N8N, к вашему экземпляру Keep Notes для программного чтения, создания и организации заметок.",
"learnMore": "Подробнее о MCP"
},
"serverStatus": {
"title": "Состояние сервера",
"running": "Запущен",
"stopped": "Остановлен",
"mode": "Режим",
"url": "URL"
},
"apiKeys": {
"title": "Ключи API",
"description": "Ключи API позволяют внешним инструментам получать доступ к вашим заметкам через MCP. Храните ключи в секрете.",
"generate": "Создать новый ключ",
"empty": "Нет ключей API. Создайте один, чтобы начать.",
"active": "Активен",
"revoked": "Отозван",
"revoke": "Отозвать",
"delete": "Удалить",
"createdAt": "Создан",
"lastUsed": "Последнее использование",
"never": "Никогда",
"confirmRevoke": "Вы уверены, что хотите отозвать этот ключ? Инструменты, использующие его, потеряют доступ.",
"confirmDelete": "Вы уверены, что хотите навсегда удалить этот ключ?"
},
"createDialog": {
"title": "Создать ключ API",
"description": "Создайте новый ключ API для подключения внешних инструментов к вашим заметкам.",
"nameLabel": "Имя ключа",
"namePlaceholder": "напр. Claude Code, Cursor, N8N",
"generating": "Генерация...",
"generate": "Создать",
"successTitle": "Ключ API создан",
"successDescription": "Скопируйте ключ API сейчас. Вы не сможете увидеть его снова.",
"copy": "Копировать",
"copied": "Скопировано!",
"done": "Готово"
},
"configInstructions": {
"title": "Инструкции по настройке",
"description": "Используйте свой ключ API для настройки этих инструментов.",
"claudeCode": {
"title": "Claude Code",
"description": "Добавьте это в файл конфигурации MCP Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Добавьте это в настройки MCP Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Используйте эти учётные данные в узле N8N MCP:"
}
}
} }
} }

View File

@@ -997,5 +997,65 @@
"collapse": "收起", "collapse": "收起",
"expand": "展开", "expand": "展开",
"open": "打开" "open": "打开"
},
"mcpSettings": {
"title": "MCP 设置",
"description": "管理 API 密钥并配置外部工具",
"whatIsMcp": {
"title": "什么是 MCP",
"description": "模型上下文协议MCP是一个开放协议使 AI 模型能够与外部工具和数据源安全交互。通过 MCP您可以将 Claude Code、Cursor 或 N8N 等工具连接到您的 Keep Notes 实例,以编程方式读取、创建和整理笔记。",
"learnMore": "了解更多关于 MCP"
},
"serverStatus": {
"title": "服务器状态",
"running": "运行中",
"stopped": "已停止",
"mode": "模式",
"url": "URL"
},
"apiKeys": {
"title": "API 密钥",
"description": "API 密钥允许外部工具通过 MCP 访问您的笔记。请保管好您的密钥。",
"generate": "生成新密钥",
"empty": "暂无 API 密钥。生成一个以开始使用。",
"active": "有效",
"revoked": "已撤销",
"revoke": "撤销",
"delete": "删除",
"createdAt": "创建于",
"lastUsed": "上次使用",
"never": "从未使用",
"confirmRevoke": "确定要撤销此密钥吗?使用该密钥的工具将失去访问权限。",
"confirmDelete": "确定要永久删除此密钥吗?"
},
"createDialog": {
"title": "生成 API 密钥",
"description": "创建新的 API 密钥以将外部工具连接到您的笔记。",
"nameLabel": "密钥名称",
"namePlaceholder": "例如Claude Code、Cursor、N8N",
"generating": "生成中...",
"generate": "生成",
"successTitle": "API 密钥已生成",
"successDescription": "请立即复制您的 API 密钥。之后将无法再次查看。",
"copy": "复制",
"copied": "已复制!",
"done": "完成"
},
"configInstructions": {
"title": "配置说明",
"description": "使用您的 API 密钥配置这些工具。",
"claudeCode": {
"title": "Claude Code",
"description": "将以下内容添加到您的 Claude Code MCP 配置文件中:"
},
"cursor": {
"title": "Cursor",
"description": "将以下内容添加到您的 Cursor MCP 设置中:"
},
"n8n": {
"title": "N8N",
"description": "在您的 N8N MCP 节点中使用以下凭据:"
}
}
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -275,6 +275,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'

View File

@@ -13554,6 +13554,7 @@ export namespace Prisma {
fontSize: string | null fontSize: string | null
demoMode: boolean | null demoMode: boolean | null
showRecentNotes: boolean | null showRecentNotes: boolean | null
notesViewMode: string | null
emailNotifications: boolean | null emailNotifications: boolean | null
desktopNotifications: boolean | null desktopNotifications: boolean | null
anonymousAnalytics: boolean | null anonymousAnalytics: boolean | null
@@ -13571,6 +13572,7 @@ export namespace Prisma {
fontSize: string | null fontSize: string | null
demoMode: boolean | null demoMode: boolean | null
showRecentNotes: boolean | null showRecentNotes: boolean | null
notesViewMode: string | null
emailNotifications: boolean | null emailNotifications: boolean | null
desktopNotifications: boolean | null desktopNotifications: boolean | null
anonymousAnalytics: boolean | null anonymousAnalytics: boolean | null
@@ -13588,6 +13590,7 @@ export namespace Prisma {
fontSize: number fontSize: number
demoMode: number demoMode: number
showRecentNotes: number showRecentNotes: number
notesViewMode: number
emailNotifications: number emailNotifications: number
desktopNotifications: number desktopNotifications: number
anonymousAnalytics: number anonymousAnalytics: number
@@ -13607,6 +13610,7 @@ export namespace Prisma {
fontSize?: true fontSize?: true
demoMode?: true demoMode?: true
showRecentNotes?: true showRecentNotes?: true
notesViewMode?: true
emailNotifications?: true emailNotifications?: true
desktopNotifications?: true desktopNotifications?: true
anonymousAnalytics?: true anonymousAnalytics?: true
@@ -13624,6 +13628,7 @@ export namespace Prisma {
fontSize?: true fontSize?: true
demoMode?: true demoMode?: true
showRecentNotes?: true showRecentNotes?: true
notesViewMode?: true
emailNotifications?: true emailNotifications?: true
desktopNotifications?: true desktopNotifications?: true
anonymousAnalytics?: true anonymousAnalytics?: true
@@ -13641,6 +13646,7 @@ export namespace Prisma {
fontSize?: true fontSize?: true
demoMode?: true demoMode?: true
showRecentNotes?: true showRecentNotes?: true
notesViewMode?: true
emailNotifications?: true emailNotifications?: true
desktopNotifications?: true desktopNotifications?: true
anonymousAnalytics?: true anonymousAnalytics?: true
@@ -13731,6 +13737,7 @@ export namespace Prisma {
fontSize: string fontSize: string
demoMode: boolean demoMode: boolean
showRecentNotes: boolean showRecentNotes: boolean
notesViewMode: string
emailNotifications: boolean emailNotifications: boolean
desktopNotifications: boolean desktopNotifications: boolean
anonymousAnalytics: boolean anonymousAnalytics: boolean
@@ -13765,6 +13772,7 @@ export namespace Prisma {
fontSize?: boolean fontSize?: boolean
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: boolean
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
@@ -13783,6 +13791,7 @@ export namespace Prisma {
fontSize?: boolean fontSize?: boolean
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: boolean
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
@@ -13801,6 +13810,7 @@ export namespace Prisma {
fontSize?: boolean fontSize?: boolean
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: boolean
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
@@ -13830,6 +13840,10 @@ export namespace Prisma {
fontSize: string fontSize: string
demoMode: boolean demoMode: boolean
showRecentNotes: boolean showRecentNotes: boolean
/**
* "masonry" = cartes Muuri ; "list" = liste classique
*/
notesViewMode: string
emailNotifications: boolean emailNotifications: boolean
desktopNotifications: boolean desktopNotifications: boolean
anonymousAnalytics: boolean anonymousAnalytics: boolean
@@ -14238,6 +14252,7 @@ export namespace Prisma {
readonly fontSize: FieldRef<"UserAISettings", 'String'> readonly fontSize: FieldRef<"UserAISettings", 'String'>
readonly demoMode: FieldRef<"UserAISettings", 'Boolean'> readonly demoMode: FieldRef<"UserAISettings", 'Boolean'>
readonly showRecentNotes: FieldRef<"UserAISettings", 'Boolean'> readonly showRecentNotes: FieldRef<"UserAISettings", 'Boolean'>
readonly notesViewMode: FieldRef<"UserAISettings", 'String'>
readonly emailNotifications: FieldRef<"UserAISettings", 'Boolean'> readonly emailNotifications: FieldRef<"UserAISettings", 'Boolean'>
readonly desktopNotifications: FieldRef<"UserAISettings", 'Boolean'> readonly desktopNotifications: FieldRef<"UserAISettings", 'Boolean'>
readonly anonymousAnalytics: FieldRef<"UserAISettings", 'Boolean'> readonly anonymousAnalytics: FieldRef<"UserAISettings", 'Boolean'>
@@ -14771,6 +14786,7 @@ export namespace Prisma {
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'
@@ -15817,6 +15833,7 @@ export namespace Prisma {
fontSize?: StringFilter<"UserAISettings"> | string fontSize?: StringFilter<"UserAISettings"> | string
demoMode?: BoolFilter<"UserAISettings"> | boolean demoMode?: BoolFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolFilter<"UserAISettings"> | boolean showRecentNotes?: BoolFilter<"UserAISettings"> | boolean
notesViewMode?: StringFilter<"UserAISettings"> | string
emailNotifications?: BoolFilter<"UserAISettings"> | boolean emailNotifications?: BoolFilter<"UserAISettings"> | boolean
desktopNotifications?: BoolFilter<"UserAISettings"> | boolean desktopNotifications?: BoolFilter<"UserAISettings"> | boolean
anonymousAnalytics?: BoolFilter<"UserAISettings"> | boolean anonymousAnalytics?: BoolFilter<"UserAISettings"> | boolean
@@ -15835,6 +15852,7 @@ export namespace Prisma {
fontSize?: SortOrder fontSize?: SortOrder
demoMode?: SortOrder demoMode?: SortOrder
showRecentNotes?: SortOrder showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder emailNotifications?: SortOrder
desktopNotifications?: SortOrder desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder anonymousAnalytics?: SortOrder
@@ -15856,6 +15874,7 @@ export namespace Prisma {
fontSize?: StringFilter<"UserAISettings"> | string fontSize?: StringFilter<"UserAISettings"> | string
demoMode?: BoolFilter<"UserAISettings"> | boolean demoMode?: BoolFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolFilter<"UserAISettings"> | boolean showRecentNotes?: BoolFilter<"UserAISettings"> | boolean
notesViewMode?: StringFilter<"UserAISettings"> | string
emailNotifications?: BoolFilter<"UserAISettings"> | boolean emailNotifications?: BoolFilter<"UserAISettings"> | boolean
desktopNotifications?: BoolFilter<"UserAISettings"> | boolean desktopNotifications?: BoolFilter<"UserAISettings"> | boolean
anonymousAnalytics?: BoolFilter<"UserAISettings"> | boolean anonymousAnalytics?: BoolFilter<"UserAISettings"> | boolean
@@ -15874,6 +15893,7 @@ export namespace Prisma {
fontSize?: SortOrder fontSize?: SortOrder
demoMode?: SortOrder demoMode?: SortOrder
showRecentNotes?: SortOrder showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder emailNotifications?: SortOrder
desktopNotifications?: SortOrder desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder anonymousAnalytics?: SortOrder
@@ -15897,6 +15917,7 @@ export namespace Prisma {
fontSize?: StringWithAggregatesFilter<"UserAISettings"> | string fontSize?: StringWithAggregatesFilter<"UserAISettings"> | string
demoMode?: BoolWithAggregatesFilter<"UserAISettings"> | boolean demoMode?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolWithAggregatesFilter<"UserAISettings"> | boolean showRecentNotes?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
notesViewMode?: StringWithAggregatesFilter<"UserAISettings"> | string
emailNotifications?: BoolWithAggregatesFilter<"UserAISettings"> | boolean emailNotifications?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
desktopNotifications?: BoolWithAggregatesFilter<"UserAISettings"> | boolean desktopNotifications?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
anonymousAnalytics?: BoolWithAggregatesFilter<"UserAISettings"> | boolean anonymousAnalytics?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
@@ -16978,6 +16999,7 @@ export namespace Prisma {
fontSize?: string fontSize?: string
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
@@ -16996,6 +17018,7 @@ export namespace Prisma {
fontSize?: string fontSize?: string
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
@@ -17012,6 +17035,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -17030,6 +17054,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -17047,6 +17072,7 @@ export namespace Prisma {
fontSize?: string fontSize?: string
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
@@ -17063,6 +17089,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -17080,6 +17107,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -17946,6 +17974,7 @@ export namespace Prisma {
fontSize?: SortOrder fontSize?: SortOrder
demoMode?: SortOrder demoMode?: SortOrder
showRecentNotes?: SortOrder showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder emailNotifications?: SortOrder
desktopNotifications?: SortOrder desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder anonymousAnalytics?: SortOrder
@@ -17963,6 +17992,7 @@ export namespace Prisma {
fontSize?: SortOrder fontSize?: SortOrder
demoMode?: SortOrder demoMode?: SortOrder
showRecentNotes?: SortOrder showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder emailNotifications?: SortOrder
desktopNotifications?: SortOrder desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder anonymousAnalytics?: SortOrder
@@ -17980,6 +18010,7 @@ export namespace Prisma {
fontSize?: SortOrder fontSize?: SortOrder
demoMode?: SortOrder demoMode?: SortOrder
showRecentNotes?: SortOrder showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder emailNotifications?: SortOrder
desktopNotifications?: SortOrder desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder anonymousAnalytics?: SortOrder
@@ -19613,6 +19644,7 @@ export namespace Prisma {
fontSize?: string fontSize?: string
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
@@ -19629,6 +19661,7 @@ export namespace Prisma {
fontSize?: string fontSize?: string
demoMode?: boolean demoMode?: boolean
showRecentNotes?: boolean showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean emailNotifications?: boolean
desktopNotifications?: boolean desktopNotifications?: boolean
anonymousAnalytics?: boolean anonymousAnalytics?: boolean
@@ -19947,6 +19980,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -19963,6 +19997,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{ {
"name": "prisma-client-c6853a2d560fc459913c7f241f4427cd8f8957f5474f01e1a2cabb8c1f55d4d8", "name": "prisma-client-2331c58c0b3910e5ab8251136c7336826e2ba4756d13e7c07da246b228c31c54",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",

View File

@@ -229,6 +229,8 @@ model UserAISettings {
fontSize String @default("medium") fontSize String @default("medium")
demoMode Boolean @default(false) demoMode Boolean @default(false)
showRecentNotes Boolean @default(true) showRecentNotes Boolean @default(true)
/// "masonry" = cartes Muuri ; "list" = liste classique
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)

View File

@@ -275,6 +275,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'

Binary file not shown.

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserAISettings" ADD COLUMN "notesViewMode" TEXT NOT NULL DEFAULT 'masonry';

View File

@@ -229,6 +229,8 @@ model UserAISettings {
fontSize String @default("medium") fontSize String @default("medium")
demoMode Boolean @default(false) demoMode Boolean @default(false)
showRecentNotes Boolean @default(true) showRecentNotes Boolean @default(true)
/// "masonry" = grille cartes Muuri ; "tabs" = onglets + panneau (type OneNote). Ancienne valeur "list" migrée vers "tabs" en lecture.
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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB