From ed807d3b2a63888550ec1ecdec6564eae95731da Mon Sep 17 00:00:00 2001 From: Sepehr Ramezani Date: Tue, 28 Apr 2026 16:50:57 +0200 Subject: [PATCH] Add safe database migration workflow and note history infrastructure. This introduces guarded migrations with automatic backups, fixes note creation after DB reset, and wires snapshot/restore history across notes surfaces. --- Momento-main/momento/memento-note/README.md | 82 + .../momento/memento-note/app/(main)/page.tsx | 28 + .../memento-note/app/actions/ai-settings.ts | 280 +++ .../momento/memento-note/app/actions/notes.ts | 1952 +++++++++++++++++ .../app/actions/title-suggestions.ts | 142 ++ .../app/api/notes/[id]/history/route.ts | 51 + .../app/api/notes/[id]/move/route.ts | 110 + .../memento-note/app/api/notes/[id]/route.ts | 218 ++ .../components/ai/ai-settings-panel.tsx | 225 ++ .../memento-note/components/home-client.tsx | 475 ++++ .../memento-note/components/masonry-grid.tsx | 338 +++ .../memento-note/components/note-actions.tsx | 243 ++ .../memento-note/components/note-card.tsx | 782 +++++++ .../components/note-history-modal.tsx | 219 ++ .../components/note-inline-editor.tsx | 856 ++++++++ .../components/notes-main-section.tsx | 69 + .../components/notes-tabs-view.tsx | 994 +++++++++ .../momento/memento-note/lib/note-history.ts | 121 + .../momento/memento-note/lib/types.ts | 232 ++ .../momento/memento-note/package.json | 107 + .../migration.sql | 43 + .../momento/memento-note/prisma/schema.prisma | 409 ++++ .../memento-note/scripts/safe-migrate.js | 76 + 23 files changed, 8052 insertions(+) create mode 100644 Momento-main/momento/memento-note/README.md create mode 100644 Momento-main/momento/memento-note/app/(main)/page.tsx create mode 100644 Momento-main/momento/memento-note/app/actions/ai-settings.ts create mode 100644 Momento-main/momento/memento-note/app/actions/notes.ts create mode 100644 Momento-main/momento/memento-note/app/actions/title-suggestions.ts create mode 100644 Momento-main/momento/memento-note/app/api/notes/[id]/history/route.ts create mode 100644 Momento-main/momento/memento-note/app/api/notes/[id]/move/route.ts create mode 100644 Momento-main/momento/memento-note/app/api/notes/[id]/route.ts create mode 100644 Momento-main/momento/memento-note/components/ai/ai-settings-panel.tsx create mode 100644 Momento-main/momento/memento-note/components/home-client.tsx create mode 100644 Momento-main/momento/memento-note/components/masonry-grid.tsx create mode 100644 Momento-main/momento/memento-note/components/note-actions.tsx create mode 100644 Momento-main/momento/memento-note/components/note-card.tsx create mode 100644 Momento-main/momento/memento-note/components/note-history-modal.tsx create mode 100644 Momento-main/momento/memento-note/components/note-inline-editor.tsx create mode 100644 Momento-main/momento/memento-note/components/notes-main-section.tsx create mode 100644 Momento-main/momento/memento-note/components/notes-tabs-view.tsx create mode 100644 Momento-main/momento/memento-note/lib/note-history.ts create mode 100644 Momento-main/momento/memento-note/lib/types.ts create mode 100644 Momento-main/momento/memento-note/package.json create mode 100644 Momento-main/momento/memento-note/prisma/migrations/20260428150000_add_note_history/migration.sql create mode 100644 Momento-main/momento/memento-note/prisma/schema.prisma create mode 100644 Momento-main/momento/memento-note/scripts/safe-migrate.js diff --git a/Momento-main/momento/memento-note/README.md b/Momento-main/momento/memento-note/README.md new file mode 100644 index 0000000..b2141bc --- /dev/null +++ b/Momento-main/momento/memento-note/README.md @@ -0,0 +1,82 @@ +# Keep Notes ✨ + +Keep Notes est une application avancée de prise de notes hybride, combinant la fluidité d'un outil local moderne avec la puissance de l'Intelligence Artificielle. Conçue pour offrir des performances maximales, elle utilise les dernières avancées de l'écosystème React et Next.js. + +## 🚀 Fonctionnalités + +- **Notes & Carnets** : Organisez vos idées rapidement avec des dossiers, codes couleurs, et épinglage. +- **Support Markdown & Rendu Riche** : Éditez ou affichez vos notes instantanément. +- **Disposition Masonry** : Grille CSS ultra-rapide (0 JavaScript) avec drag & drop fluide via `@dnd-kit`. +- **Intégration de l'Intelligence Artificielle** : + - **Memory Echo** : Suggestion automatique et connexions entre notes similaires (RAG / Embeddings). + - **Auto-Tagging** : Création automatique d'étiquettes pertinentes. + - **Organisation par lots** (Batch Organization) : Tri automatique des notes en vrac. + - **Amélioration textuelle** : Reformulation, synthèse, ou traduction propulsés par l'IA. +- **Haute Performance (RSC & Turbopack)** : Rendu Server Components natif pour une hydratation sans délai et développement accéléré via Turbopack. + +## 📄 Licence et Droits d'Auteur + +### **Licence Utilisateur Final (Version actuelle - Personnelle & Non-Commerciale)** +Ce code source est fourni **strictement pour un usage personnel et éducatif**. +- **Utilisation non-commerciale uniquement** : Il est interdit d'utiliser ce projet (ou tout code dérivé) pour générer des revenus, construire un produit commercial ou l'intégrer dans un service monétisé. +- **Redistribution sous condition** : Vous ne pouvez pas redistribuer ou publier cette version sans maintenir cette licence restrictive. + +*(Inspiré de Creative Commons Attribution-NonCommercial 4.0 International - CC BY-NC 4.0).* + +--- + +## 🗺️ Roadmap & Version SaaS Commerciale Publique + +Une version complète de **Keep Notes** destinée au grand public est prévue et en cours de planification. Cette version cloud s'appuiera sur de toutes nouvelles optimisations d'infrastructure : + +1. **Migration Base de Données** : + - Remplacement de SQLite local par **PostgreSQL** afin de supporter l'architecture multi-tenant (plusieurs utilisateurs avec sécurité accrue des données). +2. **Système de Monétisation (Features IA)** : + - Mise en place d'un modèle d'abonnement SaaS (Stripe). + - Intégration d'un système de crédit ("AI Credits") pour réguler l'usage des API d'intelligence artificielle (LLMs, Embeddings) de façon soutenable. +3. **Optimisations Scalabilité** : + - Déploiement distribué. + +--- + +## 🛠️ Stack Technique + +- **Framework** : Next.js 15 (App Router, Server Components) +- **Frontend** : React 19, Tailwind CSS, Radix UI primitives +- **Drag & Drop** : `@dnd-kit/core` & `sortable` +- **Base de Données** : Prisma ORM, SQLite en env de développement (bientôt PostgreSQL) +- **Outillage** : Turbopack, TypeScript + +## 💻 Instructions de Développement + +### Installation +```bash +npm install +# ou +yarn install +``` + +### Initialisation de la Base de données +```bash +npx prisma generate +npx prisma db push +``` + +### Migrations sûres (anti-perte de données) +```bash +npm run db:migrate +``` +- Cette commande passe par `scripts/safe-migrate.js`. +- Un backup est créé avant migration (`backups/migrations/`), puis `prisma migrate deploy` est exécuté. +- La migration est interrompue si le backup échoue. + +Commande dev avancée (à utiliser explicitement seulement) : +```bash +npm run db:migrate:dev +``` + +### Lancement du serveur (avec Turbopack) +```bash +npm run dev +``` +Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur. diff --git a/Momento-main/momento/memento-note/app/(main)/page.tsx b/Momento-main/momento/memento-note/app/(main)/page.tsx new file mode 100644 index 0000000..a31c2ae --- /dev/null +++ b/Momento-main/momento/memento-note/app/(main)/page.tsx @@ -0,0 +1,28 @@ +import { getAllNotes } from '@/app/actions/notes' +import { getAISettings } from '@/app/actions/ai-settings' +import { HomeClient } from '@/components/home-client' + +export default async function HomePage() { + const [allNotes, settings] = await Promise.all([ + getAllNotes(), + getAISettings(), + ]) + + const notesViewMode = + settings?.notesViewMode === 'masonry' + ? 'masonry' as const + : settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list' + ? 'tabs' as const + : 'masonry' as const + + return ( + + ) +} diff --git a/Momento-main/momento/memento-note/app/actions/ai-settings.ts b/Momento-main/momento/memento-note/app/actions/ai-settings.ts new file mode 100644 index 0000000..9757eef --- /dev/null +++ b/Momento-main/momento/memento-note/app/actions/ai-settings.ts @@ -0,0 +1,280 @@ +'use server' + +import { auth } from '@/auth' +import { prisma } from '@/lib/prisma' +import { revalidatePath, updateTag } from 'next/cache' + +export type UserAISettingsData = { + titleSuggestions?: boolean + semanticSearch?: boolean + paragraphRefactor?: boolean + memoryEcho?: boolean + memoryEchoFrequency?: 'daily' | 'weekly' | 'custom' + aiProvider?: 'auto' | 'openai' | 'ollama' + preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl' + demoMode?: boolean + showRecentNotes?: boolean + notesViewMode?: 'masonry' | 'tabs' | 'list' + emailNotifications?: boolean + desktopNotifications?: boolean + anonymousAnalytics?: boolean + fontSize?: 'small' | 'medium' | 'large' + languageDetection?: boolean + autoLabeling?: boolean + noteHistory?: boolean +} + +/** 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', + 'languageDetection', + 'autoLabeling', + 'noteHistory', +] as const + +type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number] + +function pickUserAISettingsForDb(input: UserAISettingsData): Partial> { + const out: Partial> = {} + 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 + */ +export async function updateAISettings(settings: UserAISettingsData) { + + const session = await auth() + + + if (!session?.user?.id) { + console.error('[updateAISettings] Unauthorized: No session or user ID') + throw new Error('Unauthorized') + } + + 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 + + // Upsert settings (create if not exists, update if exists) + await prisma.userAISettings.upsert({ + where: { userId: session.user.id }, + create: { + userId: session.user.id, + ...payload, + }, + update: payload, + }) + + revalidatePath('/settings/ai', 'page') + revalidatePath('/settings/appearance', 'page') + revalidatePath('/', 'layout') + updateTag('ai-settings') + + return { success: true } + } catch (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 memento-note, exécutez : npx prisma db push (ou appliquez les migrations Prisma).' + ) + } + throw new Error('Failed to update AI settings') + } +} + +/** + * Get AI settings for the current user (Cached) + */ +import { unstable_cache } from 'next/cache' + +// Internal cached function to fetch settings from DB +const getCachedAISettings = unstable_cache( + async (userId: string) => { + try { + const settings = await prisma.userAISettings.findUnique({ + where: { userId } + }) + + if (!settings) { + return { + titleSuggestions: true, + semanticSearch: true, + paragraphRefactor: true, + memoryEcho: true, + memoryEchoFrequency: 'daily' as const, + aiProvider: 'auto' as const, + preferredLanguage: 'auto' as const, + demoMode: false, + showRecentNotes: false, + notesViewMode: 'masonry' as const, + emailNotifications: false, + desktopNotifications: false, + anonymousAnalytics: false, + theme: 'light' as const, + fontSize: 'medium' as const, + languageDetection: true, + autoLabeling: true, + noteHistory: false, + } + } + + const raw = settings.notesViewMode + const viewMode = + raw === 'masonry' + ? ('masonry' as const) + : raw === 'list' || raw === 'tabs' + ? ('tabs' as const) + : ('masonry' as const) + + return { + titleSuggestions: settings.titleSuggestions, + semanticSearch: settings.semanticSearch, + paragraphRefactor: settings.paragraphRefactor, + memoryEcho: settings.memoryEcho, + memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom', + aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama', + preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl', + demoMode: settings.demoMode, + showRecentNotes: settings.showRecentNotes, + notesViewMode: viewMode, + emailNotifications: settings.emailNotifications, + desktopNotifications: settings.desktopNotifications, + anonymousAnalytics: settings.anonymousAnalytics, + // theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded + fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large', + languageDetection: settings.languageDetection ?? true, + autoLabeling: settings.autoLabeling ?? true, + noteHistory: settings.noteHistory ?? false, + } + } catch (error) { + console.error('Error getting AI settings:', error) + // Return defaults on error + return { + titleSuggestions: true, + semanticSearch: true, + paragraphRefactor: true, + memoryEcho: true, + memoryEchoFrequency: 'daily' as const, + aiProvider: 'auto' as const, + preferredLanguage: 'auto' as const, + demoMode: false, + showRecentNotes: false, + notesViewMode: 'masonry' as const, + emailNotifications: false, + desktopNotifications: false, + anonymousAnalytics: false, + theme: 'light' as const, + fontSize: 'medium' as const, + languageDetection: true, + autoLabeling: true, + noteHistory: false, + } + } + }, + ['user-ai-settings'], + { tags: ['ai-settings'] } +) + +export async function getAISettings(userId?: string) { + let id = userId + + if (!id) { + const session = await auth() + id = session?.user?.id + } + + // Return defaults for non-logged-in users + if (!id) { + return { + titleSuggestions: true, + semanticSearch: true, + paragraphRefactor: true, + memoryEcho: true, + memoryEchoFrequency: 'daily' as const, + aiProvider: 'auto' as const, + preferredLanguage: 'auto' as const, + demoMode: false, + showRecentNotes: false, + notesViewMode: 'masonry' as const, + emailNotifications: false, + desktopNotifications: false, + anonymousAnalytics: false, + theme: 'light' as const, + fontSize: 'medium' as const, + languageDetection: true, + autoLabeling: true, + noteHistory: false, + } + } + + return getCachedAISettings(id) +} + +/** + * Get user's preferred AI provider + */ +export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> { + const settings = await getAISettings() + return settings.aiProvider +} + +/** + * Check if a specific AI feature is enabled for the user + */ +export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Promise { + const settings = await getAISettings() + + switch (feature) { + case 'titleSuggestions': + return settings.titleSuggestions + case 'semanticSearch': + return settings.semanticSearch + case 'paragraphRefactor': + return settings.paragraphRefactor + case 'memoryEcho': + return settings.memoryEcho + case 'noteHistory': + return settings.noteHistory + default: + return true + } +} diff --git a/Momento-main/momento/memento-note/app/actions/notes.ts b/Momento-main/momento/memento-note/app/actions/notes.ts new file mode 100644 index 0000000..000776f --- /dev/null +++ b/Momento-main/momento/memento-note/app/actions/notes.ts @@ -0,0 +1,1952 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import prisma from '@/lib/prisma' +import { Note, CheckItem } from '@/lib/types' +import { auth } from '@/auth' +import { getAIProvider } from '@/lib/ai/factory' +import { parseNote as parseNoteUtil, cosineSimilarity, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils' +import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config' +import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service' +import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup' +import { getAISettings } from '@/app/actions/ai-settings' +import { + createNoteHistorySnapshot, + isNoteHistoryEnabledForUser, + parseNoteHistoryEntry, + shouldCaptureHistorySnapshot, +} from '@/lib/note-history' + + +/** + * Champs sélectionnés pour les listes de notes (sans embedding pour économiser ~6KB/note). + * L'embedding ne charge que pour la recherche sémantique. + */ +const NOTE_LIST_SELECT = { + id: true, + title: true, + content: true, + color: true, + isPinned: true, + isArchived: true, + trashedAt: true, + type: true, + dismissedFromRecent: true, + checkItems: true, + labels: true, + images: true, + links: true, + reminder: true, + isReminderDone: true, + reminderRecurrence: true, + reminderLocation: true, + isMarkdown: true, + size: true, + sharedWith: true, + userId: true, + order: true, + notebookId: true, + createdAt: true, + updatedAt: true, + contentUpdatedAt: true, + autoGenerated: true, + aiProvider: true, + aiConfidence: true, + language: true, + languageConfidence: true, + lastAiAnalysis: true, + // embedding: false — volontairement omis (économise ~6KB JSON/note) +} as const + +// Wrapper for parseNote (embedding validation removed - embeddings are now in NoteEmbedding table) +function parseNote(dbNote: any): Note { + return parseNoteUtil(dbNote) +} + +async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) { + const fallbackEmail = `user-${sessionUser.id}@local.momento` + const safeEmail = sessionUser.email || fallbackEmail + + await prisma.user.upsert({ + where: { id: sessionUser.id }, + update: { + ...(sessionUser.email ? { email: sessionUser.email } : {}), + ...(sessionUser.name !== undefined ? { name: sessionUser.name } : {}), + }, + create: { + id: sessionUser.id, + email: safeEmail, + name: sessionUser.name || null, + }, + }) +} + +// Helper to get hash color for labels (copied from utils) +function getHashColor(name: string): string { + const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray'] + let hash = 0 + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash) + } + return colors[Math.abs(hash) % colors.length] +} + +/** Clé stable (carnet + nom) : les étiquettes sont uniques par (notebookId, name) côté Prisma */ +function labelScopeKey(notebookId: string | null | undefined, rawName: string): string { + const name = rawName.trim().toLowerCase() + if (!name) return '' + const nb = notebookId ?? '' + return `${nb}\u0000${name}` +} + +function collectLabelNamesFromNote(note: { + labels: string | null + labelRelations?: { name: string }[] +}): string[] { + const names: string[] = [] + if (note.labels) { + try { + const parsed: unknown = JSON.parse(note.labels) + if (Array.isArray(parsed)) { + for (const l of parsed) { + if (typeof l === 'string' && l.trim()) names.push(l.trim()) + } + } + } catch (e) { + console.error('[SYNC] Failed to parse labels:', e) + } + } + for (const rel of note.labelRelations ?? []) { + if (rel.name?.trim()) names.push(rel.name.trim()) + } + return names +} + +/** + * Upsert Label rows and return their IDs. + * No orphan cleanup — labels are only deleted via the label management dialog. + */ +async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null): Promise<{ id: string; name: string }[]> { + try { + const nbScope = notebookId ?? null + + if (noteLabels.length > 0) { + // Deduplicate case-insensitively, keep original case + const seen = new Set() + const trimmedNames = noteLabels + .map(name => name?.trim()) + .filter((n): n is string => Boolean(n)) + .filter(n => { + const key = n.toLowerCase() + if (seen.has(key)) return false + seen.add(key) + return true + }) + + for (const name of trimmedNames) { + // Case-insensitive find on PostgreSQL + const existing = await prisma.label.findFirst({ + where: { userId, name: { equals: name, mode: 'insensitive' }, notebookId: nbScope }, + }) + if (!existing) { + await prisma.label.create({ + data: { userId, name, color: getHashColor(name), notebookId: nbScope }, + }) + } + } + + if (trimmedNames.length === 0) return [] + // Search with original case (case-insensitive on PostgreSQL) + return prisma.label.findMany({ + where: { userId, notebookId: nbScope, name: { in: trimmedNames, mode: 'insensitive' } }, + select: { id: true, name: true }, + }) + } + + return [] + } catch (error) { + console.error('Fatal error in syncLabels:', error) + return [] + } +} + +/** Sync both Note.labels (JSON) AND labelRelations for a single note. + * Also cleans up orphan labels in the same notebook scope. */ +async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) { + const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))] + const labelRows = await syncLabels(userId, uniqueNames, notebookId) + const labelIds = labelRows.map(l => l.id) + await prisma.note.update({ + where: { id: noteId }, + data: { + labels: uniqueNames.length > 0 ? JSON.stringify(uniqueNames) : null, + labelRelations: { set: labelIds.map(id => ({ id })) }, + }, + }) + + // Clean up orphan labels: labels in this notebook scope that are no longer + // referenced by any note (neither via JSON nor via labelRelations) + if (notebookId !== null) { + const allLabels = await prisma.label.findMany({ + where: { notebookId, userId }, + select: { id: true, name: true }, + }) + if (allLabels.length > 0) { + const labelIdsSet = new Set(allLabels.map(l => l.id)) + // Find notes in this notebook that have any label relation + const notesWithLabels = await prisma.note.findMany({ + where: { + notebookId, + userId, + labelRelations: { some: { id: { in: Array.from(labelIdsSet) } } }, + }, + select: { labels: true }, + }) + // Collect all label names still in use via JSON + const namesInUse = new Set() + for (const n of notesWithLabels) { + if (n.labels) { + try { + const parsed = JSON.parse(n.labels as string) + if (Array.isArray(parsed)) { + parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase())) + } + } catch {} + } + } + // Delete labels not in use + const orphans = allLabels.filter(l => !namesInUse.has(l.name.toLowerCase())) + if (orphans.length > 0) { + await prisma.label.deleteMany({ + where: { id: { in: orphans.map(l => l.id) } }, + }) + } + } + } +} + +/** Après déplacement via API : rattacher les étiquettes de la note au bon carnet */ +export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId: string | null) { + const session = await auth() + if (!session?.user?.id) return + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { labels: true }, + }) + if (!note) return + let labels: string[] = [] + if (note.labels) { + try { + const raw = JSON.parse(note.labels) as unknown + if (Array.isArray(raw)) { + labels = raw.filter((x): x is string => typeof x === 'string') + } + } catch { + /* ignore */ + } + } + await syncNoteLabels(noteId, labels, newNotebookId, session.user.id) +} + +// Get all notes (non-archived by default) +export async function getNotes(includeArchived = false) { + const session = await auth(); + if (!session?.user?.id) return []; + + try { + const notes = await prisma.note.findMany({ + where: { + userId: session.user.id, + trashedAt: null, + ...(includeArchived ? {} : { isArchived: false }), + }, + select: NOTE_LIST_SELECT, + orderBy: [ + { isPinned: 'desc' }, + { order: 'asc' }, + { updatedAt: 'desc' } + ] + }) + + return notes.map(parseNote) + } catch (error) { + console.error('Error fetching notes:', error) + return [] + } +} + +// Get notes with reminders (upcoming, overdue, done) +export async function getNotesWithReminders() { + const session = await auth(); + if (!session?.user?.id) return []; + + try { + const notes = await prisma.note.findMany({ + where: { + userId: session.user.id, + trashedAt: null, + isArchived: false, + reminder: { not: null } + }, + select: NOTE_LIST_SELECT, + orderBy: { reminder: 'asc' } + }) + + return notes.map(parseNote) + } catch (error) { + console.error('Error fetching notes with reminders:', error) + return [] + } +} + +// Mark a reminder as done / undone +export async function toggleReminderDone(noteId: string, done: boolean) { + const session = await auth(); + if (!session?.user?.id) return { error: 'Unauthorized' } + + try { + await prisma.note.update({ + where: { id: noteId, userId: session.user.id }, + data: { isReminderDone: done } + }) + revalidatePath('/reminders') + return { success: true } + } catch (error) { + console.error('Error toggling reminder done:', error) + return { error: 'Failed to update reminder' } + } +} + +// Get archived notes only +export async function getArchivedNotes() { + const session = await auth(); + if (!session?.user?.id) return []; + + try { + const notes = await prisma.note.findMany({ + where: { + userId: session.user.id, + isArchived: true, + trashedAt: null + }, + select: NOTE_LIST_SELECT, + orderBy: { updatedAt: 'desc' } + }) + + return notes.map(parseNote) + } catch (error) { + console.error('Error fetching archived notes:', error) + return [] + } +} + +export async function getNoteHistory(noteId: string, limit = 30) { + const session = await auth() + if (!session?.user?.id) return [] + + const enabled = await isNoteHistoryEnabledForUser(session.user.id) + if (!enabled) return [] + + const clampedLimit = Math.min(Math.max(limit, 1), 100) + + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { id: true }, + }) + if (!note) return [] + + const entries = await (prisma as any).noteHistory.findMany({ + where: { noteId: note.id, userId: session.user.id }, + orderBy: { createdAt: 'desc' }, + take: clampedLimit, + }) + + return entries.map(parseNoteHistoryEntry) +} + +export async function restoreNoteVersion(noteId: string, historyEntryId: string) { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + const enabled = await isNoteHistoryEnabledForUser(session.user.id) + if (!enabled) throw new Error('History is disabled') + + const [note, historyEntry] = await Promise.all([ + prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { id: true, notebookId: true }, + }), + (prisma as any).noteHistory.findFirst({ + where: { + id: historyEntryId, + noteId, + userId: session.user.id, + }, + }), + ]) + + if (!note || !historyEntry) { + throw new Error('History entry not found') + } + + const restored = await prisma.note.update({ + where: { id: note.id, userId: session.user.id }, + data: { + title: historyEntry.title, + content: historyEntry.content, + color: historyEntry.color, + isPinned: historyEntry.isPinned, + isArchived: historyEntry.isArchived, + type: historyEntry.type, + checkItems: historyEntry.checkItems, + labels: historyEntry.labels, + images: historyEntry.images, + links: historyEntry.links, + isMarkdown: historyEntry.isMarkdown, + size: historyEntry.size, + notebookId: historyEntry.notebookId, + contentUpdatedAt: new Date(), + }, + }) + + try { + await createNoteHistorySnapshot({ + noteId: note.id, + userId: session.user.id, + reason: `restore:v${historyEntry.version}`, + }) + } catch (snapshotError) { + console.error('[HISTORY] Failed to create snapshot after restore:', snapshotError) + } + + revalidatePath('/') + revalidatePath(`/note/${note.id}`) + revalidatePath('/archive') + if (note.notebookId) revalidatePath(`/notebook/${note.notebookId}`) + if (historyEntry.notebookId && historyEntry.notebookId !== note.notebookId) { + revalidatePath(`/notebook/${historyEntry.notebookId}`) + } + + return parseNote(restored) +} + +// Search notes - DB-side filtering (fast) with optional semantic search +// Supports contextual search within notebook (IA5) +export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) { + const session = await auth(); + if (!session?.user?.id) return []; + + try { + // If query empty, return all notes + if (!query || !query.trim()) { + return await getAllNotes(); + } + + // If semantic search is requested, use the full implementation + if (useSemantic) { + return await semanticSearch(query, session.user.id, notebookId); + } + + // DB-side keyword search using LIKE — much faster than loading all notes in memory + const notes = await prisma.note.findMany({ + where: { + userId: session.user.id, + isArchived: false, + trashedAt: null, + OR: [ + { title: { contains: query } }, + { content: { contains: query } }, + { labels: { contains: query } }, + ], + }, + select: NOTE_LIST_SELECT, + orderBy: [ + { isPinned: 'desc' }, + { order: 'asc' }, + { updatedAt: 'desc' } + ] + }); + + return notes.map(parseNote); + } catch (error) { + console.error('Search error:', error); + return []; + } +} + +// Semantic search with AI embeddings - SIMPLE VERSION +// Supports contextual search within notebook (IA5) +async function semanticSearch(query: string, userId: string, notebookId?: string) { + const allNotes = await prisma.note.findMany({ + where: { + userId: userId, + isArchived: false, + trashedAt: null, + ...(notebookId !== undefined ? { notebookId } : {}) + }, + include: { noteEmbedding: true } + }); + + const queryLower = query.toLowerCase().trim(); + + // Get query embedding + let queryEmbedding: number[] | null = null; + try { + const provider = getAIProvider(await getSystemConfig()); + queryEmbedding = await provider.getEmbeddings(query); + } catch (e) { + console.error('Failed to generate query embedding:', e); + // Fallback to simple keyword search + queryEmbedding = null; + } + + // Filter notes: keyword match OR semantic match (threshold 30%) + const results = allNotes.map(note => { + const title = (note.title || '').toLowerCase(); + const content = note.content.toLowerCase(); + const labels = note.labels ? JSON.parse(note.labels) : []; + + // Keyword match + const keywordMatch = title.includes(queryLower) || + content.includes(queryLower) || + labels.some((l: string) => l.toLowerCase().includes(queryLower)); + + // Semantic match (if embedding available) + let semanticMatch = false; + let similarity = 0; + if (queryEmbedding && note.noteEmbedding?.embedding) { + similarity = cosineSimilarity(queryEmbedding, JSON.parse(note.noteEmbedding.embedding)); + semanticMatch = similarity > 0.3; // 30% threshold - works well for related concepts + } + + return { + note, + keywordMatch, + semanticMatch, + similarity + }; + }).filter(r => r.keywordMatch || r.semanticMatch); + + // Parse and add match info + return results.map(r => { + const parsed = parseNote(r.note); + + // Determine match type + let matchType: 'exact' | 'related' | null = null; + if (r.semanticMatch) { + matchType = 'related'; + } else if (r.keywordMatch) { + matchType = 'exact'; + } + + return { + ...parsed, + matchType + }; + }); +} + +// Create a new note +export async function createNote(data: { + title?: string + content: string + color?: string + type?: 'text' | 'checklist' + checkItems?: CheckItem[] + labels?: string[] + images?: string[] + links?: any[] + isArchived?: boolean + reminder?: Date | null + isMarkdown?: boolean + size?: 'small' | 'medium' | 'large' + autoGenerated?: boolean + aiProvider?: string + 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(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + // Defensive guard: after DB reset/migration, auth session can exist while User row is missing. + // Recreate user row to avoid Note_userId_fkey failures. + await ensureSessionUserExists({ + id: session.user.id, + email: session.user.email, + name: session.user.name, + }) + + // Save note to DB immediately (fast!) — AI operations run in background after + const note = await prisma.note.create({ + data: { + userId: session.user.id, + title: data.title || null, + content: data.content, + color: data.color || 'default', + type: data.type || 'text', + checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null, + labels: null, // set by syncNoteLabels below + images: data.images ? JSON.stringify(data.images) : null, + links: data.links ? JSON.stringify(data.links) : null, + isArchived: data.isArchived || false, + reminder: data.reminder || null, + isMarkdown: data.isMarkdown || false, + size: data.size || 'small', + autoGenerated: data.autoGenerated || null, + aiProvider: data.aiProvider || null, + notebookId: data.notebookId || null, + } + }) + + // Sync labels (JSON + labelRelations + Label rows) in one call + if (data.labels && data.labels.length > 0) { + await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id) + } + + try { + const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id) + if (historyEnabled) { + await createNoteHistorySnapshot({ + noteId: note.id, + userId: session.user.id, + reason: 'create', + }) + } + } catch (snapshotError) { + console.error('[HISTORY] Failed to create initial snapshot:', snapshotError) + } + + if (!data.skipRevalidation) { + // Revalidate main page (handles both inbox and notebook views via query params) + revalidatePath('/') + } + + // Fire-and-forget: run AI operations in background without blocking the response + const userId = session.user.id + const noteId = note.id + const content = data.content + const notebookId = data.notebookId + const hasUserLabels = data.labels && data.labels.length > 0 + + // Use setImmediate-like pattern to not block the response + ;(async () => { + try { + // Background task 1: Generate embedding + const bgConfig = await getSystemConfig() + const provider = getAIProvider(bgConfig) + const embedding = await provider.getEmbeddings(content) + if (embedding) { + await prisma.noteEmbedding.upsert({ + where: { noteId: noteId }, + create: { noteId: noteId, embedding: JSON.stringify(embedding) }, + update: { embedding: JSON.stringify(embedding) } + }) + } + } catch (e) { + console.error('[BG] Embedding generation failed:', e) + } + + // Background task 2: Auto-labeling (only if no user labels and has notebook) + if (!hasUserLabels && notebookId) { + try { + const userAISettings = await getAISettings(userId) + const autoLabelingEnabled = userAISettings.autoLabeling !== false + const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70) + + console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId) + + if (autoLabelingEnabled) { + // Detect user's language from their existing notes for localized prompts + let userLang = 'en' + try { + const langResult = await prisma.note.groupBy({ + by: ['language'], + where: { userId, language: { not: null } }, + _count: true, + orderBy: { _count: { language: 'desc' } }, + take: 1, + }) + if (langResult.length > 0 && langResult[0].language) { + userLang = langResult[0].language + } + } catch {} + + const suggestions = await contextualAutoTagService.suggestLabels( + content, + notebookId, + userId, + userLang + ) + + console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label)) + + const appliedLabels = suggestions + .filter(s => s.confidence >= autoLabelingConfidence) + .map(s => s.label) + + if (appliedLabels.length > 0) { + // Merge with existing labels + const existing = await prisma.note.findUnique({ + where: { id: noteId }, + select: { labels: true }, + }) + let existingNames: string[] = [] + if (existing?.labels) { + try { + const parsed = existing.labels as unknown + existingNames = Array.isArray(parsed) + ? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0) + : [] + } catch { existingNames = [] } + } + const merged = [...new Set([...existingNames, ...appliedLabels])] + await syncNoteLabels(noteId, merged, notebookId ?? null, userId) + if (!data.skipRevalidation) { + revalidatePath('/') + } + } + } + } catch (error) { + console.error('[BG] Auto-labeling failed:', error) + } + } else { + console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId) + } + })() + + return parseNote(note) + } catch (error) { + console.error('Error creating note:', error) + throw new Error('Failed to create note') + } +} + +// Update a note +export async function updateNote(id: string, data: { + title?: string | null + content?: string + color?: string + isPinned?: boolean + isArchived?: boolean + type?: 'text' | 'checklist' + checkItems?: CheckItem[] | null + labels?: string[] | null + images?: string[] | null + links?: any[] | null + reminder?: Date | null + isMarkdown?: boolean + size?: 'small' | 'medium' | 'large' + autoGenerated?: boolean | null + aiProvider?: string | null + notebookId?: string | null +}, options?: { skipContentTimestamp?: boolean; skipRevalidation?: boolean }) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + const oldNote = await prisma.note.findUnique({ + where: { id, userId: session.user.id }, + select: { labels: true, notebookId: true, reminder: true } + }) + const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : [] + const oldNotebookId = oldNote?.notebookId + + const updateData: any = { ...data } + + // Reset isReminderDone only when reminder date actually changes (not on every save) + if ('reminder' in data && data.reminder !== null) { + const newTime = new Date(data.reminder as Date).getTime() + const oldTime = oldNote?.reminder ? new Date(oldNote.reminder).getTime() : null + if (newTime !== oldTime) { + updateData.isReminderDone = false + } + } + + // Generate embedding in background — don't block the update + if (data.content !== undefined) { + const noteId = id + const content = data.content + ;(async () => { + try { + const provider = getAIProvider(await getSystemConfig()); + const embedding = await provider.getEmbeddings(content); + if (embedding) { + await prisma.noteEmbedding.upsert({ + where: { noteId: noteId }, + create: { noteId: noteId, embedding: JSON.stringify(embedding) }, + update: { embedding: JSON.stringify(embedding) } + }) + } + } catch (e) { + console.error('[BG] Embedding regeneration failed:', e); + } + })() + } + + if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null + // labels handled by syncNoteLabels below + delete updateData.labels + if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null + if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null + if ('notebookId' in data) updateData.notebookId = data.notebookId + // Explicitly handle size to ensure it propagates + if ('size' in data && data.size) updateData.size = data.size + + // Only update contentUpdatedAt for actual content changes, NOT for property changes + // (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 isContentChange = contentFields.some(field => field in data) + if (isContentChange && !options?.skipContentTimestamp) { + updateData.contentUpdatedAt = new Date() + } + + const note = await prisma.note.update({ + where: { id, userId: session.user.id }, + data: updateData + }) + + // Sync labels (JSON + labelRelations + Label rows) + const notebookMoved = + data.notebookId !== undefined && data.notebookId !== oldNotebookId + if (data.labels !== undefined || notebookMoved) { + const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels + const effectiveNotebookId = + data.notebookId !== undefined ? data.notebookId : oldNotebookId + await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id) + } + + try { + const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id) + if (historyEnabled && shouldCaptureHistorySnapshot(data as Record)) { + await createNoteHistorySnapshot({ + noteId: id, + userId: session.user.id, + reason: 'update', + }) + } + } catch (snapshotError) { + console.error('[HISTORY] Failed to create snapshot after update:', snapshotError) + } + + // Only revalidate for STRUCTURAL changes that affect the page layout/lists + // Content edits (title, content, size, color) use optimistic UI — no refresh needed + const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId'] + const isStructuralChange = structuralFields.some(field => field in data) + + if (isStructuralChange && !options?.skipRevalidation) { + revalidatePath('/') + revalidatePath(`/note/${id}`) + if (data.isArchived !== undefined) { + revalidatePath('/archive') + } + + if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) { + if (oldNotebookId) { + revalidatePath(`/notebook/${oldNotebookId}`) + } + if (data.notebookId) { + revalidatePath(`/notebook/${data.notebookId}`) + } + } + } + + return parseNote(note) + } catch (error) { + console.error('Error updating note:', error) + throw error // Re-throw the REAL error, not a generic one + } +} + +// Soft-delete a note (move to trash) +export async function deleteNote(id: string, options?: { skipRevalidation?: boolean }) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + await prisma.note.update({ + where: { id, userId: session.user.id }, + data: { trashedAt: new Date() } + }) + + if (!options?.skipRevalidation) { + revalidatePath('/') + } + return { success: true } + } catch (error) { + console.error('Error deleting note:', error) + throw new Error('Failed to delete note') + } +} + +// Trash actions +export async function trashNote(id: string, options?: { skipRevalidation?: boolean }) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + await prisma.note.update({ + where: { id, userId: session.user.id }, + data: { trashedAt: new Date() } + }) + if (!options?.skipRevalidation) { + revalidatePath('/') + } + return { success: true } + } catch (error) { + console.error('Error trashing note:', error) + throw new Error('Failed to trash note') + } +} + +export async function restoreNote(id: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + await prisma.note.update({ + where: { id, userId: session.user.id }, + data: { trashedAt: null } + }) + revalidatePath('/') + revalidatePath('/trash') + return { success: true } + } catch (error) { + console.error('Error restoring note:', error) + throw new Error('Failed to restore note') + } +} + +export async function getTrashedNotes() { + const session = await auth(); + if (!session?.user?.id) return []; + + try { + const notes = await prisma.note.findMany({ + where: { + userId: session.user.id, + trashedAt: { not: null } + }, + select: NOTE_LIST_SELECT, + orderBy: { trashedAt: 'desc' } + }) + + return notes.map(parseNote) + } catch (error) { + console.error('Error fetching trashed notes:', error) + return [] + } +} + +export async function permanentDeleteNote(id: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + // Fetch images before deleting so we can clean up files + const note = await prisma.note.findUnique({ + where: { id, userId: session.user.id }, + select: { images: true } + }) + const imageUrls = parseImageUrls(note?.images ?? null) + + await prisma.note.delete({ where: { id, userId: session.user.id } }) + + // Clean up orphaned image files (safe: skips if referenced by other notes) + if (imageUrls.length > 0) { + await cleanupNoteImages(id, imageUrls) + } + + await syncLabels(session.user.id, []) + revalidatePath('/trash') + revalidatePath('/') + return { success: true } + } catch (error) { + console.error('Error permanently deleting note:', error) + throw new Error('Failed to permanently delete note') + } +} + +export async function emptyTrash() { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + // Fetch trashed notes with images before deleting + const trashedNotes = await prisma.note.findMany({ + where: { + userId: session.user.id, + trashedAt: { not: null } + }, + select: { id: true, images: true } + }) + + await prisma.note.deleteMany({ + where: { + userId: session.user.id, + trashedAt: { not: null } + } + }) + + // Clean up image files for all deleted notes + for (const note of trashedNotes) { + const imageUrls = parseImageUrls(note.images) + if (imageUrls.length > 0) { + await cleanupNoteImages(note.id, imageUrls) + } + } + + await syncLabels(session.user.id, []) + revalidatePath('/trash') + revalidatePath('/') + return { success: true } + } catch (error) { + console.error('Error emptying trash:', error) + throw new Error('Failed to empty trash') + } +} + +export async function removeImageFromNote(noteId: string, imageIndex: number) { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + try { + const note = await prisma.note.findUnique({ + where: { id: noteId, userId: session.user.id }, + select: { images: true }, + }) + if (!note) throw new Error('Note not found') + + const imageUrls = parseImageUrls(note.images) + if (imageIndex < 0 || imageIndex >= imageUrls.length) throw new Error('Invalid image index') + + const removedUrl = imageUrls[imageIndex] + const newImages = imageUrls.filter((_, i) => i !== imageIndex) + + await prisma.note.update({ + where: { id: noteId }, + data: { images: newImages.length > 0 ? JSON.stringify(newImages) : null }, + }) + + // Clean up file if no other note references it + await deleteImageFileSafely(removedUrl, noteId) + + return { success: true } + } catch (error) { + console.error('Error removing image:', error) + throw new Error('Failed to remove image') + } +} + +export async function cleanupOrphanedImages(imageUrls: string[], noteId: string) { + const session = await auth() + if (!session?.user?.id) return + + try { + for (const url of imageUrls) { + await deleteImageFileSafely(url, noteId) + } + } catch { + // Silent — best-effort cleanup + } +} + +export async function getTrashCount() { + const session = await auth(); + if (!session?.user?.id) return 0; + + try { + return await prisma.note.count({ + where: { + userId: session.user.id, + trashedAt: { not: null } + } + }) + } catch { + return 0 + } +} + +// Toggle functions +export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) } +export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) } +export async function updateColor(id: string, color: string) { return updateNote(id, { color }) } +export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) } +export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null, aiProvider: null }) } + +// Update note size WITHOUT revalidation - client uses optimistic updates +export async function updateSize(id: string, size: 'small' | 'medium' | 'large') { + + const result = await updateNote(id, { size }) + + return result +} + +// Get all unique labels +export async function getAllLabels() { + try { + const notes = await prisma.note.findMany({ select: { labels: true } }) + const labelsSet = new Set() + notes.forEach((note: any) => { + const labels = note.labels ? JSON.parse(note.labels) : null + if (labels) labels.forEach((label: string) => labelsSet.add(label)) + }) + return Array.from(labelsSet).sort() + } catch (error) { + console.error('Error fetching labels:', error) + return [] + } +} + +// Reorder +export async function reorderNotes(draggedId: string, targetId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + try { + const draggedNote = await prisma.note.findUnique({ where: { id: draggedId, userId: session.user.id } }) + const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } }) + if (!draggedNote || !targetNote) throw new Error('Notes not found') + const allNotes = await prisma.note.findMany({ + where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false, trashedAt: null }, + orderBy: { order: 'asc' } + }) + const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId) + const targetIndex = reorderedNotes.findIndex((n: any) => n.id === targetId) + reorderedNotes.splice(targetIndex, 0, draggedNote) + const updates = reorderedNotes.map((note: any, index: number) => + prisma.note.update({ where: { id: note.id }, data: { order: index } }) + ) + await prisma.$transaction(updates) + revalidatePath('/') + return { success: true } + } catch (error) { + throw new Error('Failed to reorder notes') + } +} + +export async function updateFullOrder(ids: string[]) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + const userId = session.user.id; + try { + const updates = ids.map((id: string, index: number) => + prisma.note.update({ where: { id, userId }, data: { order: index } }) + ) + await prisma.$transaction(updates) + revalidatePath('/') + return { success: true } + } catch (error) { + throw new Error('Failed to update order') + } +} + +// Optimized version for drag & drop - no revalidation to prevent double refresh +export async function updateFullOrderWithoutRevalidation(ids: string[]) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + const userId = session.user.id; + try { + // Verify all notes belong to the user before updating + const notes = await prisma.note.findMany({ + where: { id: { in: ids }, userId }, + select: { id: true }, + }) + const ownedIds = new Set(notes.map(n => n.id)) + const validIds = ids.filter(id => ownedIds.has(id)) + + const updates = validIds.map((id: string, index: number) => + prisma.note.update({ where: { id }, data: { order: index } }) + ) + await prisma.$transaction(updates) + return { success: true } + } catch (error) { + throw new Error('Failed to update order') + } +} + +// Maintenance - Sync all labels and clean up orphans (par carnet, aligné sur syncLabels) +export async function cleanupAllOrphans() { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + const userId = session.user.id; + let createdCount = 0; + let deletedCount = 0; + let errors: any[] = []; + try { + const allNotes = await prisma.note.findMany({ + where: { userId }, + select: { + notebookId: true, + labels: true, + labelRelations: { select: { name: true } }, + }, + }) + + const usedSet = new Set() + for (const note of allNotes) { + for (const name of collectLabelNamesFromNote(note)) { + const key = labelScopeKey(note.notebookId, name) + if (key) usedSet.add(key) + } + } + + let allScoped = await prisma.label.findMany({ + where: { userId }, + select: { id: true, name: true, notebookId: true }, + }) + + const ensuredPairs = new Set() + for (const note of allNotes) { + for (const name of collectLabelNamesFromNote(note)) { + const key = labelScopeKey(note.notebookId, name) + if (!key || ensuredPairs.has(key)) continue + ensuredPairs.add(key) + const trimmed = name.trim() + const nb = note.notebookId ?? null + const exists = allScoped.some( + l => (l.notebookId ?? null) === nb && l.name.toLowerCase() === trimmed.toLowerCase() + ) + if (exists) continue + try { + const created = await prisma.label.create({ + data: { + userId, + name: trimmed, + color: getHashColor(trimmed), + notebookId: nb, + }, + }) + allScoped.push(created) + createdCount++ + } catch (e: any) { + console.error(`Failed to create label:`, e) + errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code }) + allScoped = await prisma.label.findMany({ + where: { userId }, + select: { id: true, name: true, notebookId: true }, + }) + } + } + } + + allScoped = await prisma.label.findMany({ + where: { userId }, + select: { id: true, name: true, notebookId: true }, + }) + for (const label of allScoped) { + const key = labelScopeKey(label.notebookId, label.name) + if (!key || usedSet.has(key)) continue + try { + await prisma.label.update({ + where: { id: label.id }, + data: { notes: { set: [] } }, + }) + await prisma.label.delete({ where: { id: label.id } }) + deletedCount++ + } catch (e: any) { + console.error(`Failed to delete orphan ${label.id}:`, e) + errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code }) + } + } + + revalidatePath('/') + revalidatePath('/settings') + return { + success: true, + created: createdCount, + deleted: deletedCount, + errors, + message: `Created ${createdCount} missing label records, deleted ${deletedCount} orphan labels${errors.length > 0 ? ` (${errors.length} errors)` : ''}` + } + } catch (error) { + console.error('[CLEANUP] Fatal error:', error); + throw new Error('Failed to cleanup database') + } +} + +export async function syncAllEmbeddings() { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + const userId = session.user.id; + let updatedCount = 0; + try { + const notesToSync = await prisma.note.findMany({ + where: { + userId, + trashedAt: null, + noteEmbedding: { is: null } + } + }) + const provider = getAIProvider(await getSystemConfig()); + for (const note of notesToSync) { + if (!note.content) continue; + try { + const embedding = await provider.getEmbeddings(note.content); + if (embedding) { + await prisma.noteEmbedding.upsert({ + where: { noteId: note.id }, + create: { noteId: note.id, embedding: JSON.stringify(embedding) }, + update: { embedding: JSON.stringify(embedding) } + }) + updatedCount++; + } + } catch (e) { } + } + return { success: true, count: updatedCount } + } catch (error: any) { + throw new Error(`Failed to sync embeddings: ${error.message}`) + } +} + +// Get all notes including those shared with the user +export async function getAllNotes(includeArchived = false) { + const session = await auth(); + if (!session?.user?.id) return []; + + const userId = session.user.id; + + try { + // Fetch own notes + shared notes in parallel — no embedding to keep transfer fast + const [ownNotes, acceptedShares] = await Promise.all([ + prisma.note.findMany({ + where: { + userId, + trashedAt: null, + ...(includeArchived ? {} : { isArchived: false }), + }, + select: NOTE_LIST_SELECT, + orderBy: [ + { isPinned: 'desc' }, + { order: 'asc' }, + { updatedAt: 'desc' } + ] + }), + prisma.noteShare.findMany({ + where: { userId, status: 'accepted' }, + include: { note: { select: NOTE_LIST_SELECT } } + }) + ]) + + const sharedNotes = acceptedShares + .map(share => share.note) + .filter(note => includeArchived || !note.isArchived) + .map(note => ({ ...note, _isShared: true })) + + return [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)] + } catch (error) { + console.error('Error fetching notes:', error) + return [] + } +} + +// Get pinned notes only +export async function getPinnedNotes(notebookId?: string) { + const session = await auth(); + if (!session?.user?.id) return []; + + const userId = session.user.id; + + try { + const notes = await prisma.note.findMany({ + where: { + userId: userId, + isPinned: true, + isArchived: false, + trashedAt: null, + ...(notebookId !== undefined ? { notebookId } : {}) + }, + orderBy: [ + { order: 'asc' }, + { updatedAt: 'desc' } + ] + }) + + return notes.map(parseNote) + } catch (error) { + console.error('Error fetching pinned notes:', error) + return [] + } +} + +// Get recent notes (notes modified in the last 7 days) +// Get recent notes (notes modified in the last 7 days) +export async function getRecentNotes(limit: number = 3) { + const session = await auth(); + if (!session?.user?.id) return []; + + const userId = session.user.id; + + try { + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) + sevenDaysAgo.setHours(0, 0, 0, 0) // Set to start of day + + const notes = await prisma.note.findMany({ + where: { + userId: userId, + contentUpdatedAt: { gte: sevenDaysAgo }, + isArchived: false, + trashedAt: null, + dismissedFromRecent: false // Filter out dismissed notes + }, + orderBy: { contentUpdatedAt: 'desc' }, + take: limit + }) + + return notes.map(parseNote) + } catch (error) { + console.error('Error fetching recent notes:', error) + return [] + } +} + +// Dismiss a note from Recent section +export async function dismissFromRecent(id: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + await prisma.note.update({ + where: { id, userId: session.user.id }, + data: { dismissedFromRecent: true } + }) + + // revalidatePath('/') // Removed to prevent immediate refill of the list + return { success: true } + } catch (error) { + console.error('Error dismissing note from recent:', error) + throw new Error('Failed to dismiss note') + } +} + +export async function getNoteById(noteId: string) { + const session = await auth(); + if (!session?.user?.id) return null; + + const userId = session.user.id; + + try { + const note = await prisma.note.findFirst({ + where: { + id: noteId, + OR: [ + { userId: userId }, + { + shares: { + some: { + userId: userId, + status: 'accepted' + } + } + } + ] + } + }) + + if (!note) return null + + return parseNote(note) + } catch (error) { + console.error('Error fetching note:', error) + return null + } +} + +// Add a collaborator to a note (updated to use new share request system) +export async function addCollaborator(noteId: string, userEmail: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + // Use the new share request system + const result = await createShareRequest(noteId, userEmail, 'view'); + + // Get user info for response + const targetUser = await prisma.user.findUnique({ + where: { email: userEmail }, + select: { id: true, name: true, email: true, image: true } + }); + + if (!targetUser) { + throw new Error('User not found') + } + + return { success: true, user: targetUser } + } catch (error: any) { + console.error('Error adding collaborator:', error) + throw error + } +} + +// Remove a collaborator from a note +export async function removeCollaborator(noteId: string, userId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + const note = await prisma.note.findUnique({ + where: { id: noteId } + }) + + if (!note) { + throw new Error('Note not found') + } + + if (note.userId !== session.user.id) { + throw new Error('You can only manage collaborators on your own notes') + } + + // Delete the NoteShare record (cascades to remove access) + await prisma.noteShare.deleteMany({ + where: { + noteId, + userId + } + }) + + // Don't revalidatePath here - it would close all open dialogs! + // The UI will update via optimistic updates in the dialog + return { success: true } + } catch (error: any) { + console.error('Error removing collaborator:', error) + throw new Error(error.message || 'Failed to remove collaborator') + } +} + +// Get collaborators for a note +export async function getNoteCollaborators(noteId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + const note = await prisma.note.findUnique({ + where: { id: noteId }, + select: { userId: true } + }) + + if (!note) { + throw new Error('Note not found') + } + + // Owner can always see collaborators + // Shared users can also see collaborators if they have accepted access + if (note.userId !== session.user.id) { + const share = await prisma.noteShare.findUnique({ + where: { + noteId_userId: { + noteId, + userId: session.user.id + } + } + }) + if (!share || share.status !== 'accepted') { + throw new Error('You do not have access to this note') + } + } + + // Get all users who have been shared this note (any status) + const shares = await prisma.noteShare.findMany({ + where: { + noteId + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true + } + } + } + }) + + return shares.map(share => share.user) + } catch (error: any) { + console.error('Error fetching collaborators:', error) + throw new Error(error.message || 'Failed to fetch collaborators') + } +} + +// Get all users associated with a note (owner + collaborators) +export async function getNoteAllUsers(noteId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + const note = await prisma.note.findUnique({ + where: { id: noteId }, + select: { userId: true } + }) + + if (!note) { + throw new Error('Note not found') + } + + // Check access via NoteShare + const share = await prisma.noteShare.findUnique({ + where: { + noteId_userId: { + noteId, + userId: session.user.id + } + } + }) + + const hasAccess = note.userId === session.user.id || (share && share.status === 'accepted') + + if (!hasAccess) { + throw new Error('You do not have access to this note') + } + + // Get owner + const owner = await prisma.user.findUnique({ + where: { id: note.userId! }, + select: { + id: true, + name: true, + email: true, + image: true + } + }) + + if (!owner) { + throw new Error('Owner not found') + } + + // Get collaborators (accepted shares) + const shares = await prisma.noteShare.findMany({ + where: { + noteId, + status: 'accepted' + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true + } + } + } + }) + + const collaborators = shares.map(share => share.user) + + // Return owner + collaborators + return [owner, ...collaborators] + } catch (error: any) { + console.error('Error fetching note users:', error) + throw new Error(error.message || 'Failed to fetch note users') + } +} + +// ============================================================ +// NEW: Share Request System with Accept/Decline Workflow +// ============================================================ + +// Create a share request (invite user) +export async function createShareRequest(noteId: string, recipientEmail: string, permission: 'view' | 'comment' | 'edit' = 'view') { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + // Find note + const note = await prisma.note.findUnique({ + where: { id: noteId } + }); + + if (!note) { + throw new Error('Note not found'); + } + + // Check ownership + if (note.userId !== session.user.id) { + throw new Error('Only the owner can share notes'); + } + + // Find recipient by email + const recipient = await prisma.user.findUnique({ + where: { email: recipientEmail } + }); + + if (!recipient) { + throw new Error('User not found'); + } + + // Don't share with yourself + if (recipient.id === session.user.id) { + throw new Error('You cannot share with yourself'); + } + + // Check if share already exists + const existingShare = await prisma.noteShare.findUnique({ + where: { + noteId_userId: { + noteId, + userId: recipient.id + } + } + }); + + if (existingShare) { + if (existingShare.status === 'declined') { + // Reactivate declined share + await prisma.noteShare.update({ + where: { id: existingShare.id }, + data: { + status: 'pending', + notifiedAt: new Date(), + permission + } + }); + } else if (existingShare.status === 'removed') { + // Reactivate removed share + await prisma.noteShare.update({ + where: { id: existingShare.id }, + data: { + status: 'pending', + notifiedAt: new Date(), + permission + } + }); + } else { + throw new Error('Note already shared with this user'); + } + } else { + // Create new share request + await prisma.noteShare.create({ + data: { + noteId, + userId: recipient.id, + sharedBy: session.user.id, + status: 'pending', + permission, + notifiedAt: new Date() + } + }); + } + + // Don't revalidatePath here - it would close all open dialogs! + // The UI will update via optimistic updates in the dialog + return { success: true }; + } catch (error: any) { + console.error('Error creating share request:', error); + throw error; + } +} + +// Get pending share requests for current user +export async function getPendingShareRequests() { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + const pendingRequests = await prisma.noteShare.findMany({ + where: { + userId: session.user.id, + status: 'pending' + }, + include: { + note: { + select: { + id: true, + title: true, + content: true, + color: true, + createdAt: true + } + }, + sharer: { + select: { + id: true, + name: true, + email: true, + image: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + + return pendingRequests; + } catch (error: any) { + console.error('Error fetching pending share requests:', error); + throw error; + } +} + +// Respond to share request (accept/decline) +export async function respondToShareRequest(shareId: string, action: 'accept' | 'decline') { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + const share = await prisma.noteShare.findUnique({ + where: { id: shareId }, + include: { + note: true, + sharer: true + } + }); + + if (!share) { + throw new Error('Share request not found'); + } + + // Verify this share belongs to current user + if (share.userId !== session.user.id) { + throw new Error('Unauthorized'); + } + + // Update share status + const updatedShare = await prisma.noteShare.update({ + where: { id: shareId }, + data: { + status: action === 'accept' ? 'accepted' : 'declined', + respondedAt: new Date() + }, + include: { + note: { + select: { + title: true + } + } + } + }); + + // Revalidate all relevant cache tags + revalidatePath('/'); + + return { success: true, share: updatedShare }; + } catch (error: any) { + console.error('Error responding to share request:', error); + throw error; + } +} + +// Get all accepted shares for current user (notes shared with me) +export async function getAcceptedSharedNotes() { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + const acceptedShares = await prisma.noteShare.findMany({ + where: { + userId: session.user.id, + status: 'accepted' + }, + include: { + note: true + }, + orderBy: { + createdAt: 'desc' + } + }); + + return acceptedShares.map(share => share.note); + } catch (error: any) { + console.error('Error fetching accepted shared notes:', error); + throw error; + } +} + +// Remove share from my view (hide the note) +export async function removeSharedNoteFromView(shareId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + const share = await prisma.noteShare.findUnique({ + where: { id: shareId } + }); + + if (!share) { + throw new Error('Share not found'); + } + + // Verify this share belongs to current user + if (share.userId !== session.user.id) { + throw new Error('Unauthorized'); + } + + // Update status to 'removed' (note is hidden from user's view) + await prisma.noteShare.update({ + where: { id: shareId }, + data: { + status: 'removed' + } + }); + + revalidatePath('/'); + return { success: true }; + } catch (error: any) { + console.error('Error removing shared note from view:', error); + throw error; + } +} + +// Get notification count for pending shares +export async function getPendingShareCount() { + const session = await auth(); + if (!session?.user?.id) return 0; + + try { + const count = await prisma.noteShare.count({ + where: { + userId: session.user.id, + status: 'pending' + } + }); + + return count; + } catch (error) { + console.error('Error getting pending share count:', error); + return 0; + } +} + +// Leave a shared note (remove from my list) +export async function leaveSharedNote(noteId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error('Unauthorized'); + + try { + // Find the NoteShare record for this user and note + const share = await prisma.noteShare.findUnique({ + where: { + noteId_userId: { + noteId, + userId: session.user.id + } + } + }); + + if (!share) { + throw new Error('Share not found'); + } + + // Verify this share belongs to current user + if (share.userId !== session.user.id) { + throw new Error('Unauthorized'); + } + + // Update status to 'removed' (note is hidden from user's view) + await prisma.noteShare.update({ + where: { id: share.id }, + data: { + status: 'removed' + } + }); + + revalidatePath('/'); + return { success: true }; + } catch (error: any) { + console.error('Error leaving shared note:', error); + throw error; + } +} diff --git a/Momento-main/momento/memento-note/app/actions/title-suggestions.ts b/Momento-main/momento/memento-note/app/actions/title-suggestions.ts new file mode 100644 index 0000000..41dff0e --- /dev/null +++ b/Momento-main/momento/memento-note/app/actions/title-suggestions.ts @@ -0,0 +1,142 @@ +'use server' + +import { auth } from '@/auth' +import { titleSuggestionService } from '@/lib/ai/services/title-suggestion.service' +import { prisma } from '@/lib/prisma' +import { revalidatePath } from 'next/cache' +import { createNoteHistorySnapshot, isNoteHistoryEnabledForUser } from '@/lib/note-history' + +export interface GenerateTitlesResponse { + suggestions: Array<{ + title: string + confidence: number + reasoning?: string + }> + noteId: string +} + +/** + * Generate title suggestions for a note + * Triggered when note reaches 50+ words without a title + */ +export async function generateTitleSuggestions(noteId: string): Promise { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized') + } + + try { + // Fetch note content + const note = await prisma.note.findUnique({ + where: { id: noteId }, + select: { id: true, content: true, userId: true } + }) + + if (!note) { + throw new Error('Note not found') + } + + if (note.userId !== session.user.id) { + throw new Error('Forbidden') + } + + if (!note.content || note.content.trim().length === 0) { + throw new Error('Note content is empty') + } + + // Generate suggestions + const suggestions = await titleSuggestionService.generateSuggestions(note.content) + + return { + suggestions, + noteId + } + } catch (error) { + console.error('Error generating title suggestions:', error) + throw error + } +} + +/** + * Apply selected title to note + */ +export async function applyTitleSuggestion( + noteId: string, + selectedTitle: string +): Promise { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized') + } + + try { + // Update note with selected title + await prisma.note.update({ + where: { + id: noteId, + userId: session.user.id + }, + data: { + title: selectedTitle, + autoGenerated: true, + lastAiAnalysis: new Date() + } + }) + + try { + const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id) + if (historyEnabled) { + await createNoteHistorySnapshot({ + noteId, + userId: session.user.id, + reason: 'title-suggestion', + }) + } + } catch (snapshotError) { + console.error('[HISTORY] Failed to create snapshot after title suggestion:', snapshotError) + } + + revalidatePath('/') + revalidatePath(`/note/${noteId}`) + } catch (error) { + console.error('Error applying title suggestion:', error) + throw error + } +} + +/** + * Record user feedback on title suggestions + * (Phase 3 - for improving future suggestions) + */ +export async function recordTitleFeedback( + noteId: string, + selectedTitle: string, + allSuggestions: Array<{ title: string; confidence: number }> +): Promise { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized') + } + + try { + // Save to AiFeedback table for learning + await prisma.aiFeedback.create({ + data: { + noteId, + userId: session.user.id, + feedbackType: 'thumbs_up', // User chose one of our suggestions + feature: 'title_suggestion', + originalContent: JSON.stringify(allSuggestions), + correctedContent: selectedTitle, + metadata: JSON.stringify({ + timestamp: new Date().toISOString(), + provider: 'auto' // Will be dynamic based on user settings + }) + } + }) + + } catch (error) { + console.error('Error recording title feedback:', error) + // Don't throw - feedback is optional + } +} diff --git a/Momento-main/momento/memento-note/app/api/notes/[id]/history/route.ts b/Momento-main/momento/memento-note/app/api/notes/[id]/history/route.ts new file mode 100644 index 0000000..84289c4 --- /dev/null +++ b/Momento-main/momento/memento-note/app/api/notes/[id]/history/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import { getNoteHistory, restoreNoteVersion } from '@/app/actions/notes' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { id } = await params + const limitRaw = request.nextUrl.searchParams.get('limit') + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 30 + const entries = await getNoteHistory(id, Number.isNaN(limit) ? 30 : limit) + return NextResponse.json({ success: true, data: entries }) + } catch (error) { + console.error('Error fetching note history:', error) + return NextResponse.json({ success: false, error: 'Failed to fetch history' }, { status: 500 }) + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { id } = await params + const body = await request.json() + const historyEntryId = body?.historyEntryId + + if (!historyEntryId || typeof historyEntryId !== 'string') { + return NextResponse.json({ success: false, error: 'historyEntryId is required' }, { status: 400 }) + } + + const restoredNote = await restoreNoteVersion(id, historyEntryId) + return NextResponse.json({ success: true, data: restoredNote }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to restore history version' + console.error('Error restoring note history:', error) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/Momento-main/momento/memento-note/app/api/notes/[id]/move/route.ts b/Momento-main/momento/memento-note/app/api/notes/[id]/move/route.ts new file mode 100644 index 0000000..8fe7078 --- /dev/null +++ b/Momento-main/momento/memento-note/app/api/notes/[id]/move/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import { auth } from '@/auth' +import { reconcileLabelsAfterNoteMove } from '@/app/actions/notes' +import { createNoteHistorySnapshot, isNoteHistoryEnabledForUser } from '@/lib/note-history' + +// POST /api/notes/[id]/move - Move a note to a notebook (or to Inbox) +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { id } = await params + const body = await request.json() + const { notebookId } = body + + // Get the note + const note = await prisma.note.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + notebookId: true + } + }) + + if (!note) { + return NextResponse.json( + { success: false, error: 'Note not found' }, + { status: 404 } + ) + } + + // Verify ownership + if (note.userId !== session.user.id) { + return NextResponse.json( + { success: false, error: 'Forbidden' }, + { status: 403 } + ) + } + + // If notebookId is provided, verify it exists and belongs to the user + if (notebookId !== null && notebookId !== '') { + const notebook = await prisma.notebook.findUnique({ + where: { id: notebookId }, + select: { userId: true } + }) + + if (!notebook || notebook.userId !== session.user.id) { + return NextResponse.json( + { success: false, error: 'Notebook not found or unauthorized' }, + { status: 403 } + ) + } + } + + // Update the note's notebook + // notebookId = null or "" means move to Inbox (Notes générales) + const targetNotebookId = notebookId && notebookId !== '' ? notebookId : null + + const updatedNote = await prisma.note.update({ + where: { id }, + data: { + notebookId: targetNotebookId + }, + include: { + notebook: { + select: { id: true, name: true } + } + } + }) + + await reconcileLabelsAfterNoteMove(id, targetNotebookId) + + try { + const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id) + if (historyEnabled) { + await createNoteHistorySnapshot({ + noteId: id, + userId: session.user.id, + reason: 'move-notebook', + }) + } + } catch (snapshotError) { + console.error('[HISTORY] Failed to create snapshot after notebook move:', snapshotError) + } + + // No revalidatePath('/') here — the client-side triggerRefresh() in + // notebooks-context.tsx handles the refresh. Avoiding server-side + // revalidation prevents a double-refresh (server + client). + + return NextResponse.json({ + success: true, + data: updatedNote, + message: notebookId && notebookId !== '' + ? `Note moved to "${updatedNote.notebook?.name || 'notebook'}"` + : 'Note moved to Inbox' + }) + } catch (error) { + return NextResponse.json( + { success: false, error: 'Failed to move note' }, + { status: 500 } + ) + } +} diff --git a/Momento-main/momento/memento-note/app/api/notes/[id]/route.ts b/Momento-main/momento/memento-note/app/api/notes/[id]/route.ts new file mode 100644 index 0000000..e8dd49b --- /dev/null +++ b/Momento-main/momento/memento-note/app/api/notes/[id]/route.ts @@ -0,0 +1,218 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/prisma' +import { auth } from '@/auth' +import { parseNote } from '@/lib/utils' +import { + createNoteHistorySnapshot, + isNoteHistoryEnabledForUser, + shouldCaptureHistorySnapshot, +} from '@/lib/note-history' + +// GET /api/notes/[id] - Get a single note +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ) + } + + try { + const { id } = await params + const note = await prisma.note.findUnique({ + where: { id } + }) + + if (!note) { + return NextResponse.json( + { success: false, error: 'Note not found' }, + { status: 404 } + ) + } + + if (note.userId !== session.user.id) { + const share = await prisma.noteShare.findUnique({ + where: { + noteId_userId: { + noteId: note.id, + userId: session.user.id + } + } + }) + if (!share || share.status !== 'accepted') { + return NextResponse.json( + { success: false, error: 'Forbidden' }, + { status: 403 } + ) + } + } + + return NextResponse.json({ + success: true, + data: parseNote(note) + }) + } catch (error) { + console.error('Error fetching note:', error) + return NextResponse.json( + { success: false, error: 'Failed to fetch note' }, + { status: 500 } + ) + } +} + +// PUT /api/notes/[id] - Update a note +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ) + } + + try { + const { id } = await params + + const existingNote = await prisma.note.findUnique({ + where: { id } + }) + + if (!existingNote) { + return NextResponse.json( + { success: false, error: 'Note not found' }, + { status: 404 } + ) + } + + if (existingNote.userId !== session.user.id) { + return NextResponse.json( + { success: false, error: 'Forbidden' }, + { status: 403 } + ) + } + + const body = await request.json() + // Whitelist allowed fields to prevent mass assignment + const allowedFields = ['title', 'content', 'color', 'isPinned', 'isArchived', 'type', 'isMarkdown', 'size', 'notebookId'] + const updateData: Record = {} + for (const key of allowedFields) { + if (key in body) { + updateData[key] = body[key] + } + } + + if ('checkItems' in body) { + updateData.checkItems = body.checkItems ?? null + } + if ('labels' in body) { + updateData.labels = body.labels ?? null + } + + // Only update if data actually changed + const hasChanges = Object.keys(updateData).some((key) => { + const newValue = updateData[key] + const oldValue = (existingNote as any)[key] + // Handle arrays/objects by comparing JSON + if (typeof newValue === 'object' && newValue !== null) { + return JSON.stringify(newValue) !== JSON.stringify(oldValue) + } + return newValue !== oldValue + }) + + // If no changes, return existing note without updating timestamp + if (!hasChanges) { + return NextResponse.json({ + success: true, + data: parseNote(existingNote), + }) + } + + const note = await prisma.note.update({ + where: { id }, + data: updateData, + }) + + try { + const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id) + if (historyEnabled && shouldCaptureHistorySnapshot(updateData)) { + await createNoteHistorySnapshot({ + noteId: id, + userId: session.user.id, + reason: 'api:update', + }) + } + } catch (snapshotError) { + console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError) + } + + return NextResponse.json({ + success: true, + data: parseNote(note) + }) + } catch (error) { + console.error('Error updating note:', error) + return NextResponse.json( + { success: false, error: 'Failed to update note' }, + { status: 500 } + ) + } +} + +// DELETE /api/notes/[id] - Delete a note +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ) + } + + try { + const { id } = await params + + const existingNote = await prisma.note.findUnique({ + where: { id } + }) + + if (!existingNote) { + return NextResponse.json( + { success: false, error: 'Note not found' }, + { status: 404 } + ) + } + + if (existingNote.userId !== session.user.id) { + return NextResponse.json( + { success: false, error: 'Forbidden' }, + { status: 403 } + ) + } + + await prisma.note.update({ + where: { id }, + data: { trashedAt: new Date() } + }) + + return NextResponse.json({ + success: true, + message: 'Note moved to trash' + }) + } catch (error) { + console.error('Error deleting note:', error) + return NextResponse.json( + { success: false, error: 'Failed to delete note' }, + { status: 500 } + ) + } +} diff --git a/Momento-main/momento/memento-note/components/ai/ai-settings-panel.tsx b/Momento-main/momento/memento-note/components/ai/ai-settings-panel.tsx new file mode 100644 index 0000000..d355e0a --- /dev/null +++ b/Momento-main/momento/memento-note/components/ai/ai-settings-panel.tsx @@ -0,0 +1,225 @@ +'use client' + +import { useState } from 'react' +import { Card } from '@/components/ui/card' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { updateAISettings } from '@/app/actions/ai-settings' +import { DemoModeToggle } from '@/components/demo-mode-toggle' +import { toast } from 'sonner' +import { Loader2 } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' + +interface AISettingsPanelProps { + initialSettings: { + titleSuggestions: boolean + semanticSearch: boolean + paragraphRefactor: boolean + memoryEcho: boolean + memoryEchoFrequency: 'daily' | 'weekly' | 'custom' + aiProvider: 'auto' | 'openai' | 'ollama' + preferredLanguage: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl' + demoMode: boolean + languageDetection: boolean + autoLabeling: boolean + noteHistory: boolean + } +} + +export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) { + const [settings, setSettings] = useState(initialSettings) + const [isPending, setIsPending] = useState(false) + const { t } = useLanguage() + + const handleToggle = async (feature: string, value: boolean) => { + // Optimistic update + setSettings(prev => ({ ...prev, [feature]: value })) + + try { + setIsPending(true) + await updateAISettings({ [feature]: value }) + toast.success(t('aiSettings.saved')) + } catch (error) { + console.error('Error updating setting:', error) + toast.error(t('aiSettings.error')) + // Revert on error + setSettings(initialSettings) + } finally { + setIsPending(false) + } + } + + const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => { + setSettings(prev => ({ ...prev, memoryEchoFrequency: value })) + + try { + setIsPending(true) + await updateAISettings({ memoryEchoFrequency: value }) + toast.success(t('aiSettings.saved')) + } catch (error) { + console.error('Error updating frequency:', 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') => { + setSettings(prev => ({ ...prev, preferredLanguage: value })) + + try { + setIsPending(true) + await updateAISettings({ preferredLanguage: value }) + toast.success(t('aiSettings.saved')) + } catch (error) { + console.error('Error updating language:', error) + toast.error(t('aiSettings.error')) + setSettings(initialSettings) + } finally { + setIsPending(false) + } + } + + const handleDemoModeToggle = async (enabled: boolean) => { + setSettings(prev => ({ ...prev, demoMode: enabled })) + + try { + setIsPending(true) + await updateAISettings({ demoMode: enabled }) + } catch (error) { + console.error('Error toggling demo mode:', error) + toast.error(t('aiSettings.error')) + setSettings(initialSettings) + throw error + } finally { + setIsPending(false) + } + } + + return ( +
+ {isPending && ( +
+ + {t('aiSettings.saving')} +
+ )} + + {/* Feature Toggles */} +
+

{t('aiSettings.features')}

+ + handleToggle('titleSuggestions', checked)} + /> + + + handleToggle('paragraphRefactor', checked)} + /> + + handleToggle('memoryEcho', checked)} + /> + + {settings.memoryEcho && ( + + +

+ {t('aiSettings.frequencyDesc')} +

+ +
+ + +
+
+ + +
+
+
+ )} + + {/* Language Detection Toggle */} + handleToggle('languageDetection', checked)} + /> + + {/* Auto Labeling Toggle */} + handleToggle('autoLabeling', checked)} + /> + + handleToggle('noteHistory', checked)} + /> + + {/* Demo Mode Toggle */} + +
+ + +
+ ) +} + +interface FeatureToggleProps { + name: string + description: string + checked: boolean + onChange: (checked: boolean) => void +} + +function FeatureToggle({ name, description, checked, onChange }: FeatureToggleProps) { + return ( + +
+
+ +

{description}

+
+ +
+
+ ) +} diff --git a/Momento-main/momento/memento-note/components/home-client.tsx b/Momento-main/momento/memento-note/components/home-client.tsx new file mode 100644 index 0000000..c5cb93b --- /dev/null +++ b/Momento-main/momento/memento-note/components/home-client.tsx @@ -0,0 +1,475 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import { useSearchParams, useRouter } from 'next/navigation' +import dynamic from 'next/dynamic' +import { Note } from '@/lib/types' +import { updateAISettings } from '@/app/actions/ai-settings' +import { getAllNotes, searchNotes } from '@/app/actions/notes' +import { NoteInput } from '@/components/note-input' +import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section' +import { NotesViewToggle } from '@/components/notes-view-toggle' +import { MemoryEchoNotification } from '@/components/memory-echo-notification' +import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' +import { FavoritesSection } from '@/components/favorites-section' +import { Button } from '@/components/ui/button' +import { Wand2, ChevronRight, Plus, FileText } from 'lucide-react' +import { useLabels } from '@/context/LabelContext' +import { useNoteRefresh } from '@/context/NoteRefreshContext' +import { useReminderCheck } from '@/hooks/use-reminder-check' +import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion' +import { useNotebooks } from '@/context/notebooks-context' +import { getNotebookIcon } from '@/lib/notebook-icon' +import { cn } from '@/lib/utils' +import { LabelFilter } from '@/components/label-filter' +import { useLanguage } from '@/lib/i18n' +import { useHomeView } from '@/context/home-view-context' +import { NoteHistoryModal } from '@/components/note-history-modal' + +// Lazy-load heavy dialogs — uniquement chargés à la demande +const NoteEditor = dynamic( + () => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })), + { ssr: false } +) +const BatchOrganizationDialog = dynamic( + () => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })), + { ssr: false } +) +const AutoLabelSuggestionDialog = dynamic( + () => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })), + { ssr: false } +) + +type InitialSettings = { + showRecentNotes: boolean + notesViewMode: 'masonry' | 'tabs' + noteHistory: boolean +} + +interface HomeClientProps { + initialNotes: Note[] + initialSettings: InitialSettings +} + +export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { + const searchParams = useSearchParams() + const router = useRouter() + const { t } = useLanguage() + + const [notes, setNotes] = useState(initialNotes) + const [pinnedNotes, setPinnedNotes] = useState( + initialNotes.filter(n => n.isPinned) + ) + const [notesViewMode, setNotesViewMode] = useState(initialSettings.notesViewMode) + const [noteHistoryEnabled, setNoteHistoryEnabled] = useState(initialSettings.noteHistory) + const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null) + const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded + const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null) + const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) + const [historyNote, setHistoryNote] = useState(null) + const { refreshKey, triggerRefresh } = useNoteRefresh() + const { labels } = useLabels() + const { setControls } = useHomeView() + + const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion() + const [autoLabelOpen, setAutoLabelOpen] = useState(false) + + useEffect(() => { + if (shouldSuggestLabels && suggestNotebookId) { + setAutoLabelOpen(true) + } + }, [shouldSuggestLabels, suggestNotebookId]) + + const notebookFilter = searchParams.get('notebook') + const isInbox = !notebookFilter + + const handleNoteCreated = useCallback((note: Note) => { + setNotes((prevNotes) => { + const notebookFilter = searchParams.get('notebook') + const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || [] + const colorFilter = searchParams.get('color') + const search = searchParams.get('search')?.trim() || null + + if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes + if (!notebookFilter && note.notebookId) return prevNotes + + if (labelFilter.length > 0) { + const noteLabels = note.labels || [] + if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes + } + + if (colorFilter) { + const labelNamesWithColor = labels + .filter((label: any) => label.color === colorFilter) + .map((label: any) => label.name) + const noteLabels = note.labels || [] + if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes + } + + if (search) { + router.refresh() + return prevNotes + } + + const isPinned = note.isPinned || false + const pinnedNotes = prevNotes.filter(n => n.isPinned) + const unpinnedNotes = prevNotes.filter(n => !n.isPinned) + + if (isPinned) { + return [note, ...pinnedNotes, ...unpinnedNotes] + } else { + return [...pinnedNotes, note, ...unpinnedNotes] + } + }) + + triggerRefresh() + + if (!note.notebookId) { + const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length + if (wordCount >= 20) { + setNotebookSuggestion({ noteId: note.id, content: note.content || '' }) + } + } + }, [searchParams, labels, router, triggerRefresh]) + + const handleOpenNote = (noteId: string) => { + const note = notes.find(n => n.id === noteId) + if (note) setEditingNote({ note, readOnly: false }) + } + + const handleOpenHistory = useCallback((note: Note) => { + setHistoryNote(note) + setHistoryOpen(true) + }, []) + + const handleEnableHistory = useCallback(async () => { + await updateAISettings({ noteHistory: true }) + setNoteHistoryEnabled(true) + }, []) + + const handleHistoryRestored = useCallback((restored: Note) => { + setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n))) + setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n))) + setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev)) + }, []) + + const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => { + setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n)) + setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n)) + }, []) + + useReminderCheck(notes) + + // Listen for global label deletion and immediately update local state + useEffect(() => { + const handler = (e: Event) => { + const { name } = (e as CustomEvent).detail + if (!name) return + const removeLabel = (note: Note) => { + const currentLabels = note.labels || [] + const updated = currentLabels.filter((l) => l.toLowerCase() !== name.toLowerCase()) + if (updated.length === currentLabels.length) return note + return { ...note, labels: updated.length > 0 ? updated : null } + } + setNotes((prev) => prev.map(removeLabel)) + setPinnedNotes((prev) => prev.map(removeLabel)) + } + window.addEventListener('label-deleted', handler) + return () => window.removeEventListener('label-deleted', handler) + }, []) + + const prevRefreshKey = useRef(refreshKey) + + // Rechargement uniquement pour les filtres actifs (search, labels, notebook) + // Les notes initiales suffisent sans filtre + useEffect(() => { + const search = searchParams.get('search')?.trim() || null + const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || [] + const colorFilter = searchParams.get('color') + const notebook = searchParams.get('notebook') + const semanticMode = searchParams.get('semantic') === 'true' + + const isBackgroundRefresh = refreshKey > prevRefreshKey.current + prevRefreshKey.current = refreshKey + + // Pour le refreshKey (mutations), toujours recharger + // Pour les filtres, charger depuis le serveur + const hasActiveFilter = search || labelFilter.length > 0 || colorFilter + + const load = async () => { + if (!isBackgroundRefresh) { + setIsLoading(true) + } + let allNotes = search + ? await searchNotes(search, semanticMode, notebook || undefined) + : await getAllNotes() + + // Filtre notebook côté client + // Shared notes appear ONLY in inbox (general notes), not in notebooks + if (notebook) { + allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared) + } else { + allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared) + } + + // Filtre labels + if (labelFilter.length > 0) { + allNotes = allNotes.filter((note: any) => + note.labels?.some((label: string) => labelFilter.includes(label)) + ) + } + + // Filtre couleur + if (colorFilter) { + const labelNamesWithColor = labels + .filter((label: any) => label.color === colorFilter) + .map((label: any) => label.name) + allNotes = allNotes.filter((note: any) => + note.labels?.some((label: string) => labelNamesWithColor.includes(label)) + ) + } + + // Merger avec les tailles locales pour ne pas écraser les modifications + setNotes(prev => { + const localSizeMap = new Map(prev.map(n => [n.id, n.size])) + return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })) + }) + setPinnedNotes(allNotes.filter((n: any) => n.isPinned)) + setIsLoading(false) + } + + // Éviter le rechargement initial si les notes sont déjà chargées sans filtres + if (refreshKey > 0 || hasActiveFilter) { + const cancelled = { value: false } + load().then(() => { if (cancelled.value) return }) + return () => { cancelled.value = true } + } else { + // Données initiales : filtrage inbox/notebook côté client seulement + let filtered = initialNotes + if (notebook) { + filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared) + } else { + filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared) + } + // Merger avec les tailles déjà modifiées localement + setNotes(prev => { + const localSizeMap = new Map(prev.map(n => [n.id, n.size])) + return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })) + }) + setPinnedNotes(filtered.filter(n => n.isPinned)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, refreshKey]) + + const { notebooks } = useNotebooks() + const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook')) + + useEffect(() => { + setControls({ + isTabsMode: notesViewMode === 'tabs', + openNoteComposer: () => {}, + }) + return () => setControls(null) + }, [notesViewMode, setControls]) + + const handleNoteCreatedWrapper = (note: any) => { + handleNoteCreated(note) + } + + const Breadcrumbs = ({ notebookName }: { notebookName: string }) => ( +
+ {t('nav.notebooks')} + + {notebookName} +
+ ) + + const isTabs = notesViewMode === 'tabs' + + return ( +
+ {/* Notebook Specific Header */} + {currentNotebook ? ( +
+ +
+
+
+ {(() => { + const Icon = getNotebookIcon(currentNotebook.icon || 'folder') + return ( + + ) + })()} +
+

{currentNotebook.name}

+
+
+ + { + const params = new URLSearchParams(searchParams.toString()) + if (newLabels.length > 0) params.set('labels', newLabels.join(',')) + else params.delete('labels') + router.push(`/?${params.toString()}`) + }} + className="border-gray-200" + /> +
+
+
+ ) : ( +
+ {!isTabs &&
} +
+
+
+ +
+

{t('notes.title')}

+
+
+ + { + const params = new URLSearchParams(searchParams.toString()) + if (newLabels.length > 0) params.set('labels', newLabels.join(',')) + else params.delete('labels') + router.push(`/?${params.toString()}`) + }} + className="border-gray-200" + /> + {isInbox && !isLoading && notes.length >= 2 && ( + + )} + +
+
+
+ )} + + {!isTabs && ( +
+ +
+ )} + + {isLoading ? ( +
{t('general.loading')}
+ ) : ( + <> + setEditingNote({ note, readOnly })} + onSizeChange={handleSizeChange} + /> + + {(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && ( +
+ !note.isPinned)} + onEdit={(note, readOnly) => setEditingNote({ note, readOnly })} + onSizeChange={handleSizeChange} + currentNotebookId={searchParams.get('notebook')} + noteHistoryEnabled={noteHistoryEnabled} + onOpenHistory={handleOpenHistory} + onEnableHistory={handleEnableHistory} + /> +
+ )} + + {notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && !isTabs && ( +
+ {t('notes.emptyState')} +
+ )} + + )} + + + + {notebookSuggestion && ( + setNotebookSuggestion(null)} + /> + )} + + {batchOrganizationOpen && ( + router.refresh()} + /> + )} + + {autoLabelOpen && ( + { + setAutoLabelOpen(open) + if (!open) dismissLabelSuggestion() + }} + notebookId={suggestNotebookId} + onLabelsCreated={() => router.refresh()} + /> + )} + + {editingNote && ( + setEditingNote(null)} + /> + )} + + +
+ ) +} diff --git a/Momento-main/momento/memento-note/components/masonry-grid.tsx b/Momento-main/momento/memento-note/components/masonry-grid.tsx new file mode 100644 index 0000000..22102ba --- /dev/null +++ b/Momento-main/momento/memento-note/components/masonry-grid.tsx @@ -0,0 +1,338 @@ +'use client' + +import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Note } from '@/lib/types'; +import { NoteCard } from './note-card'; +import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes'; +import { useNotebookDrag } from '@/context/notebook-drag-context'; +import { useLanguage } from '@/lib/i18n'; +import { useCardSizeMode } from '@/hooks/use-card-size-mode'; +import dynamic from 'next/dynamic'; +import './masonry-grid.css'; + +// Lazy-load NoteEditor — uniquement chargé au clic +const NoteEditor = dynamic( + () => import('./note-editor').then(m => ({ default: m.NoteEditor })), + { ssr: false } +); + +interface MasonryGridProps { + notes: Note[]; + onEdit?: (note: Note, readOnly?: boolean) => void; + onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void; + isTrashView?: boolean; + noteHistoryEnabled?: boolean; + onOpenHistory?: (note: Note) => void; +} + +// ───────────────────────────────────────────── +// Sortable Note Item +// ───────────────────────────────────────────── +interface SortableNoteProps { + note: Note; + onEdit: (note: Note, readOnly?: boolean) => void; + onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void; + onDragStartNote?: (noteId: string) => void; + onDragEndNote?: () => void; + isDragging?: boolean; + isOverlay?: boolean; + isTrashView?: boolean; + noteHistoryEnabled?: boolean; + onOpenHistory?: (note: Note) => void; +} + +const SortableNoteItem = memo(function SortableNoteItem({ + note, + onEdit, + onSizeChange, + onDragStartNote, + onDragEndNote, + isDragging, + isOverlay, + isTrashView, + noteHistoryEnabled, + onOpenHistory, +}: SortableNoteProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging: isSortableDragging, + } = useSortable({ id: note.id }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isSortableDragging && !isOverlay ? 0.3 : 1, + }; + + return ( +
+ onSizeChange(note.id, newSize)} + noteHistoryEnabled={noteHistoryEnabled} + onOpenHistory={onOpenHistory} + /> +
+ ); +}) + +// ───────────────────────────────────────────── +// Sortable Grid Section (pinned or others) +// ───────────────────────────────────────────── +interface SortableGridSectionProps { + notes: Note[]; + onEdit: (note: Note, readOnly?: boolean) => void; + onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void; + draggedNoteId: string | null; + onDragStartNote: (noteId: string) => void; + onDragEndNote: () => void; + isTrashView?: boolean; + noteHistoryEnabled?: boolean; + onOpenHistory?: (note: Note) => void; +} + +const SortableGridSection = memo(function SortableGridSection({ + notes, + onEdit, + onSizeChange, + draggedNoteId, + onDragStartNote, + onDragEndNote, + isTrashView, + noteHistoryEnabled, + onOpenHistory, +}: SortableGridSectionProps) { + const ids = useMemo(() => notes.map(n => n.id), [notes]); + + return ( + +
+ {notes.map(note => ( + + ))} +
+
+ ); +}); + +// ───────────────────────────────────────────── +// Main MasonryGrid component +// ───────────────────────────────────────────── +export function MasonryGrid({ + notes, + onEdit, + onSizeChange, + isTrashView, + noteHistoryEnabled = false, + onOpenHistory, +}: MasonryGridProps) { + const { t } = useLanguage(); + const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null); + const { startDrag, endDrag, draggedNoteId } = useNotebookDrag(); + const cardSizeMode = useCardSizeMode(); + const isUniformMode = cardSizeMode === 'uniform'; + + // Local notes state for optimistic size/order updates + const [localNotes, setLocalNotes] = useState(notes); + + useEffect(() => { + setLocalNotes(prev => { + const prevIds = prev.map(n => n.id).join(',') + const incomingIds = notes.map(n => n.id).join(',') + if (prevIds === incomingIds) { + const localSizeMap = new Map(prev.map(n => [n.id, n.size])) + return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })) + } + // Notes added/removed: full sync but preserve local sizes + const localSizeMap = new Map(prev.map(n => [n.id, n.size])) + return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })) + }) + }, [notes]); + + const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]); + const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]); + + const [activeId, setActiveId] = useState(null); + const activeNote = useMemo( + () => localNotes.find(n => n.id === activeId) ?? null, + [localNotes, activeId] + ); + + const handleEdit = useCallback((note: Note, readOnly?: boolean) => { + if (onEdit) { + onEdit(note, readOnly); + } else { + setEditingNote({ note, readOnly }); + } + }, [onEdit]); + + const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => { + setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n)); + onSizeChange?.(noteId, newSize); + }, [onSizeChange]); + + // @dnd-kit sensors — pointer (desktop) + touch (mobile) + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, // Évite les activations accidentelles + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile + }) + ); + + const localNotesRef = useRef(localNotes) + useEffect(() => { + localNotesRef.current = localNotes + }, [localNotes]) + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(event.active.id as string); + startDrag(event.active.id as string); + }, [startDrag]); + + const handleDragEnd = useCallback(async (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + endDrag(); + + if (!over || active.id === over.id) return; + + const reordered = arrayMove( + localNotesRef.current, + localNotesRef.current.findIndex(n => n.id === active.id), + localNotesRef.current.findIndex(n => n.id === over.id), + ); + + if (reordered.length === 0) return; + + setLocalNotes(reordered); + // Persist order outside of setState to avoid "setState in render" warning + const ids = reordered.map(n => n.id); + updateFullOrderWithoutRevalidation(ids).catch(err => { + console.error('Failed to persist order:', err); + }); + }, [endDrag]); + + return ( + +
+ {pinnedNotes.length > 0 && ( +
+

+ {t('notes.pinned')} +

+ +
+ )} + + {othersNotes.length > 0 && ( +
+ {pinnedNotes.length > 0 && ( +

+ {t('notes.others')} +

+ )} + +
+ )} +
+ + {/* DragOverlay — montre une copie flottante pendant le drag */} + + {activeNote ? ( +
+ handleSizeChange(activeNote.id, newSize)} + noteHistoryEnabled={noteHistoryEnabled} + onOpenHistory={onOpenHistory} + /> +
+ ) : null} +
+ + {editingNote && ( + setEditingNote(null)} + /> + )} +
+ ); +} diff --git a/Momento-main/momento/memento-note/components/note-actions.tsx b/Momento-main/momento/memento-note/components/note-actions.tsx new file mode 100644 index 0000000..c39f492 --- /dev/null +++ b/Momento-main/momento/memento-note/components/note-actions.tsx @@ -0,0 +1,243 @@ +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Archive, + ArchiveRestore, + MoreVertical, + Palette, + Pin, + Users, + Maximize2, + FileText, + Trash2, + RotateCcw, + History, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { NOTE_COLORS } from "@/lib/types" +import { useLanguage } from "@/lib/i18n" + +interface NoteActionsProps { + isPinned: boolean + isArchived: boolean + currentColor: string + currentSize?: 'small' | 'medium' | 'large' + onTogglePin: () => void + onToggleArchive: () => void + onColorChange: (color: string) => void + onSizeChange?: (size: 'small' | 'medium' | 'large') => void + onDelete: () => void + onShareCollaborators?: () => void + isMarkdown?: boolean + onToggleMarkdown?: () => void + isTrashView?: boolean + onRestore?: () => void + onPermanentDelete?: () => void + onOpenHistory?: () => void + historyEnabled?: boolean + className?: string +} + +export function NoteActions({ + isPinned, + isArchived, + currentColor, + currentSize = 'small', + onTogglePin, + onToggleArchive, + onColorChange, + onSizeChange, + onDelete, + onShareCollaborators, + isMarkdown = false, + onToggleMarkdown, + isTrashView, + onRestore, + onPermanentDelete, + onOpenHistory, + historyEnabled = false, + className +}: NoteActionsProps) { + const { t } = useLanguage() + + // Trash view: show only Restore and Permanent Delete + if (isTrashView) { + return ( +
e.stopPropagation()} + > + {/* Restore Button */} + + + {/* Permanent Delete Button */} + +
+ ) + } + + return ( +
e.stopPropagation()} + > + {/* Color Palette */} + + + + + +
+ {Object.entries(NOTE_COLORS).map(([colorName, classes]) => ( +
+
+
+ + {/* Markdown Toggle */} + {onToggleMarkdown && ( + + )} + + {/* More Options */} + + + + + + {/* Pin/Unpin Option */} + + {isPinned ? ( + <> + + {t('notes.unpin')} + + ) : ( + <> + + {t('notes.pin')} + + )} + + + + {isArchived ? ( + <> + + {t('notes.unarchive')} + + ) : ( + <> + + {t('notes.archive')} + + )} + + + {onOpenHistory && ( + + + {historyEnabled + ? (t('notes.history') || 'Historique') + : (t('notes.enableHistory') || "Activer l'historique")} + + )} + + {/* Size Selector */} + {onSizeChange && ( + <> + +
+ {t('notes.size')} +
+ {(['small', 'medium', 'large'] as const).map((size) => ( + { + onSizeChange?.(size); + }} + className={cn( + "capitalize", + currentSize === size && "bg-accent" + )} + > + + {t(`notes.${size}` as const)} + + ))} + + )} + + {/* Collaborators */} + {onShareCollaborators && ( + <> + + { + e.stopPropagation() + onShareCollaborators() + }} + > + + {t('notes.shareWithCollaborators')} + + + )} + + + + + {t('notes.delete')} + +
+
+
+ ) +} diff --git a/Momento-main/momento/memento-note/components/note-card.tsx b/Momento-main/momento/memento-note/components/note-card.tsx new file mode 100644 index 0000000..9359712 --- /dev/null +++ b/Momento-main/momento/memento-note/components/note-card.tsx @@ -0,0 +1,782 @@ +'use client' + +import { Note, NOTE_COLORS, NoteColor } from '@/lib/types' +import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react' +import { useState, useEffect, useTransition, useOptimistic, memo } from 'react' +import { useSession } from 'next-auth/react' +import { useRouter, useSearchParams } from 'next/navigation' +import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote, restoreNote, permanentDeleteNote } from '@/app/actions/notes' +import { cn } from '@/lib/utils' +import { formatDistanceToNow, Locale } from 'date-fns' +import { enUS } from 'date-fns/locale/en-US' +import { fr } from 'date-fns/locale/fr' +import { es } from 'date-fns/locale/es' +import { de } from 'date-fns/locale/de' +import { faIR } from 'date-fns/locale/fa-IR' +import { it } from 'date-fns/locale/it' +import { pt } from 'date-fns/locale/pt' +import { ru } from 'date-fns/locale/ru' +import { zhCN } from 'date-fns/locale/zh-CN' +import { ja } from 'date-fns/locale/ja' +import { ko } from 'date-fns/locale/ko' +import { ar } from 'date-fns/locale/ar' +import { hi } from 'date-fns/locale/hi' +import { nl } from 'date-fns/locale/nl' +import { pl } from 'date-fns/locale/pl' +import { MarkdownContent } from './markdown-content' +import { LabelBadge } from './label-badge' +import { NoteImages } from './note-images' +import { NoteChecklist } from './note-checklist' +import { NoteActions } from './note-actions' +import { CollaboratorDialog } from './collaborator-dialog' +import { CollaboratorAvatars } from './collaborator-avatars' +import { ConnectionsBadge } from './connections-badge' +import { ConnectionsOverlay } from './connections-overlay' +import { ComparisonModal } from './comparison-modal' +import { FusionModal } from './fusion-modal' +import { useConnectionsCompare } from '@/hooks/use-connections-compare' +import { useLabels } from '@/context/LabelContext' +import { useNoteRefresh } from '@/context/NoteRefreshContext' +import { useLanguage } from '@/lib/i18n' +import { useNotebooks } from '@/context/notebooks-context' +import { toast } from 'sonner' + +// Mapping of supported languages to date-fns locales +const localeMap: Record = { + en: enUS, + fr: fr, + es: es, + de: de, + fa: faIR, + it: it, + pt: pt, + ru: ru, + zh: zhCN, + ja: ja, + ko: ko, + ar: ar, + hi: hi, + nl: nl, + pl: pl, +} + +function getDateLocale(language: string): Locale { + return localeMap[language] || enUS +} + +// Map icon names to lucide-react components +const ICON_MAP: Record = { + 'folder': Folder, + 'briefcase': Briefcase, + 'document': FileText, + 'lightning': Zap, + 'chart': BarChart3, + 'globe': Globe, + 'sparkle': Sparkles, + 'book': Book, + 'heart': Heart, + 'crown': Crown, + 'music': Music, + 'building': Building2, +} + +// Function to get icon component by name +function getNotebookIcon(iconName: string): LucideIcon { + const IconComponent = ICON_MAP[iconName] || Folder + return IconComponent +} + +interface NoteCardProps { + note: Note + onEdit?: (note: Note, readOnly?: boolean) => void + isDragging?: boolean + isDragOver?: boolean + onDragStart?: (noteId: string) => void + onDragEnd?: () => void + onResize?: () => void + onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void + isTrashView?: boolean + noteHistoryEnabled?: boolean + onOpenHistory?: (note: Note) => void +} + +// Helper function to get initials from name +function getInitials(name: string): string { + if (!name) return '??' + const trimmedName = name.trim() + const parts = trimmedName.split(' ') + if (parts.length === 1) { + return trimmedName.substring(0, 2).toUpperCase() + } + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} + +// Helper function to get avatar color based on name hash +function getAvatarColor(name: string): string { + const colors = [ + 'bg-primary', + 'bg-purple-600', + 'bg-emerald-600', + 'bg-amber-600', + 'bg-pink-600', + 'bg-teal-600', + 'bg-blue-600', + 'bg-indigo-600', + ] + + const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + return colors[hash % colors.length] +} + +export const NoteCard = memo(function NoteCard({ + note, + onEdit, + onDragStart, + onDragEnd, + isDragging, + onResize, + onSizeChange, + isTrashView, + noteHistoryEnabled = false, + onOpenHistory, +}: NoteCardProps) { + const router = useRouter() + const searchParams = useSearchParams() + const { refreshLabels } = useLabels() + const { triggerRefresh } = useNoteRefresh() + const { data: session } = useSession() + const { t, language } = useLanguage() + const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks() + const [, startTransition] = useTransition() + const [isDeleting, setIsDeleting] = useState(false) + const [isHidden, setIsHidden] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false) + const [collaborators, setCollaborators] = useState([]) + const [owner, setOwner] = useState(null) + const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false) + const [comparisonNotes, setComparisonNotes] = useState(null) + const [fusionNotes, setFusionNotes] = useState>>([]) + const [showNotebookMenu, setShowNotebookMenu] = useState(false) + + // Move note to a notebook + const handleMoveToNotebook = async (notebookId: string | null) => { + await moveNoteToNotebookOptimistic(note.id, notebookId) + setShowNotebookMenu(false) + // No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic + } + + // Optimistic UI state for instant feedback + const [optimisticNote, addOptimisticNote] = useOptimistic( + note, + (state, newProps: Partial) => ({ ...state, ...newProps }) + ) + + // Local color state so color persists after transition ends + const [localColor, setLocalColor] = useState(note.color) + + const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default + + // Check if this note is currently open in the editor + const isNoteOpenInEditor = searchParams.get('note') === note.id + + // Only fetch comparison notes when we have IDs to compare + const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare( + comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null + ) + + const currentUserId = session?.user?.id + const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId + const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId + const isOwner = currentUserId && note.userId && currentUserId === note.userId + + // Load collaborators only for shared notes (not owned by current user) + useEffect(() => { + // Skip API call for notes owned by current user — no need to fetch collaborators + if (!isSharedNote) { + // For own notes, set owner to current user + if (currentUserId && session?.user) { + setOwner({ + id: currentUserId, + name: session.user.name, + email: session.user.email, + image: session.user.image, + }) + } + return + } + + let isMounted = true + + const loadCollaborators = async () => { + if (note.userId && isMounted) { + try { + const users = await getNoteAllUsers(note.id) + if (isMounted) { + setCollaborators(users) + if (users.length > 0) { + setOwner(users[0]) + } + } + } catch (error) { + console.error('Failed to load collaborators:', error) + if (isMounted) { + setCollaborators([]) + } + } + } + } + + loadCollaborators() + + return () => { + isMounted = false + } + }, [note.id, note.userId, isSharedNote, currentUserId, session?.user]) + + const handleDelete = async () => { + setIsDeleting(true) + setIsHidden(true) // masquage immédiat + try { + await deleteNote(note.id) + await refreshLabels() + triggerRefresh() // met à jour la liste et le compteur du carnet + } catch (error) { + console.error('Failed to delete note:', error) + setIsHidden(false) + setIsDeleting(false) + } + } + + const handleRestore = async () => { + setIsDeleting(true) + setIsHidden(true) + try { + await restoreNote(note.id) + triggerRefresh() + toast.success(t('trash.noteRestored')) + } catch (error) { + console.error('Failed to restore note:', error) + setIsHidden(false) + setIsDeleting(false) + } + } + + const handlePermanentDelete = async () => { + setIsDeleting(true) + setIsHidden(true) + try { + await permanentDeleteNote(note.id) + triggerRefresh() + toast.success(t('trash.notePermanentlyDeleted')) + } catch (error) { + console.error('Failed to permanently delete note:', error) + setIsHidden(false) + setIsDeleting(false) + } + } + + const handleTogglePin = async () => { + startTransition(async () => { + addOptimisticNote({ isPinned: !note.isPinned }) + await togglePin(note.id, !note.isPinned) + + if (!note.isPinned) { + toast.success(t('notes.pinned') || 'Note pinned') + } else { + toast.info(t('notes.unpinned') || 'Note unpinned') + } + }) + } + + const handleToggleArchive = async () => { + startTransition(async () => { + addOptimisticNote({ isArchived: !note.isArchived }) + await toggleArchive(note.id, !note.isArchived) + }) + } + + const handleColorChange = async (color: string) => { + setLocalColor(color) // instant visual update, survives transition + startTransition(async () => { + addOptimisticNote({ color }) + await updateNote(note.id, { color }, { skipRevalidation: false }) + }) + } + + const handleSizeChange = async (size: 'small' | 'medium' | 'large') => { + startTransition(async () => { + // Instant visual feedback for the card itself + addOptimisticNote({ size }) + + // Notify parent so it can update its local state + onSizeChange?.(size) + + // Trigger layout refresh + onResize?.() + setTimeout(() => onResize?.(), 300) + + // Update server in background + + try { + await updateSize(note.id, size); + } catch (error) { + console.error('Failed to update note size:', error); + } + }) + } + + const handleCheckItem = async (checkItemId: string) => { + if (note.type === 'checklist' && Array.isArray(note.checkItems)) { + const updatedItems = note.checkItems.map(item => + item.id === checkItemId ? { ...item, checked: !item.checked } : item + ) + startTransition(async () => { + addOptimisticNote({ checkItems: updatedItems }) + await updateNote(note.id, { checkItems: updatedItems }) + // No router.refresh() — optimistic update is sufficient and avoids grid rebuild + }) + } + } + + const handleLeaveShare = async () => { + if (confirm(t('notes.confirmLeaveShare'))) { + try { + await leaveSharedNote(note.id) + setIsDeleting(true) // Hide the note from view + } catch (error) { + console.error('Failed to leave share:', error) + } + } + } + + const handleRemoveFusedBadge = async (e: React.MouseEvent) => { + e.stopPropagation() // Prevent opening the note editor + startTransition(async () => { + addOptimisticNote({ autoGenerated: null }) + await removeFusedBadge(note.id) + // No router.refresh() — optimistic update is sufficient and avoids grid rebuild + }) + } + + if (isDeleting) return null + + const getMinHeight = (size?: string) => { + switch (size) { + case 'medium': return '350px' + case 'large': return '500px' + default: return '150px' // small + } + } + + + + if (isHidden) return null + + return ( + { + e.dataTransfer.setData('text/plain', note.id) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers + onDragStart?.(note.id) + }} + onDragEnd={() => onDragEnd?.()} + className={cn( + 'note-card group relative rounded-lg overflow-hidden p-6 border-transparent shadow-sm', + 'transition-all duration-200 ease-out', + 'hover:shadow-md hover:border-border/50 hover:-translate-y-0.5', + colorClasses.bg, + colorClasses.card, + colorClasses.hover, + isDragging && 'shadow-lg' + )} + onClick={(e) => { + // Only trigger edit if not clicking on buttons + const target = e.target as HTMLElement + if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) { + // For shared notes, pass readOnly flag + onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean) + } + }} + > + {/* Drag Handle - Only visible on mobile/touch devices */} +
+ +
+ + {/* Move to Notebook Dropdown Menu */} +
e.stopPropagation()} className="absolute top-2 right-2 z-20"> + + + + + +
+ {t('notebookSuggestion.moveToNotebook')} +
+ handleMoveToNotebook(null)}> + + {t('notebookSuggestion.generalNotes')} + + {notebooks.map((notebook: any) => { + const NotebookIcon = getNotebookIcon(notebook.icon || 'folder') + return ( + handleMoveToNotebook(notebook.id)} + > + + {notebook.name} + + ) + })} +
+
+
+ + {/* Pin Button - Visible on hover or if pinned */} + + + + + {/* Reminder Icon - Move slightly if pin button is there */} + {note.reminder && new Date(note.reminder) > new Date() && ( + + )} + + {/* Fusion Badge */} + {optimisticNote.aiProvider === 'fusion' && ( +
+ + {t('memoryEcho.fused')} + +
+ )} + + {/* Title */} + {optimisticNote.title && ( +

+ {optimisticNote.title} +

+ )} + + {/* Search Match Type Badge */} + {optimisticNote.matchType && ( + + {t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)} + + )} + + {/* Shared badge */} + {isSharedNote && owner && ( +
+ + {t('notes.sharedBy')} {owner.name || owner.email} + + +
+ )} + + {/* Images Component */} + + + {/* Link Previews */} + {Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && ( +
+ {optimisticNote.links.map((link, idx) => ( + e.stopPropagation()} + > + {link.imageUrl && ( + + )} + + {/* Content */} + {optimisticNote.type === 'text' ? ( +
+ +
+ ) : ( + + )} + + {/* Labels - using shared LabelBadge component */} + {optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && ( +
+ {optimisticNote.labels.map((label) => ( + + ))} +
+ )} + + {/* Footer with Date only */} +
+ {/* Creation Date */} +
+ {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })} +
+
+ + {/* Owner Avatar - Aligned with action buttons at bottom */} + {owner && ( +
+ {getInitials(owner.name || owner.email || '??')} +
+ )} + + {/* Action Bar Component - Always show for now to fix regression */} + {true && ( + setShowDeleteDialog(true)} + onShareCollaborators={() => setShowCollaboratorDialog(true)} + isTrashView={isTrashView} + onRestore={handleRestore} + onPermanentDelete={handlePermanentDelete} + onOpenHistory={() => onOpenHistory?.(note)} + historyEnabled={noteHistoryEnabled} + className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity" + /> + )} + + {/* Collaborator Dialog */} + {currentUserId && note.userId && ( +
e.stopPropagation()}> + +
+ )} + + {/* Connections Badge - Bottom right (spec: amber, absolute) */} +
+ { + if (!isNoteOpenInEditor) { + setShowConnectionsOverlay(true) + } + }} + /> +
+ + {/* Connections Overlay */} +
e.stopPropagation()}> + setShowConnectionsOverlay(false)} + noteId={note.id} + onOpenNote={(connNoteId) => { + setShowConnectionsOverlay(false) + const params = new URLSearchParams(searchParams.toString()) + params.set('note', connNoteId) + router.push(`?${params.toString()}`) + }} + onCompareNotes={(noteIds) => { + setComparisonNotes(noteIds) + }} + onMergeNotes={async (noteIds) => { + const fetchedNotes = await Promise.all(noteIds.map(async (id) => { + try { + const res = await fetch(`/api/notes/${id}`) + if (!res.ok) return null + const data = await res.json() + return data.success && data.data ? data.data : null + } catch { return null } + })) + setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array>) + }} + /> +
+ + {/* Comparison Modal */} + {comparisonNotes && comparisonNotesData.length > 0 && ( +
e.stopPropagation()}> + setComparisonNotes(null)} + notes={comparisonNotesData} + onOpenNote={(noteId) => { + const foundNote = comparisonNotesData.find(n => n.id === noteId) + if (foundNote) { + onEdit?.(foundNote, false) + } + }} + /> +
+ )} + + {/* Fusion Modal */} + {fusionNotes.length > 0 && ( +
e.stopPropagation()}> + 0} + onClose={() => setFusionNotes([])} + notes={fusionNotes} + onConfirmFusion={async ({ title, content }, options) => { + await createNote({ + title, + content, + labels: options.keepAllTags + ? [...new Set(fusionNotes.flatMap(n => n.labels || []))] + : fusionNotes[0].labels || [], + color: fusionNotes[0].color, + type: 'text', + isMarkdown: true, + autoGenerated: true, + aiProvider: 'fusion', + notebookId: fusionNotes[0].notebookId ?? undefined + }) + if (options.archiveOriginals) { + for (const n of fusionNotes) { + if (n.id) await updateNote(n.id, { isArchived: true }) + } + } + toast.success(t('toast.notesFusionSuccess')) + setFusionNotes([]) + triggerRefresh() + }} + /> +
+ )} + + {/* Delete Confirmation Dialog */} + + + + {t('notes.confirmDeleteTitle') || t('notes.delete')} + + {t('notes.confirmDelete') || 'Are you sure you want to delete this note?'} + + + + {t('common.cancel') || 'Cancel'} + + {t('notes.delete') || 'Delete'} + + + + + + ) +}) \ No newline at end of file diff --git a/Momento-main/momento/memento-note/components/note-history-modal.tsx b/Momento-main/momento/memento-note/components/note-history-modal.tsx new file mode 100644 index 0000000..b2a002a --- /dev/null +++ b/Momento-main/momento/memento-note/components/note-history-modal.tsx @@ -0,0 +1,219 @@ +'use client' + +import { useEffect, useMemo, useState, useTransition } from 'react' +import { formatDistanceToNow } from 'date-fns' +import { fr } from 'date-fns/locale/fr' +import { enUS } from 'date-fns/locale/en-US' +import { History, Loader2, RotateCcw } from 'lucide-react' +import { toast } from 'sonner' +import { getNoteHistory, restoreNoteVersion } from '@/app/actions/notes' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { useLanguage } from '@/lib/i18n' +import type { Note, NoteHistoryEntry } from '@/lib/types' +import { cn } from '@/lib/utils' + +interface NoteHistoryModalProps { + open: boolean + onOpenChange: (open: boolean) => void + note: Note | null + enabled: boolean + onEnableHistory: () => Promise + onRestored: (note: Note) => void +} + +function getDateLocale(language: string) { + if (language === 'fr') return fr + return enUS +} + +export function NoteHistoryModal({ + open, + onOpenChange, + note, + enabled, + onEnableHistory, + onRestored, +}: NoteHistoryModalProps) { + const { t, language } = useLanguage() + const [entries, setEntries] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isRestoring, startRestoring] = useTransition() + const [isEnabling, startEnabling] = useTransition() + + useEffect(() => { + if (!open || !note || !enabled) return + + let cancelled = false + setIsLoading(true) + getNoteHistory(note.id, 50) + .then((result) => { + if (cancelled) return + setEntries(result) + setSelectedId(result[0]?.id ?? null) + }) + .catch((error) => { + console.error('Failed to load note history:', error) + toast.error(t('general.error')) + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, [open, note, enabled, t]) + + const selectedEntry = useMemo( + () => entries.find((entry) => entry.id === selectedId) ?? null, + [entries, selectedId] + ) + + const handleRestore = () => { + if (!note || !selectedEntry) return + startRestoring(async () => { + try { + const restored = await restoreNoteVersion(note.id, selectedEntry.id) + onRestored(restored) + toast.success(t('notes.historyRestored') || 'Version restaurée') + } catch (error) { + console.error('Failed to restore history entry:', error) + toast.error(t('general.error')) + } + }) + } + + const handleEnable = () => { + startEnabling(async () => { + try { + await onEnableHistory() + toast.success(t('notes.historyEnabled') || 'History activé') + } catch (error) { + console.error('Failed to enable history:', error) + toast.error(t('general.error')) + } + }) + } + + return ( + + + + + + {t('notes.history') || 'Historique'} + + + {note?.title || t('notes.untitled') || 'Sans titre'} + + + + {!enabled ? ( +
+

+ {t('notes.historyDisabledDesc') || "L'historique est désactivé pour votre compte."} +

+ +
+ ) : ( +
+
+ {isLoading ? ( +
+ + {t('general.loading')} +
+ ) : entries.length === 0 ? ( +

+ {t('notes.historyEmpty') || 'Aucune version disponible'} +

+ ) : ( +
+ {entries.map((entry) => ( + + ))} +
+ )} +
+ +
+ {selectedEntry ? ( +
+
+

+ {t('notes.title') || 'Titre'} +

+

+ {selectedEntry.title || t('notes.untitled') || 'Sans titre'} +

+
+ +
+

+ {t('notes.content') || 'Contenu'} +

+
+                      {selectedEntry.content || ''}
+                    
+
+
+ ) : ( +

+ {t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'} +

+ )} +
+
+ )} + + + {enabled && selectedEntry && ( + + )} + +
+
+ ) +} diff --git a/Momento-main/momento/memento-note/components/note-inline-editor.tsx b/Momento-main/momento/memento-note/components/note-inline-editor.tsx new file mode 100644 index 0000000..b528fa2 --- /dev/null +++ b/Momento-main/momento/memento-note/components/note-inline-editor.tsx @@ -0,0 +1,856 @@ +'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 { LabelBadge } from '@/components/label-badge' +import { EditorConnectionsSection } from '@/components/editor-connections-section' +import { FusionModal } from '@/components/fusion-modal' +import { ComparisonModal } from '@/components/comparison-modal' +import { useLanguage } from '@/lib/i18n' +import { cn } from '@/lib/utils' +import { + updateNote, + toggleArchive, + deleteNote, + createNote, +} 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, + RotateCcw, + History, +} 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 { useNoteRefresh } from '@/context/NoteRefreshContext' +import { useNotebooks } from '@/context/notebooks-context' +import { ContextualAIChat } from '@/components/contextual-ai-chat' +import { formatDistanceToNow } from 'date-fns' +import { fr } from 'date-fns/locale/fr' +import { enUS } from 'date-fns/locale/en-US' +import { useSession } from 'next-auth/react' +import { getAISettings } from '@/app/actions/ai-settings' + +interface NoteInlineEditorProps { + note: Note + onDelete?: (noteId: string) => void + onArchive?: (noteId: string) => void + onChange?: (noteId: string, fields: Partial) => void + onOpenHistory?: (note: Note) => void + noteHistoryEnabled?: boolean + 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, + onOpenHistory, + noteHistoryEnabled = false, + colorKey, + defaultPreviewMode = false, +}: NoteInlineEditorProps) { + const { t, language } = useLanguage() + const { data: session } = useSession() + const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true) + const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true) + + useEffect(() => { + if (session?.user?.id) { + const userId = session.user.id + import('@/app/actions/ai-settings').then(({ getAISettings }) => { + getAISettings(userId).then(settings => { + setAiAssistantEnabled(settings.paragraphRefactor !== false) + setAutoLabelingEnabled(settings.autoLabeling !== false) + }).catch(err => console.error("Failed to fetch AI settings", err)) + }) + } + }, [session?.user?.id]) + const { labels: globalLabels, addLabel } = useLabels() + const [, startTransition] = useTransition() + const { triggerRefresh } = useNoteRefresh() + + // ── Local edit state ────────────────────────────────────────────────────── + const [title, setTitle] = useState(note.title || '') + const [content, setContent] = useState(note.content || '') + const [checkItems, setCheckItems] = useState(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([]) + const [fusionNotes, setFusionNotes] = useState>>([]) + const [comparisonNotes, setComparisonNotes] = useState>>([]) + + 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 side panel + const [aiOpen, setAiOpen] = useState(false) + const [isProcessingAI, setIsProcessingAI] = useState(false) + // Undo after AI copilot applies content + const [previousContent, setPreviousContent] = useState(null) + + // Notebooks list (for copilot chat scope) + const { notebooks } = useNotebooks() + + const fileInputRef = useRef(null) + const saveTimerRef = useRef | 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' && autoLabelingEnabled, + }) + 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 })) + } + } + + const fetchNotesByIds = async (noteIds: string[]) => { + const fetched = await Promise.all(noteIds.map(async (id) => { + try { + const res = await fetch(`/api/notes/${id}`) + if (!res.ok) return null + const data = await res.json() + return data.success && data.data ? data.data : null + } catch { return null } + })) + return fetched.filter((n: any) => n !== null) as Array> + } + + const handleMergeNotes = async (noteIds: string[]) => { + setFusionNotes(await fetchNotesByIds(noteIds)) + } + + const handleCompareNotes = async (noteIds: string[]) => { + setComparisonNotes(await fetchNotesByIds(noteIds)) + } + + const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => { + await createNote({ + title, + content, + labels: options.keepAllTags + ? [...new Set(fusionNotes.flatMap(n => n.labels || []))] + : fusionNotes[0].labels || [], + color: fusionNotes[0].color, + type: 'text', + isMarkdown: true, + autoGenerated: true, + aiProvider: 'fusion', + notebookId: fusionNotes[0].notebookId ?? undefined + }) + if (options.archiveOriginals) { + for (const n of fusionNotes) { + if (n.id) await updateNote(n.id, { isArchived: true }) + } + } + toast.success(t('toast.notesFusionSuccess')) + setFusionNotes([]) + triggerRefresh() + } + + // ── Quick actions (pin, archive, color, delete) ─────────────────────────── + const handleTogglePin = () => { + const prev = note.isPinned + startTransition(async () => { + onChange?.(note.id, { isPinned: !prev }) + try { + await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true }) + toast.success(prev ? t('notes.unpinned') : t('notes.pinned') ) + } catch { + onChange?.(note.id, { isPinned: prev }) + toast.error(t('general.error')) + } + }) + } + + const handleToggleArchive = () => { + startTransition(async () => { + onArchive?.(note.id) + try { + await toggleArchive(note.id, !note.isArchived) + triggerRefresh() + } catch { + // Cannot easily revert since onArchive removes from list + toast.error(t('general.error')) + } + }) + } + + const handleColorChange = (color: string) => { + const prev = color + startTransition(async () => { + onChange?.(note.id, { color }) + try { + await updateNote(note.id, { color }, { skipRevalidation: true }) + } catch { + onChange?.(note.id, { color: prev }) + toast.error(t('general.error')) + } + }) + } + + const handleDelete = () => { + if (!confirm(t('notes.confirmDelete'))) return + startTransition(async () => { + await deleteNote(note.id) + onDelete?.(note.id) + triggerRefresh() + }) + } + + // ── Image upload ────────────────────────────────────────────────────────── + const handleImageUpload = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files) return + for (const file of Array.from(files)) { + try { + const url = await uploadImageFile(file) + const newImages = [...(note.images || []), 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 uploadImageFile = async (file: File) => { + const formData = new FormData() + formData.append('file', file) + const res = await fetch('/api/upload', { method: 'POST', body: formData }) + if (!res.ok) throw new Error('Upload failed') + const data = await res.json() + return data.url + } + + // Paste handler: upload clipboard images + useEffect(() => { + const handlePaste = async (e: ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + for (const item of Array.from(items)) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const file = item.getAsFile() + if (!file) continue + try { + const url = await uploadImageFile(file) + const newImages = [...(note.images || []), url] + onChange?.(note.id, { images: newImages }) + await updateNote(note.id, { images: newImages }) + } catch { + toast.error(t('notes.uploadFailed', { filename: 'pasted image' })) + } + } + } + } + document.addEventListener('paste', handlePaste) + return () => document.removeEventListener('paste', handlePaste) + }, [note.id, note.images, onChange, t]) + + 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 }) + } + + // ── 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 ( +
+
+ + {/* ── Toolbar ───────────────────────────────────────────────── */} +
+ + {/* Left group: content tools */} +
+ + + + + + + + {isMarkdown && ( + + )} + + {note.type === 'text' && aiAssistantEnabled && ( + + )} + + {previousContent !== null && ( + + )} +
+ + {/* Right group: meta actions + save indicator */} +
+ + {isSaving ? ( + <> {t('notes.saving')} + ) : isDirty ? ( + <> {t('notes.dirtyStatus')} + ) : ( + <> {t('notes.savedStatus')} + )} + + + + + + + + + +
+ {Object.entries(NOTE_COLORS).map(([name, cls]) => ( +
+
+
+ + + + + + + + {note.isArchived + ? <>{t('notes.unarchive')} + : <>{t('notes.archive')}} + + {onOpenHistory && ( + onOpenHistory(note)} + > + + {noteHistoryEnabled + ? (t('notes.history') || 'Historique') + : (t('notes.enableHistory') || "Activer l'historique")} + + )} + + + {t('notes.delete')} + + + +
+
+ + {/* ── Link input bar (inline) ───────────────────────────────────────── */} + {showLinkInput && ( +
+ setLinkUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }} + autoFocus + /> + + +
+ )} + + {/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */} + {((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && ( +
+ {/* Existing labels */} + {(note.labels ?? []).map((label) => ( + + ))} + {/* AI-suggested tags inline with labels */} + setDismissedTags((p) => [...p, tag])} + /> +
+ )} + + {/* ── Scrollable editing area ── */} +
+ {/* Title */} +
+ { changeTitle(e.target.value); scheduleSave() }} + /> + {!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && ( + + )} +
+ + {/* Title Suggestions Dropdown / Inline list */} + {!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && ( +
+ { changeTitle(selectedTitle); scheduleSave() }} + onDismiss={() => setDismissedTitleSuggestions(true)} + /> +
+ )} + + {/* Images */} + {Array.isArray(note.images) && note.images.length > 0 && ( +
+ +
+ )} + + {/* Link previews */} + {Array.isArray(note.links) && note.links.length > 0 && ( +
+ {note.links.map((link, idx) => ( +
+ {link.imageUrl && ( +
+ )} +
+

{link.title || link.url}

+ {link.description &&

{link.description}

} + + {(() => { try { return new URL(link.url).hostname } catch { return link.url } })()} + +
+ +
+ ))} +
+ )} + + {/* ── Text / Checklist content ───────────────────────────────────── */} +
+ {note.type === 'text' ? ( +
+ {showMarkdownPreview && isMarkdown ? ( +
+ +
+ ) : ( +