## Translation Files - Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ missing translation keys across all 15 languages - New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels - Update nav section with workspace, quickAccess, myLibrary keys ## Component Updates - Update 15+ components to use translation keys instead of hardcoded text - Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc. - Replace 80+ hardcoded English/French strings with t() calls - Ensure consistent UI across all supported languages ## Code Quality - Remove 77+ console.log statements from codebase - Clean up API routes, components, hooks, and services - Keep only essential error handling (no debugging logs) ## UI/UX Improvements - Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500) - Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items) - Make "+" button permanently visible in notebooks section - Fix grammar and syntax errors in multiple components ## Bug Fixes - Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json - Fix syntax errors in notebook-suggestion-toast.tsx - Fix syntax errors in use-auto-tagging.ts - Fix syntax errors in paragraph-refactor.service.ts - Fix duplicate "fusion" section in nl.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Ou une version plus courte si vous préférez : feat(i18n): Add 15 languages, remove logs, update UI components - Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ translation keys: notebook, pagination, AI features - Update 15+ components to use translations (80+ strings) - Remove 77+ console.log statements from codebase - Fix JSON syntax errors in 4 translation files - Fix component syntax errors (toast, hooks, services) - Update logo to yellow post-it style - Change selection colors (#FEF3C6, #EFB162) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1697 lines
61 KiB
Markdown
1697 lines
61 KiB
Markdown
---
|
|
stepsCompleted: [1, 2, 3, 4]
|
|
inputDocuments:
|
|
- notebooks-contextual-labels-prd.md
|
|
- notebooks-wireframes.md
|
|
- project-context.md
|
|
workflowType: 'architecture'
|
|
project_name: 'Keep - Notebooks & Labels Contextuels'
|
|
user_name: 'Ramez'
|
|
date: '2026-01-11'
|
|
communication_language: 'French'
|
|
document_output_language: 'English'
|
|
focusArea: 'Notebooks with Contextual Labels Feature'
|
|
---
|
|
|
|
# Architecture Decision Document - Notebooks & Labels Contextuels
|
|
|
|
_This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together._
|
|
|
|
---
|
|
|
|
## Project Context Analysis
|
|
|
|
### Requirements Overview
|
|
|
|
**Functional Requirements:**
|
|
|
|
Le projet **Notebooks & Labels Contextuels** est une refonte architecturale majeure du système d'organisation de Keep, transformant un modèle de tags plat en une structure hiérarchique contextuelle avec intelligence artificielle intégrée.
|
|
|
|
**1. Structure en Notebooks**
|
|
- Notebooks comme organisation principale (remplacement/évolution des tags globaux)
|
|
- Notes générales (Inbox) pour notes non organisées
|
|
- Une note appartient à UN seul notebook (ou aucune)
|
|
- Labels 100% contextuels à chaque notebook (isolement total)
|
|
|
|
**2. UX Interactions Complexes**
|
|
- Drag & drop à deux niveaux : réorganiser les notebooks + déplacer des notes
|
|
- Menu contextuel alternatif pour le déplacement
|
|
- Création contextuelle de notebooks et labels
|
|
- Navigation fluide entre Inbox et Notebooks
|
|
|
|
**3. Intégration IA (6 Features)**
|
|
- **IA1:** Suggestion automatique de notebook (quand note créée dans Inbox)
|
|
- **IA2:** Suggestion de labels contextuels (filtrés par notebook actif)
|
|
- **IA3:** Organisation batch intelligente (Inbox → Notebooks)
|
|
- **IA4:** Création automatique de labels (détection thèmes récurrents)
|
|
- **IA5:** Recherche sémantique contextuelle (limitée au notebook)
|
|
- **IA6:** Synthèse automatique par notebook
|
|
|
|
**4. Migration Brownfield**
|
|
- Migration douce depuis le système de tags plat existant
|
|
- Notebook par défaut pour préserver les labels existants
|
|
- ZÉRO breaking changes - le système actuel continue de fonctionner
|
|
|
|
**Non-Functional Requirements:**
|
|
|
|
Les NFRs critiques qui façonneront l'architecture :
|
|
|
|
**Performance:**
|
|
- Drag & drop en temps réel (< 100ms latence perçue)
|
|
- Recherche sémantique < 300ms pour 1000 notes (existant)
|
|
- Suggestions IA < 2s (existant)
|
|
- Chargement de sidebar instantané (< 200ms)
|
|
|
|
**Sécurité & Intégrité:**
|
|
- Labels isolés par notebook (pas de fuite de données entre notebooks)
|
|
- Cascade delete maîtrisé : notebook supprimé → notes → Inbox (pas de perte de données)
|
|
- Contrainte d'unicité : `@@unique([notebookId, name])` pour les labels
|
|
|
|
**Brownfield Compatibility:**
|
|
- **ZERO breaking changes** - le système de tags actuel continue de fonctionner
|
|
- `notebookId` optionnel sur Note (null = Inbox)
|
|
- `notebookId` obligatoire sur Label (migration avec valeur par défaut 'TEMP_MIGRATION')
|
|
- Rollback possible si nécessaire
|
|
|
|
**Scalability:**
|
|
- Supporte jusqu'à 100+ notebooks par utilisateur
|
|
- Supporte jusqu'à 50+ labels par notebook
|
|
- Performance dégradée gracieusement avec 1000+ notes
|
|
|
|
### Scale & Complexity
|
|
|
|
**Project Complexity: MEDIUM-HIGH**
|
|
|
|
**Complexity Indicators:**
|
|
- ✅ Real-time features (drag & drop synchronisé à deux niveaux)
|
|
- ✅ IA multi-facettes (6 features distinctes réutilisant l'infrastructure existante)
|
|
- ✅ Migration de données brownfield (système de tags vers notebooks)
|
|
- ❌ PAS de multi-tenancy (single-user only - simplifie l'architecture)
|
|
- ❌ PAS de compliance réglementaire spécifique (GDPR basique uniquement)
|
|
- ✅ Intégration backend complexe (Prisma + IA)
|
|
|
|
**Primary Technical Domain:** Full-stack Web with AI
|
|
|
|
- **Frontend:** React/Next.js avec interactions riches (sidebar, drag & drop, modals)
|
|
- **Backend:** Server Actions + API Routes (existants à étendre)
|
|
- **Database:** Prisma ORM + SQLite (existants)
|
|
- **AI:** OpenAI/Ollama (infrastructure existante à adapter pour être contextuelle)
|
|
|
|
**Estimated Architectural Components:** ~8-10 composants majeurs
|
|
|
|
1. Notebook Management (CRUD + ordre manuel)
|
|
2. Label Management (contextuel par notebook)
|
|
3. Drag & Drop System (deux niveaux)
|
|
4. IA Suggestion Engine (adaptation existante)
|
|
5. Migration Service (tags → notebooks)
|
|
6. Search Contextualization (limitation par notebook)
|
|
7. State Management (sidebar + grid + optimistic UI)
|
|
8. Inbox/General Notes (zone temporaire)
|
|
|
|
### Technical Constraints & Dependencies
|
|
|
|
**Contraintes Existantes:**
|
|
- **Next.js 16.1.1** avec App Router (doit être compatible)
|
|
- **Prisma 5.22.0** + SQLite (doit étendre le schema existant)
|
|
- **React 19.2.3** strict mode (doit respecter les patterns existants)
|
|
- **Muuri** pour Masonry grid (doit cohabiter avec le nouveau drag & drop)
|
|
- **Server Actions** + **revalidatePath** (pattern existant à respecter)
|
|
|
|
**Dépendances IA:**
|
|
- **Vercel AI SDK 6.0.23** (déjà en place)
|
|
- **Auto-tagging system** (existant - à adapter pour être contextuel)
|
|
- **Semantic search** (existant - à limiter par notebook)
|
|
- **Language detection** (existant - à réutiliser)
|
|
|
|
**Contraintes UX:**
|
|
- **Responsive design** (desktop priorité, mobile secondaire)
|
|
- **Accessibilité WCAG AA** (existant)
|
|
- **Dark mode** (existant)
|
|
- **Performance targets** (existant - à maintenir)
|
|
|
|
### Cross-Cutting Concerns Identified
|
|
|
|
**1. State Management & Synchronization**
|
|
- **Challenge:** État global distribué (sidebar + grid + modals + IA)
|
|
- **Impact:** Nécessite une architecture d'état cohérente pour éviter les incohérences
|
|
- **Patterns:** React Context + useOptimistic + Server Actions (existants)
|
|
|
|
**2. Drag & Drop à Deux Niveaux**
|
|
- **Challenge:** Unifier le drag & drop Muuri (existant) avec le drag & drop notebooks (nouveau)
|
|
- **Impact:** UX cohérente pour l'utilisateur, code maintenable
|
|
- **Patterns:** @dnd-kit ou Muuri natif (à décider)
|
|
|
|
**3. IA Contextuelle**
|
|
- **Challenge:** Adapter l'auto-tagging existant pour filtrer par notebook
|
|
- **Impact:** Réutilisation maximale du code existant, pas de duplication
|
|
- **Patterns:** Services IA avec paramètre `notebookId`
|
|
|
|
**4. Migration de Données**
|
|
- **Challenge:** Migrer les tags globaux vers les notebooks sans perte
|
|
- **Impact:** User experience douce, pas de données perdues
|
|
- **Patterns:** Migration script + notebook par défaut + IA-assistée
|
|
|
|
**5. Performance de la Recherche**
|
|
- **Challenge:** Recherche sémantique limitée au notebook actif
|
|
- **Impact:** Résultats pertinents, performance acceptable
|
|
- **Patterns:** Filtrage au niveau Prisma query (pas post-traitement)
|
|
|
|
**6. Undo/Redo pour Actions Déstructives**
|
|
- **Challenge:** Permettre d'annuler les déplacements de notes, suppressions de notebooks
|
|
- **Impact:** User confiance, UX sans peur
|
|
- **Patterns:** Historique des actions ou Server Actions avec rollback
|
|
|
|
---
|
|
|
|
## Step 3: Library & Technology Evaluation (Brownfield Adaptation)
|
|
|
|
### Brownfield Context
|
|
Ce projet N'est PAS greenfield - Keep existe déjà avec une stack mature et des drag & drop fonctionnels. Cette section évalue les technologies existantes et identifie ce qui doit être ajouté.
|
|
|
|
### Existing Technology Stack
|
|
|
|
**✅ Technologies déjà en place (à réutiliser) :**
|
|
|
|
| Component | Technology | Status | Usage dans le projet Notebooks |
|
|
|-----------|-----------|--------|-------------------------------|
|
|
| Masonry Layout + Drag & Drop | **Muuri** | ✅ En production | Réorganisation des notes (niveau 1) + Réorganisation notebooks (niveau 2 - NOUVEAU) |
|
|
| AI SDK | **Vercel AI SDK 6.0.23** | ✅ En production | Adaptation pour IA contextuelle par notebook |
|
|
| Database ORM | **Prisma 5.22.0** | ✅ En production | Extension du schema avec Notebook + Label |
|
|
| Server Actions | **Next.js Server Actions** | ✅ En production | CRUD operations pour notebooks/labels |
|
|
| Auto-tagging IA | **Custom implementation** | ✅ En production | Adapter pour filtrer par notebookId |
|
|
| Semantic Search | **Embeddings + vector search** | ✅ En production | Limiter scope au notebook actif |
|
|
| State Management | **React Context + useOptimistic** | ✅ En production | Étendre pour gérer état notebooks |
|
|
| Framework | **Next.js 16.1.1 + React 19.2.3** | ✅ En production | Base de développement |
|
|
| Database | **SQLite** | ✅ En production | Ajout tables Notebook, Label |
|
|
|
|
### 🔑 DECISION ARCHITECTURALE CRITIQUE #1: Drag & Drop Unifié
|
|
|
|
**Question:** Comment implémenter le drag & drop pour les deux niveaux (notes + notebooks) ?
|
|
|
|
**Option Évaluée:** @dnd-kit
|
|
- ❌ **REJETÉ** - Introduirait une deuxième bibliothèque de drag & drop
|
|
- ❌ Dupliquerait les dépendances
|
|
- ❌ UX potentiellement incohérente (deux "feels" différents)
|
|
- ✅ Modern mais déjà disponible dans Muuri
|
|
|
|
**DÉCISION RETENUE: Utiliser Muuri pour les DEUX niveaux de drag & drop**
|
|
|
|
| Niveau | Usage | Implémentation Muuri |
|
|
|--------|-------|---------------------|
|
|
| **Niveau 1** | Réorganisation des notes dans la masonry grid | ✅ **DÉJÀ FONCTIONNEL** (existant) |
|
|
| **Niveau 2** | Réorganisation des notebooks dans la sidebar | 🆕 **À IMPLÉMENTER** avec Muuri |
|
|
|
|
**Justification:**
|
|
|
|
1. **Single Library Principle** - Une seule bibliothèque de drag & drop à maîtriser
|
|
2. **Consistance UX** - Même comportement visuel et interactif partout dans l'app
|
|
3. **Moins de dépendances** - Pas de @dnd-kit, @hello-pangea/dnd, ou react-beautiful-dnd
|
|
4. **Performance** - Muuri est déjà optimisé pour le projet (déjà testé en production)
|
|
5. **Code maintenance** - Un seul pattern de drag & drop à maintenir
|
|
|
|
**Implémentation Technique:**
|
|
|
|
```typescript
|
|
// Niveau 1: Existant (masonry-grid.tsx)
|
|
// Déjà implémenté avec Muuri - PAS DE CHANGEMENT
|
|
|
|
// Niveau 2: Nouveau (notebooks-sidebar.tsx)
|
|
// À implémenter avec la même configuration Muuri
|
|
const notebooksMuuri = new Muuri(notebooksGridRef.current, {
|
|
dragEnabled: true,
|
|
dragContainer: document.body,
|
|
dragStartPredicate: { distance: 5, delay: 0 },
|
|
// Même configuration que masonry-grid pour consistance
|
|
})
|
|
```
|
|
|
|
### Nouvelles Dépendances Requises
|
|
|
|
**✅ AUCUNE nouvelle dépendance majeure requise**
|
|
|
|
Ce projet va être implémenté avec 100% des technologies existantes. Les seuls ajouts potentiels sont :
|
|
|
|
| Type | Dépendance | Raison | Taille |
|
|
|------|-----------|--------|--------|
|
|
| Icons | `lucide-react` (optionnel) | Icons pour notebooks | ~KB (déjà utilisé dans le projet) |
|
|
| Utilities | `clsx` / `cn` (optionnel) | Classname utilities | ~KB (peut être déjà là) |
|
|
|
|
**Decision:** Utiliser les utilitaires déjà existants dans le projet. PAS de nouvelles dépendances npm majeures.
|
|
|
|
---
|
|
|
|
## Step 4: Architectural Decisions
|
|
|
|
Cette section documente TOUTES les décisions architecturales critiques pour chaque composant majeur du projet.
|
|
|
|
### 🔑 DECISION ARCHITECTURALE #2: Database Schema & Data Model
|
|
|
|
**Question:** Comment modéliser les notebooks et labels contextuels dans Prisma ?
|
|
|
|
**Contraintes:**
|
|
- ZERO breaking changes sur le système existant
|
|
- Labels doivent être isolés par notebook (pas de fuite)
|
|
- Support pour "Notes générales" (Inbox) sans notebook
|
|
- Cascade delete contrôlé (pas de perte de données)
|
|
|
|
**DÉCISION RETENUE: Schéma Prisma Extensible avec Relations Optionnelles**
|
|
|
|
```prisma
|
|
// NOUVEAU: Modèle Notebook
|
|
model Notebook {
|
|
id String @id @default(cuid())
|
|
name String
|
|
icon String? // Emoji ou icon name
|
|
color String? // Hex color pour personnalisation
|
|
order Int // Ordre manuel (drag & drop)
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
notes Note[] // Une note peut appartenir à un notebook
|
|
labels Label[] // Labels sont contextuels à ce notebook
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([userId, order])
|
|
@@index([userId])
|
|
}
|
|
|
|
// NOUVEAU: Modèle Label (contextuel)
|
|
model Label {
|
|
id String @id @default(cuid())
|
|
name String
|
|
color String?
|
|
notebookId String // OBLIGATOIRE: Label appartient à un notebook
|
|
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
|
notes Note[] // Plusieurs notes peuvent avoir ce label
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([notebookId, name]) // Nom unique DANS le notebook
|
|
@@index([notebookId])
|
|
}
|
|
|
|
// MODIFIÉ: Modèle Note existant
|
|
model Note {
|
|
id String @id @default(cuid())
|
|
title String?
|
|
content String
|
|
isPinned Boolean @default(false)
|
|
size String @default("small")
|
|
order Int
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
// NOUVEAU: Relation optionnelle avec Notebook
|
|
notebookId String? // NULL = "Notes générales" (Inbox)
|
|
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)
|
|
|
|
// NOUVEAU: Labels contextuels (relation many-to-many)
|
|
labels Label[]
|
|
|
|
// ... autres champs existants (embeddings, etc.)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([userId, order])
|
|
@@index([userId, notebookId]) // Pour filtrer par notebook efficacement
|
|
}
|
|
```
|
|
|
|
**Justification:**
|
|
|
|
1. **`notebookId` optionnel sur Note** - Permet "Notes générales" (null = Inbox)
|
|
2. **`onDelete: SetNull`** - Notebook supprimé → notes deviennent "générales" (pas de perte)
|
|
3. **`@@unique([notebookId, name])`** - Labels uniques par notebook (pas globaux)
|
|
4. **Relation many-to-many Note <-> Label** - Prisma gère la table de jointure automatiquement
|
|
5. **Indexes sur `[userId, notebookId]`** - Recherche performante par notebook
|
|
|
|
**Migration Strategy:**
|
|
|
|
```typescript
|
|
// Migration script (exécuté une fois)
|
|
export async function migrateToNotebooks() {
|
|
// 1. Créer notebook "TEMP_MIGRATION" pour tous les labels existants
|
|
const tempNotebook = await prisma.notebook.create({
|
|
data: {
|
|
name: "Labels Migrés",
|
|
userId: currentUserId,
|
|
order: 999,
|
|
}
|
|
})
|
|
|
|
// 2. Attribuer tous les labels existants à ce notebook
|
|
await prisma.label.updateMany({
|
|
data: { notebookId: tempNotebook.id }
|
|
})
|
|
|
|
// 3. Laisser les notes existantes sans notebook (Inbox)
|
|
// Elles pourront être réorganisées plus tard par l'utilisateur
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 🔑 DECISION ARCHITECTURALE #3: State Management Architecture
|
|
|
|
**Question:** Comment gérer l'état distribué (sidebar + grid + modals + IA) ?
|
|
|
|
**Contraintes:**
|
|
- État global distribué (plusieurs zones de l'UI)
|
|
- Optimistic UI pour drag & drop (latence perçue < 100ms)
|
|
- IA suggestions asynchrones
|
|
- Server Actions pour persistance
|
|
|
|
**DÉCISION RETENUE: React Context + useOptimistic + Server Actions (Hybrid)**
|
|
|
|
**Architecture en Couches:**
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────┐
|
|
│ LAYER 1: UI State (Local Component State) │
|
|
│ - Modal open/close │
|
|
│ - Input focus │
|
|
│ - Temporary UI states │
|
|
└─────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────────────────┐
|
|
│ LAYER 2: Optimistic State (useOptimistic) │
|
|
│ - Drag & drop order changes │
|
|
│ - Note moves between notebooks │
|
|
│ - Notebook reordering │
|
|
│ - CRUD operations (création/suppression notebooks) │
|
|
└─────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────────────────┐
|
|
│ LAYER 3: Global State (React Context) │
|
|
│ - Current notebook filter │
|
|
│ - List of notebooks (sidebar) │
|
|
│ - Labels contextuels (par notebook) │
|
|
└─────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────────────────┐
|
|
│ LAYER 4: Server State (Server Actions + Cache) │
|
|
│ - Persistent data (Prisma) │
|
|
│ - Next.js cache (revalidatePath) │
|
|
│ - IA computations (asynchronous) │
|
|
└─────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Implémentation:**
|
|
|
|
```typescript
|
|
// app/context/notebooks-context.tsx
|
|
export type NotebooksContextValue = {
|
|
// État global
|
|
notebooks: Notebook[]
|
|
currentNotebook: Notebook | null // null = "Notes générales"
|
|
currentLabels: Label[] // Labels du notebook actuel
|
|
|
|
// Actions optimistes
|
|
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<void>
|
|
updateNotebookOrderOptimistic: (notebookIds: string[]) => Promise<void>
|
|
moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void>
|
|
|
|
// Actions IA (non-optimistes, asynchrones)
|
|
suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null>
|
|
suggestLabelsForNote: (noteContent: string, notebookId: string) => Promise<Label[]>
|
|
}
|
|
|
|
export const NotebooksContext = createContext<NotebooksContextValue | null>(null)
|
|
|
|
export function useNotebooks() {
|
|
const context = useContext(NotebooksContext)
|
|
if (!context) throw new Error('useNotebooks must be used within NotebooksProvider')
|
|
return context
|
|
}
|
|
|
|
// app/providers.tsx
|
|
export function NotebooksProvider({ children }: { children: ReactNode }) {
|
|
const [notebooks, setNotebooks] = useState<Notebook[]>([])
|
|
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
|
|
|
|
// Charger les notebooks au mount
|
|
useEffect(() => {
|
|
loadNotebooks().then(setNotebooks)
|
|
}, [])
|
|
|
|
// Charger les labels du notebook actuel
|
|
const currentLabels = useMemo(() => {
|
|
if (!currentNotebook) return []
|
|
return notebooks.find(nb => nb.id === currentNotebook.id)?.labels ?? []
|
|
}, [currentNotebook, notebooks])
|
|
|
|
// Optimistic actions avec useOptimistic
|
|
const [optimisticNotebooks, addOptimisticNotebook] = useOptimistic(
|
|
notebooks,
|
|
(state, newNotebook: Notebook) => [...state, newNotebook]
|
|
)
|
|
|
|
const createNotebookOptimistic = async (data: CreateNotebookInput) => {
|
|
// 1. Optimistic update
|
|
const tempId = `temp-${Date.now()}`
|
|
addOptimisticNotebook({ ...data, id: tempId })
|
|
|
|
// 2. Server Action
|
|
try {
|
|
const result = await createNotebook(data)
|
|
// 3. Revalidation triggers reload with real data
|
|
revalidatePath('/')
|
|
} catch (error) {
|
|
// 4. Error handling - rollback automatic
|
|
}
|
|
}
|
|
|
|
return (
|
|
<NotebooksContext.Provider value={{
|
|
notebooks: optimisticNotebooks,
|
|
currentNotebook,
|
|
currentLabels,
|
|
createNotebookOptimistic,
|
|
// ... autres actions
|
|
}}>
|
|
{children}
|
|
</NotebooksContext.Provider>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Justification:**
|
|
|
|
1. **useOptimistic** - React 19 feature native, parfaite pour drag & drop
|
|
2. **React Context** - Suffisant pour l'état global (pas besoin de Redux/Zustand)
|
|
3. **Server Actions** - Pattern existant à maintenir pour la persistance
|
|
4. **Séparation des préoccupations** - Chaque couche a sa responsabilité
|
|
|
|
---
|
|
|
|
### 🔑 DECISION ARCHITECTURALE #4: IA Contextuelle Architecture
|
|
|
|
**Question:** Comment adapter l'IA existante pour être contextuelle par notebook ?
|
|
|
|
**Contraintes:**
|
|
- Réutiliser l'infrastructure existante (Vercel AI SDK)
|
|
- Filtrer les suggestions par notebook actif
|
|
- Pas de duplication de code
|
|
- 6 features IA à implémenter
|
|
|
|
**DÉCISION RETENUE: Pattern Adapter avec Filtrage au Niveau Service**
|
|
|
|
**Architecture IA Contextuelle:**
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────┐
|
|
│ IA Services Existants (à réutiliser) │
|
|
│ - autoTagService.ts (suggestions de tags) │
|
|
│ - semanticSearchService.ts (recherche sémantique) │
|
|
│ - languageDetectionService.ts (détection langue) │
|
|
└──────────────────────────────────────────────────────┘
|
|
↓
|
|
┌──────────────────────────────────────────────────────┐
|
|
│ NOUVEAU: Contextual Adapter Layer │
|
|
│ - contextualAutoTagService(notebookId) │
|
|
│ - contextualSearchService(notebookId) │
|
|
│ - notebookSuggestionService() │
|
|
└──────────────────────────────────────────────────────┘
|
|
↓
|
|
┌──────────────────────────────────────────────────────┐
|
|
│ Server Actions (UI Layer) │
|
|
│ - suggestLabels(noteId, notebookId) │
|
|
│ - suggestNotebook(noteId) │
|
|
│ - organizeInbox(noteIds) │
|
|
└──────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Implémentation:**
|
|
|
|
```typescript
|
|
// lib/ai/services/contextual-auto-tag.service.ts
|
|
import { autoTagService } from './auto-tag.service'
|
|
|
|
export class ContextualAutoTagService {
|
|
constructor(private notebookId: string) {}
|
|
|
|
/**
|
|
* Suggère des labels pour une note, filtrés par notebook
|
|
*/
|
|
async suggestLabels(noteContent: string): Promise<string[]> {
|
|
// 1. Récupérer tous les labels du notebook courant
|
|
const existingLabels = await prisma.label.findMany({
|
|
where: { notebookId: this.notebookId }
|
|
})
|
|
|
|
// 2. Utiliser le service existant pour générer des suggestions
|
|
const allSuggestions = await autoTagService.suggestTags(noteContent)
|
|
|
|
// 3. Filtrer: retourner seulement les labels qui existent dans ce notebook
|
|
const contextualSuggestions = allSuggestions.filter(suggestion =>
|
|
existingLabels.some(label => label.name === suggestion)
|
|
)
|
|
|
|
// 4. Si pas de correspondance, suggérer de créer un nouveau label (optionnel)
|
|
if (contextualSuggestions.length === 0 && existingLabels.length < 50) {
|
|
// Suggérer le meilleur candidat pour création
|
|
return [allSuggestions[0]] // Premier = plus pertinent
|
|
}
|
|
|
|
return contextualSuggestions
|
|
}
|
|
|
|
/**
|
|
* Crée automatiquement un label si le thème est récurrent (IA4)
|
|
*/
|
|
async createLabelIfRecurring(noteContent: string): Promise<Label | null> {
|
|
const suggestions = await autoTagService.suggestTags(noteContent)
|
|
const topSuggestion = suggestions[0]
|
|
|
|
// Vérifier si ce label existe déjà
|
|
const existing = await prisma.label.findFirst({
|
|
where: {
|
|
notebookId: this.notebookId,
|
|
name: topSuggestion
|
|
}
|
|
})
|
|
|
|
if (existing) return existing // Déjà existe
|
|
|
|
// Créer le nouveau label
|
|
return await prisma.label.create({
|
|
data: {
|
|
name: topSuggestion,
|
|
notebookId: this.notebookId,
|
|
color: getRandomColor()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// app/actions/ai.ts (Server Actions)
|
|
export async function suggestContextualLabels(noteId: string, notebookId: string) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
|
|
|
const note = await prisma.note.findUnique({
|
|
where: { id: noteId, userId: session.user.id }
|
|
})
|
|
|
|
if (!note) throw new Error('Note not found')
|
|
|
|
const service = new ContextualAutoTagService(notebookId)
|
|
const suggestions = await service.suggestLabels(note.content)
|
|
|
|
return suggestions
|
|
}
|
|
|
|
export async function suggestNotebookForNote(noteId: string) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
|
|
|
const note = await prisma.note.findUnique({
|
|
where: { id: noteId, userId: session.user.id },
|
|
include: {
|
|
notebook: true
|
|
}
|
|
})
|
|
|
|
if (!note || note.notebook) {
|
|
// Déjà dans un notebook, pas de suggestion
|
|
return null
|
|
}
|
|
|
|
// Récupérer tous les notebooks de l'utilisateur
|
|
const notebooks = await prisma.notebook.findMany({
|
|
where: { userId: session.user.id },
|
|
include: { labels: true }
|
|
})
|
|
|
|
// Utiliser l'IA pour suggérer le meilleur notebook
|
|
const prompt = `
|
|
Given this note content and the list of available notebooks,
|
|
suggest the most appropriate notebook.
|
|
|
|
Note: ${note.content}
|
|
|
|
Available notebooks:
|
|
${notebooks.map(nb => `- ${nb.name}: ${nb.labels.map(l => l.name).join(', ')}`).join('\n')}
|
|
|
|
Return ONLY the notebook name, or "NONE" if no good match.
|
|
`
|
|
|
|
const response = await generateText({
|
|
model: getOpenAIModel(),
|
|
prompt
|
|
})
|
|
|
|
const suggestedName = response.text.trim()
|
|
const suggestedNotebook = notebooks.find(nb => nb.name === suggestedName)
|
|
|
|
return suggestedNotebook
|
|
}
|
|
```
|
|
|
|
**Mapping des 6 Features IA:**
|
|
|
|
| Feature IA | Service Existant | Adaptation Contextuelle |
|
|
|------------|------------------|------------------------|
|
|
| **IA1: Suggestion Notebook** | N/A (nouveau) | `suggestNotebookForNote()` - analyse contenu + notebooks existants |
|
|
| **IA2: Suggestion Labels** | `autoTagService` | `ContextualAutoTagService` - filtre par `notebookId` |
|
|
| **IA3: Organisation Batch** | `autoTagService` | `organizeInboxBatch()` - déplace notes + suggère notebooks |
|
|
| **IA4: Création Auto Labels** | `autoTagService` | `createLabelIfRecurring()` - détect thèmes récurrents |
|
|
| **IA5: Recherche Sémantique** | `semanticSearchService` | Filtrer query Prisma par `notebookId` |
|
|
| **IA6: Synthèse Notebook** | N/A (nouveau) | `generateNotebookSummary()` - résume toutes les notes d'un notebook |
|
|
|
|
**Justification:**
|
|
|
|
1. **Réutilisation maximum** - Services existants non modifiés
|
|
2. **Pattern Adapter** - Couche d'adaptation légère pour le contexte
|
|
3. **Filtrage DB-level** - Performance optimale (pas de post-traitement)
|
|
4. **Testabilité** - Services isolés, faciles à tester unitairement
|
|
|
|
---
|
|
|
|
### 🔑 DECISION ARCHITECTURALE #5: Drag & Drop Implementation Details
|
|
|
|
**Question:** Comment implémenter concrètement le drag & drop de notebooks dans la sidebar avec Muuri ?
|
|
|
|
**Contraintes:**
|
|
- Utiliser Muuri (pas de @dnd-kit)
|
|
- Même configuration que masonry-grid pour consistance
|
|
- Performance < 100ms latence perçue
|
|
- Optimistic UI avec synchronisation serveur
|
|
|
|
**DÉCISION RETENUE: Instance Muuri dédiée pour la sidebar**
|
|
|
|
**Architecture:**
|
|
|
|
```typescript
|
|
// components/notebooks-sidebar.tsx
|
|
'use client'
|
|
|
|
import { useEffect, useRef, useCallback } from 'react'
|
|
import { useNotebooks } from '@/app/context/notebooks-context'
|
|
|
|
export function NotebooksSidebar() {
|
|
const { notebooks, currentNotebook, setCurrentNotebook, updateNotebookOrderOptimistic } = useNotebooks()
|
|
const notebooksGridRef = useRef<HTMLDivElement>(null)
|
|
const notebooksMuuri = useRef<any>(null)
|
|
|
|
const handleDragEnd = useCallback(async (grid: any) => {
|
|
if (!grid) return
|
|
|
|
const items = grid.getItems()
|
|
const notebookIds = items
|
|
.map((item: any) => item.getElement()?.getAttribute('data-id'))
|
|
.filter((id: any): id is string => !!id)
|
|
|
|
// Optimistic update via useOptimistic
|
|
await updateNotebookOrderOptimistic(notebookIds)
|
|
}, [updateNotebookOrderOptimistic])
|
|
|
|
useEffect(() => {
|
|
let isMounted = true
|
|
|
|
const initNotebooksMuuri = async () => {
|
|
// Import Muuri dynamiquement
|
|
const MuuriClass = (await import('muuri')).default
|
|
|
|
if (!isMounted || !notebooksGridRef.current) return
|
|
|
|
// Configuration IDENTIQUE à masonry-grid pour consistance
|
|
const layoutOptions = {
|
|
dragEnabled: true,
|
|
dragContainer: document.body,
|
|
dragStartPredicate: {
|
|
distance: 5, // Plus sensible que la masonry (sidebar plus compacte)
|
|
delay: 0,
|
|
},
|
|
dragPlaceholder: {
|
|
enabled: true,
|
|
createElement: (item: any) => {
|
|
const el = item.getElement().cloneNode(true)
|
|
el.style.opacity = '0.5'
|
|
return el
|
|
},
|
|
},
|
|
// Layout vertical pour sidebar
|
|
layout: {
|
|
fillGaps: false,
|
|
horizontal: false,
|
|
alignRight: false,
|
|
alignBottom: false,
|
|
rounding: false,
|
|
}
|
|
}
|
|
|
|
notebooksMuuri.current = new MuuriClass(notebooksGridRef.current, layoutOptions)
|
|
.on('dragEnd', () => handleDragEnd(notebooksMuuri.current))
|
|
}
|
|
|
|
initNotebooksMuuri()
|
|
|
|
return () => {
|
|
isMounted = false
|
|
notebooksMuuri.current?.destroy()
|
|
}
|
|
}, [handleDragEnd])
|
|
|
|
// Synchroniser quand les notebooks changent
|
|
useEffect(() => {
|
|
if (notebooksMuuri.current) {
|
|
notebooksMuuri.current.refreshItems().layout()
|
|
}
|
|
}, [notebooks])
|
|
|
|
return (
|
|
<div className="notebooks-sidebar w-64 bg-gray-50 p-4">
|
|
<h2 className="text-sm font-semibold mb-4">Notebooks</h2>
|
|
|
|
<div ref={notebooksGridRef} className="notebooks-list">
|
|
{/* "Notes générales" - toujours en premier */}
|
|
<div
|
|
className={`notebook-item p-3 rounded cursor-pointer hover:bg-gray-200 ${
|
|
!currentNotebook ? 'bg-blue-100' : ''
|
|
}`}
|
|
onClick={() => setCurrentNotebook(null)}
|
|
data-id="general-notes"
|
|
>
|
|
📝 Notes générales
|
|
</div>
|
|
|
|
{/* Liste des notebooks Muuri */}
|
|
{notebooks.map(notebook => (
|
|
<div
|
|
key={notebook.id}
|
|
className={`notebook-item p-3 rounded cursor-pointer hover:bg-gray-200 ${
|
|
currentNotebook?.id === notebook.id ? 'bg-blue-100' : ''
|
|
}`}
|
|
onClick={() => setCurrentNotebook(notebook)}
|
|
data-id={notebook.id}
|
|
>
|
|
{notebook.icon || '📚'} {notebook.name}
|
|
<span className="ml-auto text-xs text-gray-500">
|
|
{notebook.notes?.length || 0} notes
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Drag & Drop Notes vers Notebooks:**
|
|
|
|
```typescript
|
|
// Extension de masonry-grid.tsx
|
|
// Ajouter des zones de drop pour chaque notebook
|
|
|
|
const handleNoteDragEnd = useCallback(async (grid: any) => {
|
|
if (!grid) return
|
|
|
|
const items = grid.getItems()
|
|
const noteIds = items
|
|
.map((item: any) => item.getElement()?.getAttribute('data-id'))
|
|
.filter((id: any): id is string => !!id)
|
|
|
|
// Update order
|
|
await updateFullOrderWithoutRevalidation(noteIds)
|
|
|
|
// CHANGEMENT: Détecter si note déposée sur un notebook
|
|
const draggedOverNotebook = detectNotebookDropTarget()
|
|
|
|
if (draggedOverNotebook) {
|
|
await moveNotesToNotebook(noteIds, draggedOverNotebook.id)
|
|
}
|
|
}, [])
|
|
|
|
function detectNotebookDropTarget(): Notebook | null {
|
|
// Détecter quel élément de sidebar est sous la souris
|
|
const sidebarElement = document.elementFromPoint(mouseX, mouseY)
|
|
const notebookItem = sidebarElement?.closest('.notebook-item')
|
|
|
|
if (!notebookItem) return null
|
|
|
|
const notebookId = notebookItem.getAttribute('data-id')
|
|
if (notebookId === 'general-notes') return null // Inbox
|
|
|
|
return notebooks.find(nb => nb.id === notebookId) || null
|
|
}
|
|
```
|
|
|
|
**Justification:**
|
|
|
|
1. **Configuration partagée** - Options drag & drop synchronisées avec masonry-grid
|
|
2. **Performance** - Muuri optimisé pour sidebar (layout vertical simple)
|
|
3. **UX cohérente** - Même comportement de drag dans toute l'app
|
|
4. **Optimistic UI** - useOptimistic pour réactivité immédiate
|
|
|
|
---
|
|
|
|
### 🔑 DECISION ARCHITECTURALE #6: Migration Strategy (Brownfield)
|
|
|
|
**Question:** Comment migrer du système de tags plat vers notebooks sans breaking changes ?
|
|
|
|
**Contraintes:**
|
|
- ZERO breaking changes - l'ancien système doit continuer de fonctionner
|
|
- Données existantes préservées
|
|
- Migration transparente pour l'utilisateur
|
|
- Rollback possible si nécessaire
|
|
|
|
**DÉCISION RETENUE: Migration Graduelle avec Compatibilité Ascendante**
|
|
|
|
**Plan de Migration en 4 Phases:**
|
|
|
|
```typescript
|
|
// scripts/migrate-to-notebooks.ts
|
|
|
|
/**
|
|
* PHASE 1: Préparation du Schema (Non-Breaking)
|
|
* - Ajouter les tables Notebook et Label
|
|
* - Ajouter notebookId optionnel sur Note (default: null)
|
|
* - Laisser l'ancien système de tags intact
|
|
*/
|
|
async function phase1_prepareSchema() {
|
|
// Exécuter Prisma migrate
|
|
await prisma.$executeRaw`
|
|
CREATE TABLE "Notebook" (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
icon TEXT,
|
|
color TEXT,
|
|
"order" INTEGER NOT NULL,
|
|
"userId" TEXT NOT NULL,
|
|
"createdAt" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
"updatedAt" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY ("userId") REFERENCES "User"(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE "Label" (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
color TEXT,
|
|
"notebookId" TEXT NOT NULL,
|
|
"createdAt" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
"updatedAt" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY ("notebookId") REFERENCES "Notebook"(id) ON DELETE CASCADE,
|
|
UNIQUE("notebookId", "name")
|
|
);
|
|
|
|
ALTER TABLE "Note" ADD COLUMN "notebookId" TEXT;
|
|
ALTER TABLE "Note" ADD FOREIGN KEY ("notebookId") REFERENCES "Notebook"(id) ON DELETE SETNULL;
|
|
CREATE INDEX "Note_notebookId_idx" ON "Note"("userId", "notebookId");
|
|
`
|
|
|
|
console.log('✅ Phase 1: Schema préparé (compatible avec ancien système)')
|
|
}
|
|
|
|
/**
|
|
* PHASE 2: Migration des Données (Transparent)
|
|
* - Créer un notebook par défaut
|
|
* - Déplacer tous les labels existants vers ce notebook
|
|
* - Notes restent sans notebook (Inbox)
|
|
*/
|
|
async function phase2_migrateData(userId: string) {
|
|
// 2.1 Créer notebook de migration
|
|
const migrationNotebook = await prisma.notebook.create({
|
|
data: {
|
|
name: "Labels Migrés",
|
|
icon: "📦",
|
|
order: 999,
|
|
userId
|
|
}
|
|
})
|
|
|
|
// 2.2 Attribuer tous les labels existants à ce notebook
|
|
const existingLabels = await prisma.label.findMany({
|
|
where: { userId } // Assuming labels have userId
|
|
})
|
|
|
|
await Promise.all(
|
|
existingLabels.map(label =>
|
|
prisma.label.update({
|
|
where: { id: label.id },
|
|
data: { notebookId: migrationNotebook.id }
|
|
})
|
|
)
|
|
)
|
|
|
|
// 2.3 Laisser toutes les notes sans notebook (Inbox par défaut)
|
|
// Les notes resteront dans "Notes générales"
|
|
|
|
console.log('✅ Phase 2: Données migrées (labels préservés)')
|
|
}
|
|
|
|
/**
|
|
* PHASE 3: Activation de la Nouvelle UI (Opt-in)
|
|
* - Ajouter la sidebar notebooks
|
|
* - Permettre aux utilisateurs de créer de nouveaux notebooks
|
|
* - L'ancien système de tags continue de fonctionner via le notebook de migration
|
|
*/
|
|
async function phase3_enableNewUI() {
|
|
// Déployer la nouvelle UI avec sidebar
|
|
// Les utilisateurs peuvent commencer à utiliser les notebooks
|
|
// L'ancien système reste accessible via "Labels Migrés"
|
|
|
|
console.log('✅ Phase 3: Nouvelle UI déployée (opt-in)')
|
|
}
|
|
|
|
/**
|
|
* PHASE 4: Nettoyage (Futur - Optionnel)
|
|
* - Après adoption massive, supprimer l'ancien système
|
|
* - SEULEMENT après confirmation que tous les utilisateurs ont migré
|
|
*/
|
|
async function phase4_cleanup() {
|
|
// À exécuter manuellement après 6-12 mois
|
|
// Supprimer l'ancien système de tags global
|
|
|
|
console.log('⚠️ Phase 4: Nettoyage (futur, optionnel)')
|
|
}
|
|
```
|
|
|
|
**Compatibility Layer (Pendant la transition):**
|
|
|
|
```typescript
|
|
// lib/compatibility/legacy-tags.ts
|
|
|
|
/**
|
|
* Permet à l'ancien code de continuer à fonctionner
|
|
* pendant la période de transition
|
|
*/
|
|
export class LegacyTagsCompatibility {
|
|
/**
|
|
* Retourne tous les labels (mode compatibilité)
|
|
* - Si notebookId spécifié: labels de ce notebook
|
|
* - Sinon: tous les labels (ancien comportement)
|
|
*/
|
|
static async getLabels(userId: string, notebookId?: string) {
|
|
if (notebookId) {
|
|
// Nouveau système: labels contextuels
|
|
return await prisma.label.findMany({
|
|
where: { notebook: { userId, id: notebookId } }
|
|
})
|
|
} else {
|
|
// Ancien système: tous les labels (backward compat)
|
|
return await prisma.label.findMany({
|
|
where: { notebook: { userId } }
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ajoute un label à une note (mode compatibilité)
|
|
* - Crée automatiquement le notebook de migration si nécessaire
|
|
*/
|
|
static async addLabelToNote(noteId: string, labelName: string) {
|
|
const note = await prisma.note.findUnique({ where: { id: noteId } })
|
|
|
|
if (!note?.notebookId) {
|
|
// Note sans notebook: utiliser le notebook de migration
|
|
const migrationNotebook = await this.getOrCreateMigrationNotebook(note.userId)
|
|
return await this.attachLabel(note, labelName, migrationNotebook.id)
|
|
} else {
|
|
// Note avec notebook: attacher au notebook existant
|
|
return await this.attachLabel(note, labelName, note.notebookId)
|
|
}
|
|
}
|
|
|
|
private static async getOrCreateMigrationNotebook(userId: string) {
|
|
let notebook = await prisma.notebook.findFirst({
|
|
where: { name: "Labels Migrés", userId }
|
|
})
|
|
|
|
if (!notebook) {
|
|
notebook = await prisma.notebook.create({
|
|
data: { name: "Labels Migrés", userId, order: 999 }
|
|
})
|
|
}
|
|
|
|
return notebook
|
|
}
|
|
|
|
private static async attachLabel(note: Note, labelName: string, notebookId: string) {
|
|
// Créer le label s'il n'existe pas
|
|
let label = await prisma.label.findFirst({
|
|
where: { notebookId, name: labelName }
|
|
})
|
|
|
|
if (!label) {
|
|
label = await prisma.label.create({
|
|
data: { name: labelName, notebookId }
|
|
})
|
|
}
|
|
|
|
// Attacher à la note
|
|
return await prisma.note.update({
|
|
where: { id: note.id },
|
|
data: { labels: { connect: { id: label.id } } }
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
**Justification:**
|
|
|
|
1. **Non-breaking** - L'ancien système continue de fonctionner
|
|
2. **Transparent** - L'utilisateur ne perd aucune donnée
|
|
3. **Progressif** - Adoption en douceur, pas de "big bang"
|
|
4. **Rollback-friendly** - Chaque phase peut être annulée
|
|
5. **Future-proof** - Prépare le terrain pour supprimer l'ancien système plus tard
|
|
|
|
---
|
|
|
|
### 🔑 DECISION ARCHITECTURALE #7: Search Contextualization
|
|
|
|
**Question:** Comment limiter la recherche sémantique au notebook actuel ?
|
|
|
|
**Contraintes:**
|
|
- Réutiliser le service de recherche existant
|
|
- Filtrer au niveau DB (pas post-traitement)
|
|
- Performance < 300ms
|
|
- Supporter "Notes générales" (toutes les notes sans notebook)
|
|
|
|
**DÉCISION RETENUE: Filtrage Prisma avec requête conditionnelle**
|
|
|
|
**Architecture:**
|
|
|
|
```typescript
|
|
// app/actions/semantic-search.ts
|
|
|
|
export async function contextualSemanticSearch(query: string, notebookId: string | null) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
|
|
|
// Générer l'embedding pour la requête
|
|
const queryEmbedding = await generateEmbedding(query)
|
|
|
|
// CONSTRUCTION CONDITIONNELLE DE LA REQUÊTE
|
|
const baseWhere = {
|
|
userId: session.user.id,
|
|
}
|
|
|
|
// FILTRAGE PAR NOTEBOOK
|
|
const whereClause = notebookId
|
|
? { ...baseWhere, notebookId } // Notebook spécifique
|
|
: { ...baseWhere, notebookId: null } // "Notes générales" uniquement
|
|
|
|
// Si on veut rechercher dans TOUT (Inbox + tous les notebooks)
|
|
// const whereClause = baseWhere // Pas de filtre notebook
|
|
|
|
// REQUÊTE PRISMA AVEC FILTRAGE
|
|
const results = await prisma.$queryRaw`
|
|
SELECT
|
|
n.*,
|
|
v.embedding <#> ${queryEmbedding} AS distance
|
|
FROM "Note" n
|
|
LEFT JOIN "_NoteToLabel" ntl ON n.id = ntl."A"
|
|
LEFT JOIN "Label" l ON ntl."B" = l.id
|
|
WHERE n."userId" = ${session.user.id}
|
|
AND ${notebookId ? Prisma.sql`n."notebookId" = ${notebookId}` : Prisma.sql`n."notebookId" IS NULL`}
|
|
ORDER BY distance ASC
|
|
LIMIT 20
|
|
`
|
|
|
|
// Retourner les résultats
|
|
return results as Note[]
|
|
}
|
|
```
|
|
|
|
**UI Integration:**
|
|
|
|
```typescript
|
|
// components/search-bar.tsx
|
|
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useNotebooks } from '@/app/context/notebooks-context'
|
|
|
|
export function SearchBar() {
|
|
const { currentNotebook } = useNotebooks()
|
|
const [query, setQuery] = useState('')
|
|
const [results, setResults] = useState<Note[]>([])
|
|
|
|
const handleSearch = async (searchQuery: string) => {
|
|
setQuery(searchQuery)
|
|
|
|
if (searchQuery.length < 2) {
|
|
setResults([])
|
|
return
|
|
}
|
|
|
|
// RECHERCHE CONTEXTUELLE
|
|
const searchResults = await contextualSemanticSearch(
|
|
searchQuery,
|
|
currentNotebook?.id || null // null = Notes générales
|
|
)
|
|
|
|
setResults(searchResults)
|
|
}
|
|
|
|
return (
|
|
<div className="search-bar">
|
|
<input
|
|
type="text"
|
|
placeholder={currentNotebook
|
|
? `Rechercher dans "${currentNotebook.name}"`
|
|
: "Rechercher dans les Notes générales"
|
|
}
|
|
value={query}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
/>
|
|
|
|
{results.length > 0 && (
|
|
<div className="search-results">
|
|
<p className="text-xs text-gray-500">
|
|
{results.length} résultat{results.length > 1 ? 's' : ''} dans{' '}
|
|
{currentNotebook ? currentNotebook.name : 'Notes générales'}
|
|
</p>
|
|
{results.map(note => (
|
|
<SearchResultItem key={note.id} note={note} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Variante: Recherche Globale (Option Future):**
|
|
|
|
```typescript
|
|
// Si on veut permettre la recherche dans TOUS les notebooks
|
|
export async function globalSemanticSearch(query: string) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
|
|
|
const queryEmbedding = await generateEmbedding(query)
|
|
|
|
// PAS DE FILTRE NOTEBOOK
|
|
const results = await prisma.$queryRaw`
|
|
SELECT
|
|
n.*,
|
|
nb.name AS "notebookName",
|
|
v.embedding <#> ${queryEmbedding} AS distance
|
|
FROM "Note" n
|
|
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
|
|
WHERE n."userId" = ${session.user.id}
|
|
ORDER BY distance ASC
|
|
LIMIT 20
|
|
`
|
|
|
|
return results as (Note & { notebookName: string | null })[]
|
|
}
|
|
```
|
|
|
|
**Justification:**
|
|
|
|
1. **Performance** - Filtrage au niveau DB (pas de post-traitement JavaScript)
|
|
2. **Simplicité** - Réutilise le service existant avec une clause `where` supplémentaire
|
|
3. **Flexibilité** - Facile à étendre pour "recherche globale" plus tard
|
|
4. **UX claire** - L'utilisateur sait où il recherche (notebook actuel)
|
|
|
|
---
|
|
|
|
### 🔑 DECISION ARCHITECTURALE #8: Undo/Redo System
|
|
|
|
**Question:** Comment permettre l'annulation d'actions destructives (déplacements, suppressions) ?
|
|
|
|
**Contraintes:**
|
|
- UX sans peur (l'utilisateur ose explorer)
|
|
- Actions concernées: déplacement notes, suppression notebooks, suppression labels
|
|
- Pas d'historique infini (limite à 20 actions)
|
|
- Implementation simple
|
|
|
|
**DÉCISION RETENUE: Historique d'Actions Simples avec Server Actions Rollback**
|
|
|
|
**Architecture:**
|
|
|
|
```typescript
|
|
// lib/undo-history.ts
|
|
|
|
interface Action {
|
|
type: 'MOVE_NOTE' | 'DELETE_NOTEBOOK' | 'DELETE_LABEL' | 'REORDER_NOTEBOOKS'
|
|
timestamp: Date
|
|
undo: () => Promise<void>
|
|
description: string
|
|
}
|
|
|
|
class UndoHistory {
|
|
private history: Action[] = []
|
|
private maxHistory = 20
|
|
|
|
add(action: Action) {
|
|
this.history.push(action)
|
|
if (this.history.length > this.maxHistory) {
|
|
this.history.shift() // Supprimer l'action la plus ancienne
|
|
}
|
|
}
|
|
|
|
async undo() {
|
|
const lastAction = this.history.pop()
|
|
if (!lastAction) return
|
|
|
|
await lastAction.undo()
|
|
}
|
|
|
|
getLastAction(): Action | null {
|
|
return this.history[this.history.length - 1] || null
|
|
}
|
|
}
|
|
|
|
export const undoHistory = new UndoHistory()
|
|
|
|
// app/actions/notes.ts
|
|
export async function moveNoteToNotebook(noteId: string, notebookId: string | null) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
|
|
|
const note = await prisma.note.findUnique({
|
|
where: { id: noteId, userId: session.user.id }
|
|
})
|
|
|
|
if (!note) throw new Error('Note not found')
|
|
|
|
const previousNotebookId = note.notebookId
|
|
|
|
// Exécuter l'action
|
|
const updatedNote = await prisma.note.update({
|
|
where: { id: noteId },
|
|
data: { notebookId }
|
|
})
|
|
|
|
// Enregistrer dans l'historique
|
|
undoHistory.add({
|
|
type: 'MOVE_NOTE',
|
|
timestamp: new Date(),
|
|
description: `Déplacer "${note.title || 'Sans titre'}" vers ${notebookId ? 'notebook' : 'Notes générales'}`,
|
|
undo: async () => {
|
|
// Rollback: remettre dans l'ancien notebook
|
|
await prisma.note.update({
|
|
where: { id: noteId },
|
|
data: { notebookId: previousNotebookId }
|
|
})
|
|
revalidatePath('/')
|
|
}
|
|
})
|
|
|
|
revalidatePath('/')
|
|
return updatedNote
|
|
}
|
|
|
|
export async function deleteNotebook(notebookId: string) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
|
|
|
const notebook = await prisma.notebook.findUnique({
|
|
where: { id: notebookId, userId: session.user.id },
|
|
include: { notes: true }
|
|
})
|
|
|
|
if (!notebook) throw new Error('Notebook not found')
|
|
|
|
// Sauvegarder les données pour rollback
|
|
const notebookData = JSON.stringify(notebook)
|
|
|
|
// Supprimer le notebook (CASCADE delete les labels)
|
|
await prisma.notebook.delete({
|
|
where: { id: notebookId }
|
|
})
|
|
|
|
// Les notes deviennent "générales" (onDelete: SetNull)
|
|
|
|
// Enregistrer dans l'historique
|
|
undoHistory.add({
|
|
type: 'DELETE_NOTEBOOK',
|
|
timestamp: new Date(),
|
|
description: `Supprimer le notebook "${notebook.name}"`,
|
|
undo: async () => {
|
|
// Rollback: recréer le notebook
|
|
const data = JSON.parse(notebookData)
|
|
await prisma.notebook.create({
|
|
data: {
|
|
id: data.id,
|
|
name: data.name,
|
|
icon: data.icon,
|
|
color: data.color,
|
|
order: data.order,
|
|
userId: data.userId
|
|
}
|
|
})
|
|
|
|
// Recréer les labels
|
|
for (const label of data.labels) {
|
|
await prisma.label.create({
|
|
data: {
|
|
id: label.id,
|
|
name: label.name,
|
|
color: label.color,
|
|
notebookId: data.id
|
|
}
|
|
})
|
|
}
|
|
|
|
revalidatePath('/')
|
|
}
|
|
})
|
|
|
|
revalidatePath('/')
|
|
}
|
|
|
|
// app/actions/undo.ts
|
|
export async function undoLastAction() {
|
|
const session = await auth()
|
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
|
|
|
await undoHistory.undo()
|
|
|
|
revalidatePath('/')
|
|
}
|
|
```
|
|
|
|
**UI Integration:**
|
|
|
|
```typescript
|
|
// components/undo-toast.tsx
|
|
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { undoLastAction } from '@/app/actions/undo'
|
|
|
|
export function UndoToast() {
|
|
const [lastAction, setLastAction] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
// Poll pour détecter nouvelles actions
|
|
const interval = setInterval(() => {
|
|
// Récupérer la dernière action depuis le serveur
|
|
// (simplifié - en pratique on utiliserait un hook ou un contexte)
|
|
}, 1000)
|
|
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
if (!lastAction) return null
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded shadow-lg">
|
|
<span className="mr-2">{lastAction}</span>
|
|
<button
|
|
onClick={() => undoLastAction()}
|
|
className="underline hover:no-underline"
|
|
>
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Justification:**
|
|
|
|
1. **Simplicité** - Pas de bibliothèque complexe (pas d'immer, redux-undo, etc.)
|
|
2. **Performance** - Historique limité à 20 actions
|
|
3. **UX claire** - Toast avec bouton "Annuler" (comme Gmail)
|
|
4. **Suffisant** - Couvre les cas d'usage principaux (move, delete)
|
|
|
|
---
|
|
|
|
## Architecture Summary
|
|
|
|
### Decision Matrix
|
|
|
|
Voici un résumé de toutes les décisions architecturales prises :
|
|
|
|
| # | Decision | Technology / Pattern | Key Benefit | Risk Mitigation |
|
|
|---|----------|---------------------|-------------|-----------------|
|
|
| **1** | Drag & Drop Unifié | **Muuri** (2 instances) | UX cohérente, une seule library à maîtriser | Performance validée en production |
|
|
| **2** | Database Schema | **Prisma extensible** avec relations optionnelles | ZERO breaking changes, migration douce | Cascade delete maîtrisé (SetNull) |
|
|
| **3** | State Management | **React Context + useOptimistic + Server Actions** | État distribué gérable, optimistic UI natif React 19 | Couches séparées pour clarté |
|
|
| **4** | IA Contextuelle | **Pattern Adapter** avec services existants | Réutilisation maximum, pas de duplication | Filtrage DB-level pour performance |
|
|
| **5** | Drag & Drop Details | **Instance Muuri dédiée** pour sidebar | Configuration partagée avec masonry-grid | Sync via useOptimistic |
|
|
| **6** | Migration Strategy | **4 phases** avec compatibilité ascendante | Non-breaking, rollback possible | Progressif, pas de "big bang" |
|
|
| **7** | Search Contextualization | **Filtrage Prisma** conditionnel | Performance DB-level, pas post-traitement | Extension facile pour recherche globale |
|
|
| **8** | Undo/Redo System | **Historique simple** avec rollback | UX sans peur, implementation légère | Limité à 20 actions pour performance |
|
|
|
|
### Technology Stack Final
|
|
|
|
```yaml
|
|
Frontend:
|
|
Framework: Next.js 16.1.1 (App Router)
|
|
UI: React 19.2.3 (Strict Mode)
|
|
State: React Context + useOptimistic (React 19 natif)
|
|
Drag & Drop: Muuri (déjà en place)
|
|
Icons: lucide-react (optionnel, déjà présent)
|
|
|
|
Backend:
|
|
API: Server Actions (pattern existant)
|
|
ORM: Prisma 5.22.0
|
|
Database: SQLite
|
|
|
|
AI:
|
|
SDK: Vercel AI SDK 6.0.23 (déjà en place)
|
|
Providers: OpenAI / Ollama (déjà configurés)
|
|
Services: Adapter layer pour contextualisation
|
|
|
|
NOUVELLES DÉPENDANCES: AUCUNE (0)
|
|
```
|
|
|
|
### Component Architecture Map
|
|
|
|
```
|
|
keep-notes/
|
|
├── app/
|
|
│ ├── context/
|
|
│ │ └── notebooks-context.tsx # 🆕 État global notebooks
|
|
│ ├── actions/
|
|
│ │ ├── notebooks.ts # 🆕 CRUD notebooks
|
|
│ │ ├── labels.ts # 🆕 CRUD labels contextuels
|
|
│ │ ├── ai.ts # 🔄 Adapter IA contextuelle
|
|
│ │ ├── undo.ts # 🆕 Système d'annulation
|
|
│ │ └── notes.ts # 🔄 Extension pour notebooks
|
|
│ ├── components/
|
|
│ │ ├── notebooks-sidebar.tsx # 🆕 Sidebar avec drag & drop
|
|
│ │ ├── notebook-item.tsx # 🆕 Item notebook individuel
|
|
│ │ ├── label-chip.tsx # 🆕 Chip label contextuel
|
|
│ │ ├── masonry-grid.tsx # 🔄 Extension pour drop notebooks
|
|
│ │ └── undo-toast.tsx # 🆕 Toast d'annulation
|
|
│ └── providers.tsx # 🔄 Ajout NotebooksProvider
|
|
│
|
|
├── lib/
|
|
│ ├── ai/
|
|
│ │ └── services/
|
|
│ │ ├── contextual-auto-tag.service.ts # 🆕 IA contextuelle
|
|
│ │ ├── notebook-suggestion.service.ts # 🆕 IA1
|
|
│ │ └── notebook-summary.service.ts # 🆕 IA6
|
|
│ ├── compatibility/
|
|
│ │ └── legacy-tags.ts # 🆕 Couche compatibilité
|
|
│ └── undo-history.ts # 🆕 Historique d'actions
|
|
│
|
|
├── prisma/
|
|
│ └── schema.prisma # 🔄 Ajout Notebook + Label
|
|
│
|
|
└── scripts/
|
|
└── migrate-to-notebooks.ts # 🆕 Migration 4 phases
|
|
```
|
|
|
|
### Data Flow Diagram
|
|
|
|
```
|
|
┌────────────────────────────────────────────────────────────────┐
|
|
│ USER INTERACTION │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ Drag & Drop │ │ Create │ │ Search │ │
|
|
│ │ Notes │ │ Notebook │ │ Contextual │ │
|
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
|
└─────────┼──────────────────┼──────────────────┼────────────────┘
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌────────────────────────────────────────────────────────────────┐
|
|
│ LAYER 1: OPTIMISTIC UI (useOptimistic) │
|
|
│ - Mise à jour immédiate de l'UI │
|
|
│ - Feedback visuel instantané │
|
|
└────────────────────────────────────────────────────────────────┘
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌────────────────────────────────────────────────────────────────┐
|
|
│ LAYER 2: SERVER ACTIONS (app/actions/) │
|
|
│ - Validation auth/permissions │
|
|
│ - Business logic │
|
|
│ - Undo history registration │
|
|
└────────────────────────────────────────────────────────────────┘
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌────────────────────────────────────────────────────────────────┐
|
|
│ LAYER 3: DATABASE (Prisma + SQLite) │
|
|
│ - Notebook/Label CRUD │
|
|
│ - Relations & Cascade delete │
|
|
│ - Indexes pour performance │
|
|
└────────────────────────────────────────────────────────────────┘
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌────────────────────────────────────────────────────────────────┐
|
|
│ LAYER 4: AI SERVICES (Contextual) │
|
|
│ - Notebook suggestions (IA1) │
|
|
│ - Label suggestions (IA2) │
|
|
│ - Semantic search limitée (IA5) │
|
|
└────────────────────────────────────────────────────────────────┘
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌────────────────────────────────────────────────────────────────┐
|
|
│ LAYER 5: CACHE INVALIDATION │
|
|
│ - revalidatePath('/') │
|
|
│ - Refresh React Context │
|
|
└────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Performance Targets vs Implementation
|
|
|
|
| Requirement | Target | Implementation | Confidence |
|
|
|-------------|--------|----------------|------------|
|
|
| Drag & drop latence | < 100ms | Muuri + useOptimistic (sans revalidatePath) | ⭐⭐⭐⭐⭐ Validé en production |
|
|
| Recherche sémantique | < 300ms | Filtrage Prisma DB-level | ⭐⭐⭐⭐ Basé sur existant |
|
|
| Suggestions IA | < 2s | Vercel AI SDK (déjà en place) | ⭐⭐⭐⭐⭐ Validé en production |
|
|
| Sidebar chargement | < 200ms | React Context + useEffect | ⭐⭐⭐⭐ Standard React |
|
|
| Migration rollback | < 5s | Script Prisma + transaction | ⭐⭐⭐⭐ Basé sur DB size |
|
|
|
|
### Risk Assessment
|
|
|
|
| Risk | Impact | Probability | Mitigation |
|
|
|------|--------|-------------|------------|
|
|
| Performance dégradée avec 1000+ notes | HIGH | MEDIUM | Indexes Prisma, pagination, lazy loading |
|
|
| Drag & drop bug entre sidebar et grid | MEDIUM | LOW | Tests E2E, détection collision, fallback menu |
|
|
| IA suggestions incohérentes | MEDIUM | MEDIUM | Validation labels existants, feedback user |
|
|
| Migration data loss | CRITICAL | VERY LOW | Backup avant migration, rollback, tests staging |
|
|
| État global désynchronisé | MEDIUM | MEDIUM | React Context unique source of truth, revalidation |
|
|
|
|
---
|
|
|
|
## Implementation Roadmap
|
|
|
|
### Phase 1: Foundation (Week 1-2)
|
|
**Objectif:** Préparer le terrain sans toucher à l'existant
|
|
|
|
1. **Database Schema**
|
|
- [ ] Créer migration Prisma (Notebook, Label modifiés)
|
|
- [ ] Tester localement avec `prisma migrate dev`
|
|
- [ ] Valider indexes et cascade delete
|
|
|
|
2. **State Management**
|
|
- [ ] Créer `NotebooksContext` et `NotebooksProvider`
|
|
- [ ] Intégrer dans `app/providers.tsx`
|
|
- [ ] Créer hooks `useNotebooks()`
|
|
|
|
3. **Base Server Actions**
|
|
- [ ] `createNotebook()`, `updateNotebook()`, `deleteNotebook()`
|
|
- [ ] `createLabel()`, `updateLabel()`, `deleteLabel()`
|
|
- [ ] `moveNoteToNotebook()`
|
|
|
|
### Phase 2: Core UI (Week 2-3)
|
|
**Objectif:** Implémenter la sidebar et navigation
|
|
|
|
1. **Notebooks Sidebar**
|
|
- [ ] Composant `NotebooksSidebar` avec Muuri
|
|
- [ ] Drag & drop réorganisation notebooks
|
|
- [ ] "Notes générales" item
|
|
|
|
2. **Navigation**
|
|
- [ ] Filtrer notes par notebook (Inbox vs Notebook)
|
|
- [ ] Mettre à jour masonry-grid pour afficher notes filtrées
|
|
- [ ] Breadcrumb/indicateur de notebook actuel
|
|
|
|
3. **CRUD UI**
|
|
- [ ] Modal création notebook
|
|
- [ ] Modal création label
|
|
- [ ] Menu contextuel (clic droit) sur notebooks
|
|
|
|
### Phase 3: Drag & Drop Advanced (Week 3-4)
|
|
**Objectif:** Déplacer notes entre notebooks
|
|
|
|
1. **Cross-container Drag & Drop**
|
|
- [ ] Détecter drop sur sidebar
|
|
- [ ] Déplacer note vers notebook
|
|
- [ ] Déplacer note vers "Notes générales"
|
|
|
|
2. **Menu Contextuel Alternative**
|
|
- [ ] Clic droit → "Déplacer vers..."
|
|
- [ ] Liste des notebooks disponibles
|
|
- [ ] Fallback pour utilisateurs sans drag & drop
|
|
|
|
### Phase 4: IA Integration (Week 4-5)
|
|
**Objectif:** Adapter l'IA existante pour le contexte
|
|
|
|
1. **IA Services**
|
|
- [ ] `ContextualAutoTagService`
|
|
- [ ] `NotebookSuggestionService`
|
|
- [ ] `NotebookSummaryService`
|
|
|
|
2. **UI IA Features**
|
|
- [ ] IA1: Suggestion notebook quand note créée dans Inbox
|
|
- [ ] IA2: Suggestion labels contextuels
|
|
- [ ] IA5: Recherche sémantique limitée au notebook
|
|
|
|
### Phase 5: Polish & Testing (Week 5-6)
|
|
**Objectif:** Finaliser et tester
|
|
|
|
1. **Undo/Redo**
|
|
- [ ] Implémenter `UndoHistory`
|
|
- [ ] Toast d'annulation
|
|
- [ ] Tester tous les cas de rollback
|
|
|
|
2. **Migration**
|
|
- [ ] Script migration 4 phases
|
|
- [ ] Tests staging avec données réelles
|
|
- [ ] Documentation migration
|
|
|
|
3. **Testing**
|
|
- [ ] Tests E2E drag & drop
|
|
- [ ] Tests performance (1000+ notes)
|
|
- [ ] Tests IA suggestions
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
### Architectural Principles Applied
|
|
|
|
1. **Brownfield-First** - Réutilisation maximale de l'existant, ZERO breaking changes
|
|
2. **Single Responsibility** - Chaque composant/service a une responsabilité claire
|
|
3. **Performance First** - Optimisations DB-level, optimistic UI pour UX fluide
|
|
4. **Progressive Enhancement** - Migration en 4 phases, adoption graduelle
|
|
5. **Simplicity over Complexity** - Pas de Redux/Zustand/dnd-kit, utiliser le natif React
|
|
|
|
### Key Success Factors
|
|
|
|
✅ **Muuri pour tout drag & drop** - Un seul library, UX cohérente
|
|
✅ **React Context + useOptimistic** - État gérable sans librairie externe
|
|
✅ **Pattern Adapter pour IA** - Réutilisation du code existant
|
|
✅ **Migration graduelle** - Pas de "big bang", rollback possible
|
|
✅ **Zéro nouvelle dépendance majeure** - 100% technologies existantes
|
|
|
|
### Next Steps
|
|
|
|
1. **Valider ce document** - Obtenir approbation architecturale
|
|
2. **Créer tech specs détaillées** - Pour chaque composant majeur
|
|
3. **Lancer Phase 1** - Database schema + state management
|
|
4. **Itérer rapidement** - 2 semaines par phase, validation continue
|
|
|
|
---
|
|
|
|
**Document Status:** ✅ COMPLETE & VALIDATED
|
|
|
|
**Validation Date:** 2026-01-11
|
|
**Validated By:** Ramez (Product Owner)
|
|
**Architect:** Winston (AI Agent)
|
|
|
|
**Total Architectural Decisions:** 8
|
|
**Total Components:** ~10-12
|
|
**New Dependencies:** 0
|
|
**Breaking Changes:** 0
|
|
**Estimated Implementation Time:** 5-6 weeks
|
|
|
|
---
|
|
|
|
*This architecture document was created collaboratively using the BMAD Architecture Workflow.*
|
|
*Date: 2026-01-11*
|
|
*Architect: Winston (AI Agent)*
|
|
*Project: Keep - Notebooks & Labels Contextuels*
|