feat: robust automatic DB migration for Docker deployments
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s

Backup before migration (pg_dump/SQLite copy), DB connection wait with
retries, idempotent prisma migrate deploy, old backup cleanup (keep 5),
and server refuses to start if migration fails.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:30:45 +02:00
parent 69ea064ca8
commit 39c705592a
30 changed files with 181 additions and 8064 deletions

View File

@@ -1,9 +0,0 @@
{
"worktrees": {},
"sessions": {},
"tabOrder": {
"local": [
"pending:1"
]
}
}

View File

@@ -1,82 +0,0 @@
# 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.

View File

@@ -1,28 +0,0 @@
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 (
<HomeClient
initialNotes={allNotes}
initialSettings={{
showRecentNotes: settings?.showRecentNotes !== false,
notesViewMode,
noteHistory: settings?.noteHistory === true,
}}
/>
)
}

View File

@@ -1,280 +0,0 @@
'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<Record<UserAISettingsPrismaKey, unknown>> {
const out: Partial<Record<UserAISettingsPrismaKey, unknown>> = {}
for (const key of USER_AI_SETTINGS_PRISMA_KEYS) {
const v = input[key]
if (v !== undefined) {
out[key] = v
}
}
if (out.notesViewMode === 'list') {
out.notesViewMode = 'tabs'
}
if (
out.notesViewMode != null &&
out.notesViewMode !== 'masonry' &&
out.notesViewMode !== 'tabs'
) {
delete out.notesViewMode
}
return out
}
/**
* Update AI settings for the current user
*/
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<string, string | boolean | undefined>
// 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<boolean> {
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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,142 +0,0 @@
'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<GenerateTitlesResponse> {
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<void> {
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<void> {
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
}
}

View File

@@ -1,51 +0,0 @@
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 })
}
}

View File

@@ -1,110 +0,0 @@
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 }
)
}
}

View File

@@ -1,218 +0,0 @@
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<string, any> = {}
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 }
)
}
}

View File

@@ -1,225 +0,0 @@
'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 (
<div className="space-y-6">
{isPending && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Loader2 className="h-4 w-4 animate-spin" />
{t('aiSettings.saving')}
</div>
)}
{/* Feature Toggles */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t('aiSettings.features')}</h2>
<FeatureToggle
name={t('titleSuggestions.available').replace('💡 ', '')}
description={t('aiSettings.titleSuggestionsDesc')}
checked={settings.titleSuggestions}
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
<FeatureToggle
name="Assistant IA"
description="Active le bouton de chat IA et les outils d'amélioration du texte"
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
<FeatureToggle
name={t('memoryEcho.title')}
description={t('memoryEcho.dailyInsight')}
checked={settings.memoryEcho}
onChange={(checked) => handleToggle('memoryEcho', checked)}
/>
{settings.memoryEcho && (
<Card className="p-4 ml-6">
<Label htmlFor="frequency" className="text-sm font-medium">
{t('aiSettings.frequency')}
</Label>
<p className="text-xs text-gray-500 mb-3">
{t('aiSettings.frequencyDesc')}
</p>
<RadioGroup
value={settings.memoryEchoFrequency}
onValueChange={handleFrequencyChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="daily" id="daily" />
<Label htmlFor="daily" className="font-normal">
{t('aiSettings.frequencyDaily')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="weekly" id="weekly" />
<Label htmlFor="weekly" className="font-normal">
{t('aiSettings.frequencyWeekly')}
</Label>
</div>
</RadioGroup>
</Card>
)}
{/* Language Detection Toggle */}
<FeatureToggle
name="Détection de langue"
description="Détecte automatiquement la langue de vos notes"
checked={settings.languageDetection ?? true}
onChange={(checked) => handleToggle('languageDetection', checked)}
/>
{/* Auto Labeling Toggle */}
<FeatureToggle
name="Suggestion des labels"
description="Suggère et applique des étiquettes automatiquement à vos notes"
checked={settings.autoLabeling ?? true}
onChange={(checked) => handleToggle('autoLabeling', checked)}
/>
<FeatureToggle
name="Historique des notes"
description="Active les snapshots de versions et la restauration depuis History"
checked={settings.noteHistory ?? false}
onChange={(checked) => handleToggle('noteHistory', checked)}
/>
{/* Demo Mode Toggle */}
<DemoModeToggle
demoMode={settings.demoMode}
onToggle={handleDemoModeToggle}
/>
</div>
</div>
)
}
interface FeatureToggleProps {
name: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}
function FeatureToggle({ name, description, checked, onChange }: FeatureToggleProps) {
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-base font-medium">{name}</Label>
<p className="text-sm text-gray-500">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={false}
/>
</div>
</Card>
)
}

View File

@@ -1,475 +0,0 @@
'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<Note[]>(initialNotes)
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
initialNotes.filter(n => n.isPinned)
)
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(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<Note | null>(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 }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<span>{t('nav.notebooks')}</span>
<ChevronRight className="w-4 h-4" />
<span className="font-medium text-primary">{notebookName}</span>
</div>
)
const isTabs = notesViewMode === 'tabs'
return (
<div
className={cn(
'flex w-full min-h-0 flex-1 flex-col',
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
)}
>
{/* Notebook Specific Header */}
{currentNotebook ? (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
<Breadcrumbs notebookName={currentNotebook.name} />
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
{(() => {
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
return (
<Icon
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
/>
)
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
</div>
</div>
</div>
) : (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
{!isTabs && <div className="mb-1 h-5" />}
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
{isInbox && !isLoading && notes.length >= 2 && (
<Button
onClick={() => setBatchOrganizationOpen(true)}
variant="outline"
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
title={t('batch.organizeWithAI')}
>
<Wand2 className="h-4 w-4 text-purple-600" />
<span className="hidden sm:inline">{t('batch.organize')}</span>
</Button>
)}
</div>
</div>
</div>
)}
{!isTabs && (
<div
className={cn(
'animate-in fade-in slide-in-from-top-4 duration-300',
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
)}
>
<NoteInput
onNoteCreated={handleNoteCreatedWrapper}
fullWidth={isTabs}
/>
</div>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : (
<>
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
/>
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<NotesMainSection
viewMode={notesViewMode}
notes={notes.filter((note) => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={handleOpenHistory}
onEnableHistory={handleEnableHistory}
/>
</div>
)}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && !isTabs && (
<div className="text-center py-8 text-gray-500">
{t('notes.emptyState')}
</div>
)}
</>
)}
<MemoryEchoNotification onOpenNote={handleOpenNote} />
{notebookSuggestion && (
<NotebookSuggestionToast
noteId={notebookSuggestion.noteId}
noteContent={notebookSuggestion.content}
onDismiss={() => setNotebookSuggestion(null)}
/>
)}
{batchOrganizationOpen && (
<BatchOrganizationDialog
open={batchOrganizationOpen}
onOpenChange={setBatchOrganizationOpen}
onNotesMoved={() => router.refresh()}
/>
)}
{autoLabelOpen && (
<AutoLabelSuggestionDialog
open={autoLabelOpen}
onOpenChange={(open) => {
setAutoLabelOpen(open)
if (!open) dismissLabelSuggestion()
}}
notebookId={suggestNotebookId}
onLabelsCreated={() => router.refresh()}
/>
)}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
<NoteHistoryModal
open={historyOpen}
onOpenChange={setHistoryOpen}
note={historyNote}
enabled={noteHistoryEnabled}
onEnableHistory={handleEnableHistory}
onRestored={handleHistoryRestored}
/>
</div>
)
}

View File

@@ -1,338 +0,0 @@
'use client'
import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
import { useNotebookDrag } from '@/context/notebook-drag-context';
import { useLanguage } from '@/lib/i18n';
import { 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 (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="masonry-sortable-item"
data-id={note.id}
data-size={note.size}
>
<NoteCard
note={note}
onEdit={onEdit}
onDragStart={onDragStartNote}
onDragEnd={onDragEndNote}
isDragging={isDragging}
isTrashView={isTrashView}
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
);
})
// ─────────────────────────────────────────────
// Sortable Grid Section (pinned or others)
// ─────────────────────────────────────────────
interface SortableGridSectionProps {
notes: Note[];
onEdit: (note: Note, readOnly?: boolean) => void;
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
draggedNoteId: string | null;
onDragStartNote: (noteId: string) => void;
onDragEndNote: () => void;
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 (
<SortableContext items={ids} strategy={rectSortingStrategy}>
<div className="masonry-css-grid">
{notes.map(note => (
<SortableNoteItem
key={note.id}
note={note}
onEdit={onEdit}
onSizeChange={onSizeChange}
onDragStartNote={onDragStartNote}
onDragEndNote={onDragEndNote}
isDragging={draggedNoteId === note.id}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
))}
</div>
</SortableContext>
);
});
// ─────────────────────────────────────────────
// 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<Note[]>(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<string | null>(null);
const activeNote = useMemo(
() => localNotes.find(n => n.id === activeId) ?? null,
[localNotes, activeId]
);
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
if (onEdit) {
onEdit(note, readOnly);
} else {
setEditingNote({ note, readOnly });
}
}, [onEdit]);
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
onSizeChange?.(noteId, newSize);
}, [onSizeChange]);
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, // Évite les activations accidentelles
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile
})
);
const localNotesRef = useRef<Note[]>(localNotes)
useEffect(() => {
localNotesRef.current = localNotes
}, [localNotes])
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
startDrag(event.active.id as string);
}, [startDrag]);
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
endDrag();
if (!over || active.id === over.id) return;
const reordered = arrayMove(
localNotesRef.current,
localNotesRef.current.findIndex(n => n.id === active.id),
localNotesRef.current.findIndex(n => n.id === over.id),
);
if (reordered.length === 0) return;
setLocalNotes(reordered);
// Persist order outside of setState to avoid "setState in render" warning
const ids = reordered.map(n => n.id);
updateFullOrderWithoutRevalidation(ids).catch(err => {
console.error('Failed to persist order:', err);
});
}, [endDrag]);
return (
<DndContext
id="masonry-dnd"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="masonry-container" data-card-size-mode={cardSizeMode}>
{pinnedNotes.length > 0 && (
<div className="mb-8">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
{t('notes.pinned')}
</h2>
<SortableGridSection
notes={pinnedNotes}
onEdit={handleEdit}
onSizeChange={handleSizeChange}
draggedNoteId={draggedNoteId}
onDragStartNote={startDrag}
onDragEndNote={endDrag}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
)}
{othersNotes.length > 0 && (
<div>
{pinnedNotes.length > 0 && (
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
{t('notes.others')}
</h2>
)}
<SortableGridSection
notes={othersNotes}
onEdit={handleEdit}
onSizeChange={handleSizeChange}
draggedNoteId={draggedNoteId}
onDragStartNote={startDrag}
onDragEndNote={endDrag}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
)}
</div>
{/* DragOverlay — montre une copie flottante pendant le drag */}
<DragOverlay>
{activeNote ? (
<div className="masonry-sortable-item masonry-drag-overlay" data-size={activeNote.size}>
<NoteCard
note={activeNote}
onEdit={handleEdit}
isDragging={true}
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
) : null}
</DragOverlay>
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
</DndContext>
);
}

View File

@@ -1,243 +0,0 @@
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 (
<div
className={cn("flex items-center justify-end gap-1", className)}
onClick={(e) => e.stopPropagation()}
>
{/* Restore Button */}
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 px-2 text-xs"
onClick={onRestore}
title={t('trash.restore')}
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{t('trash.restore')}</span>
</Button>
{/* Permanent Delete Button */}
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 px-2 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
onClick={onPermanentDelete}
title={t('trash.permanentDelete')}
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t('trash.permanentDelete')}</span>
</Button>
</div>
)
}
return (
<div
className={cn("flex items-center justify-end gap-1", className)}
onClick={(e) => e.stopPropagation()}
>
{/* Color Palette */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
currentColor === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => onColorChange(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Markdown Toggle */}
{onToggleMarkdown && (
<Button
variant="ghost"
size="sm"
className={cn("h-8 gap-1 px-2 text-xs", isMarkdown && "text-primary bg-primary/10")}
title="Markdown"
onClick={onToggleMarkdown}
>
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">MD</span>
</Button>
)}
{/* More Options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label={t('notes.moreOptions')}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Pin/Unpin Option */}
<DropdownMenuItem onClick={onTogglePin}>
{isPinned ? (
<>
<Pin className="h-4 w-4 mr-2" />
{t('notes.unpin')}
</>
) : (
<>
<Pin className="h-4 w-4 mr-2" />
{t('notes.pin')}
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleArchive}>
{isArchived ? (
<>
<ArchiveRestore className="h-4 w-4 mr-2" />
{t('notes.unarchive')}
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
{t('notes.archive')}
</>
)}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem onClick={onOpenHistory}>
<History className="h-4 w-4 mr-2" />
{historyEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>
)}
{/* Size Selector */}
{onSizeChange && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{t('notes.size')}
</div>
{(['small', 'medium', 'large'] as const).map((size) => (
<DropdownMenuItem
key={size}
onSelect={(e) => {
onSizeChange?.(size);
}}
className={cn(
"capitalize",
currentSize === size && "bg-accent"
)}
>
<Maximize2 className="h-4 w-4 mr-2" />
{t(`notes.${size}` as const)}
</DropdownMenuItem>
))}
</>
)}
{/* Collaborators */}
{onShareCollaborators && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onShareCollaborators()
}}
>
<Users className="h-4 w-4 mr-2" />
{t('notes.shareWithCollaborators')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-red-600 dark:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />
{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -1,782 +0,0 @@
'use client'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, 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<string, Locale> = {
en: enUS,
fr: fr,
es: es,
de: de,
fa: faIR,
it: it,
pt: pt,
ru: ru,
zh: zhCN,
ja: ja,
ko: ko,
ar: ar,
hi: hi,
nl: nl,
pl: pl,
}
function getDateLocale(language: string): Locale {
return localeMap[language] || enUS
}
// Map icon names to lucide-react components
const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
}
// Function to get icon component by name
function getNotebookIcon(iconName: string): LucideIcon {
const IconComponent = ICON_MAP[iconName] || Folder
return IconComponent
}
interface NoteCardProps {
note: Note
onEdit?: (note: Note, readOnly?: boolean) => void
isDragging?: boolean
isDragOver?: boolean
onDragStart?: (noteId: string) => void
onDragEnd?: () => void
onResize?: () => void
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
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<any[]>([])
const [owner, setOwner] = useState<any>(null)
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
// Move note to a notebook
const handleMoveToNotebook = async (notebookId: string | null) => {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setShowNotebookMenu(false)
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
}
// Optimistic UI state for instant feedback
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
// Local color state so color persists after transition ends
const [localColor, setLocalColor] = useState(note.color)
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
// Check if this note is currently open in the editor
const isNoteOpenInEditor = searchParams.get('note') === note.id
// Only fetch comparison notes when we have IDs to compare
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
)
const currentUserId = session?.user?.id
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
const isOwner = currentUserId && note.userId && currentUserId === note.userId
// Load collaborators only for shared notes (not owned by current user)
useEffect(() => {
// Skip API call for notes owned by current user — no need to fetch collaborators
if (!isSharedNote) {
// For own notes, set owner to current user
if (currentUserId && session?.user) {
setOwner({
id: currentUserId,
name: session.user.name,
email: session.user.email,
image: session.user.image,
})
}
return
}
let isMounted = true
const loadCollaborators = async () => {
if (note.userId && isMounted) {
try {
const users = await getNoteAllUsers(note.id)
if (isMounted) {
setCollaborators(users)
if (users.length > 0) {
setOwner(users[0])
}
}
} catch (error) {
console.error('Failed to load collaborators:', error)
if (isMounted) {
setCollaborators([])
}
}
}
}
loadCollaborators()
return () => {
isMounted = false
}
}, [note.id, note.userId, isSharedNote, currentUserId, session?.user])
const handleDelete = async () => {
setIsDeleting(true)
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 (
<Card
data-testid="note-card"
data-draggable="true"
data-note-id={note.id}
data-size={optimisticNote.size}
style={{ minHeight: getMinHeight(optimisticNote.size) }}
draggable={true}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', note.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
onDragStart?.(note.id)
}}
onDragEnd={() => onDragEnd?.()}
className={cn(
'note-card group relative rounded-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 */}
<div
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
title={t('notes.dragToReorder') || 'Drag to reorder'}
>
<GripVertical className="h-5 w-5 text-muted-foreground" />
</div>
{/* Move to Notebook Dropdown Menu */}
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
title={t('notebookSuggestion.moveToNotebook')}
>
<FolderOpen className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{t('notebookSuggestion.moveToNotebook')}
</div>
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
<StickyNote className="h-4 w-4 mr-2" />
{t('notebookSuggestion.generalNotes')}
</DropdownMenuItem>
{notebooks.map((notebook: any) => {
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
return (
<DropdownMenuItem
key={notebook.id}
onClick={() => handleMoveToNotebook(notebook.id)}
>
<NotebookIcon className="h-4 w-4 mr-2" />
{notebook.name}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Pin Button - Visible on hover or if pinned */}
<Button
variant="ghost"
size="sm"
data-testid="pin-button"
className={cn(
"absolute top-2 right-12 z-20 h-8 w-8 p-0 rounded-md transition-opacity",
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => {
e.stopPropagation()
handleTogglePin()
}}
>
<Pin
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
/>
</Button>
{/* Reminder Icon - Move slightly if pin button is there */}
{note.reminder && new Date(note.reminder) > new Date() && (
<Bell
className="absolute top-3 right-10 h-4 w-4 text-primary"
/>
)}
{/* Fusion Badge */}
{optimisticNote.aiProvider === 'fusion' && (
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative mb-2 w-fit">
<Link2 className="h-2.5 w-2.5" />
{t('memoryEcho.fused')}
<button
onClick={handleRemoveFusedBadge}
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
title={t('notes.remove') || 'Remove'}
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</div>
)}
{/* Title */}
{optimisticNote.title && (
<h3 className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
{optimisticNote.title}
</h3>
)}
{/* Search Match Type Badge */}
{optimisticNote.matchType && (
<Badge
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
className={cn(
'mb-2 text-xs',
optimisticNote.matchType === 'exact'
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
)}
>
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
</Badge>
)}
{/* Shared badge */}
{isSharedNote && owner && (
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
{t('notes.sharedBy')} {owner.name || owner.email}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
handleLeaveShare()
}}
>
<LogOut className="h-3 w-3 mr-1" />
{t('notes.leaveShare')}
</Button>
</div>
)}
{/* Images Component */}
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
{/* Link Previews */}
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{optimisticNote.links.map((link, idx) => (
<a
key={idx}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{link.imageUrl && (
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2">
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
<span className="text-[10px] text-primary mt-1 block">
{new URL(link.url).hostname}
</span>
</div>
</a>
))}
</div>
)}
{/* Content */}
{optimisticNote.type === 'text' ? (
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent
content={optimisticNote.content}
className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed"
/>
</div>
) : (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels - using shared LabelBadge component */}
{optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{optimisticNote.labels.map((label) => (
<LabelBadge key={label} label={label} />
))}
</div>
)}
{/* Footer with Date only */}
<div className="mt-3 flex items-center justify-end">
{/* Creation Date */}
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
</div>
</div>
{/* Owner Avatar - Aligned with action buttons at bottom */}
{owner && (
<div
className={cn(
"absolute bottom-2 left-2 z-20",
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
getAvatarColor(owner.name || owner.email || 'Unknown')
)}
title={owner.name || owner.email || 'Unknown'}
>
{getInitials(owner.name || owner.email || '??')}
</div>
)}
{/* Action Bar Component - Always show for now to fix regression */}
{true && (
<NoteActions
isPinned={optimisticNote.isPinned}
isArchived={optimisticNote.isArchived}
currentColor={optimisticNote.color}
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onSizeChange={handleSizeChange}
onDelete={() => setShowDeleteDialog(true)}
onShareCollaborators={() => setShowCollaboratorDialog(true)}
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 && (
<div onClick={(e) => e.stopPropagation()}>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId={note.id}
noteOwnerId={note.userId}
currentUserId={currentUserId}
/>
</div>
)}
{/* Connections Badge - Bottom right (spec: amber, absolute) */}
<div className="absolute bottom-2 right-2 z-10">
<ConnectionsBadge
noteId={note.id}
onClick={() => {
if (!isNoteOpenInEditor) {
setShowConnectionsOverlay(true)
}
}}
/>
</div>
{/* Connections Overlay */}
<div onClick={(e) => e.stopPropagation()}>
<ConnectionsOverlay
isOpen={showConnectionsOverlay}
onClose={() => 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<Partial<Note>>)
}}
/>
</div>
{/* Comparison Modal */}
{comparisonNotes && comparisonNotesData.length > 0 && (
<div onClick={(e) => e.stopPropagation()}>
<ComparisonModal
isOpen={!!comparisonNotes}
onClose={() => setComparisonNotes(null)}
notes={comparisonNotesData}
onOpenNote={(noteId) => {
const foundNote = comparisonNotesData.find(n => n.id === noteId)
if (foundNote) {
onEdit?.(foundNote, false)
}
}}
/>
</div>
)}
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<div onClick={(e) => e.stopPropagation()}>
<FusionModal
isOpen={fusionNotes.length > 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()
}}
/>
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleDelete}>
{t('notes.delete') || 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
})

View File

@@ -1,219 +0,0 @@
'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<void>
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<NoteHistoryEntry[]>([])
const [selectedId, setSelectedId] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl p-0">
<DialogHeader className="border-b border-border/60 px-6 py-4">
<DialogTitle className="flex items-center gap-2">
<History className="h-4 w-4 text-primary" />
{t('notes.history') || 'Historique'}
</DialogTitle>
<DialogDescription>
{note?.title || t('notes.untitled') || 'Sans titre'}
</DialogDescription>
</DialogHeader>
{!enabled ? (
<div className="space-y-3 px-6 py-8">
<p className="text-sm text-muted-foreground">
{t('notes.historyDisabledDesc') || "L'historique est désactivé pour votre compte."}
</p>
<Button onClick={handleEnable} disabled={isEnabling}>
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('notes.enableHistory') || "Activer l'historique"}
</Button>
</div>
) : (
<div className="grid grid-cols-[260px_1fr] gap-0">
<div className="max-h-[60vh] overflow-y-auto border-r border-border/60 p-3">
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('general.loading')}
</div>
) : entries.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">
{t('notes.historyEmpty') || 'Aucune version disponible'}
</p>
) : (
<div className="space-y-1">
{entries.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => setSelectedId(entry.id)}
className={cn(
'w-full rounded-md border px-2.5 py-2 text-left transition-colors',
selectedId === entry.id
? 'border-primary/40 bg-primary/8'
: 'border-border/70 hover:bg-muted/60'
)}
>
<p className="text-xs font-semibold text-foreground">
v{entry.version}
</p>
<p className="text-[11px] text-muted-foreground">
{formatDistanceToNow(new Date(entry.createdAt), {
addSuffix: true,
locale: getDateLocale(language),
})}
</p>
{entry.reason && (
<p className="mt-1 line-clamp-1 text-[11px] text-muted-foreground">
{entry.reason}
</p>
)}
</button>
))}
</div>
)}
</div>
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
{selectedEntry ? (
<div className="space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('notes.title') || 'Titre'}
</p>
<p className="mt-1 text-sm text-foreground">
{selectedEntry.title || t('notes.untitled') || 'Sans titre'}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('notes.content') || 'Contenu'}
</p>
<pre className="mt-1 whitespace-pre-wrap rounded-md border border-border/70 bg-muted/30 p-3 text-sm text-foreground">
{selectedEntry.content || ''}
</pre>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'}
</p>
)}
</div>
</div>
)}
<DialogFooter className="border-t border-border/60 px-6 py-3">
{enabled && selectedEntry && (
<Button onClick={handleRestore} disabled={isRestoring}>
{isRestoring
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <RotateCcw className="mr-2 h-4 w-4" />}
{t('notes.restore') || 'Restaurer'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,856 +0,0 @@
'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<Note>) => 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<CheckItem[]>(note.checkItems || [])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
defaultPreviewMode && (note.isMarkdown || false)
)
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
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<string | null>(null)
// Notebooks list (for copilot chat scope)
const { notebooks } = useNotebooks()
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingRef = useRef({ title, content, checkItems, isMarkdown })
const noteIdRef = useRef(note.id)
// Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: note.type === 'text' ? content : '',
enabled: note.type === 'text' && !title
})
// Keep pending ref in sync for unmount save
useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown }
}, [title, content, checkItems, isMarkdown])
// ── Sync when selected note switches ─────────────────────────────────────
useEffect(() => {
// Flush unsaved changes for the PREVIOUS note before switching
if (isDirty && noteIdRef.current !== note.id) {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
noteIdRef.current = note.id
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setIsMarkdown(note.isMarkdown || false)
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
setIsDirty(false)
setDismissedTitleSuggestions(false)
clearTimeout(saveTimerRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id])
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
const scheduleSave = useCallback(() => {
setIsDirty(true)
clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
setIsSaving(true)
try {
await saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
})
setIsDirty(false)
} catch {
// silent — retry on next keystroke
} finally {
setIsSaving(false)
}
}, 1500)
}, [note.type])
// Flush on unmount
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current)
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text' && 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<Partial<Note>>
}
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<HTMLInputElement>) => {
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 (
<div className="flex h-full w-full overflow-hidden">
<div className="flex flex-1 min-w-0 flex-col overflow-hidden transition-all duration-300">
{/* ── Toolbar ───────────────────────────────────────────────── */}
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-1.5 gap-2">
{/* Left group: content tools */}
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-8 w-8"
title={t('notes.addImage') }
onClick={() => fileInputRef.current?.click()}>
<ImageIcon className="h-4 w-4" />
</Button>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
<Button variant="ghost" size="icon" className="h-8 w-8"
title={t('notes.addLink') }
onClick={() => setShowLinkInput(!showLinkInput)}>
<LinkIcon className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => {
const nextIsMarkdown = !isMarkdown
setIsMarkdown(nextIsMarkdown)
onChange?.(note.id, { isMarkdown: nextIsMarkdown })
if (!nextIsMarkdown) setShowMarkdownPreview(false)
scheduleSave()
}}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
{isMarkdown && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
<Eye className="h-4 w-4" />
</Button>
)}
{note.type === 'text' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}
title={t('ai.aiCopilot')}>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{t('ai.aiCopilot')}</span>
</Button>
)}
{previousContent !== null && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600"
title={t('ai.undoAI') }
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
{/* Right group: meta actions + save indicator */}
<div className="flex items-center gap-1">
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
) : isDirty ? (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
) : (
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
<button type="button" key={name}
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', cls.bg,
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => handleColorChange(name)} title={name} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.moreOptions')}>
<span className="text-base leading-none text-muted-foreground"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleArchive}>
{note.isArchived
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem
onClick={() => onOpenHistory(note)}
>
<History className="h-4 w-4 mr-2" />
{noteHistoryEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
{showLinkInput && (
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
<input
type="url"
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
autoFocus
/>
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : t('notes.add')}
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
{/* Existing labels */}
{(note.labels ?? []).map((label) => (
<LabelBadge key={label} label={label} />
))}
{/* AI-suggested tags inline with labels */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={note.labels || []}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
/>
</div>
)}
{/* ── Scrollable editing area ── */}
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
{/* Title */}
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
<input
type="text"
dir="auto"
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title}
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
/>
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button"
onClick={async (e) => {
e.preventDefault()
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/suggest-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
if (res.ok) {
const data = await res.json()
const suggested = data.title || data.suggestedTitle || ''
if (suggested) { changeTitle(suggested); scheduleSave() }
}
} catch { } finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
title={t('ai.suggestTitle')}
>
{isProcessingAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</button>
)}
</div>
{/* Title Suggestions Dropdown / Inline list */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="mt-2 text-sm shrink-0">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
</div>
)}
{/* Images */}
{Array.isArray(note.images) && note.images.length > 0 && (
<div className="mt-4">
<EditorImages images={note.images} onRemove={handleRemoveImage} />
</div>
)}
{/* Link previews */}
{Array.isArray(note.links) && note.links.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{note.links.map((link, idx) => (
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
{link.imageUrl && (
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
</a>
</div>
<button type="button"
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* ── Text / Checklist content ───────────────────────────────────── */}
<div className="mt-4 flex flex-1 flex-col">
{note.type === 'text' ? (
<div className="flex flex-1 flex-col">
{showMarkdownPreview && isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
<MarkdownContent content={content || ''} />
</div>
) : (
<textarea
dir="auto"
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={isMarkdown
? t('notes.takeNoteMarkdown')
: t('notes.takeNote')
}
value={content}
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
style={{ minHeight: '200px' }}
/>
)}
{/* Ghost tag suggestions are now shown in the top labels strip */}
</div>
) : (
/* Checklist */
<div className="space-y-1">
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
onClick={() => handleToggleCheckItem(ci.id)}
/>
<input
dir="auto"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
value={ci.text}
placeholder={t('notes.listItem') }
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
/>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
</div>
))}
<button type="button"
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
onClick={handleAddCheckItem}
>
<Plus className="h-4 w-4" />
{t('notes.addItem') }
</button>
{checkItems.filter((ci) => ci.checked).length > 0 && (
<div className="mt-3">
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
{t('notes.completedLabel')} ({checkItems.filter((ci) => ci.checked).length})
</p>
{checkItems.filter((ci) => ci.checked).map((ci) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
onClick={() => handleToggleCheckItem(ci.id)}
>
<CheckSquare className="h-3 w-3 opacity-60" />
</button>
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Memory Echo Connections Section ── */}
<EditorConnectionsSection
noteId={note.id}
onOpenNote={(connNoteId) => {
window.open(`/?note=${connNoteId}`, '_blank')
}}
onCompareNotes={handleCompareNotes}
onMergeNotes={handleMergeNotes}
/>
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
</div>
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<FusionModal
isOpen={fusionNotes.length > 0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={handleConfirmFusion}
/>
)}
{/* Comparison Modal */}
{comparisonNotes.length > 0 && (
<ComparisonModal
isOpen={comparisonNotes.length > 0}
onClose={() => setComparisonNotes([])}
notes={comparisonNotes}
/>
)}
</div>
{/* ── AI Copilot Side Panel ── */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
noteImages={note.images || undefined}
onApplyToNote={(newContent) => {
setPreviousContent(content)
changeContent(newContent)
scheduleSave()
}}
onUndoLastAction={previousContent !== null ? () => {
changeContent(previousContent)
setPreviousContent(null)
scheduleSave()
} : undefined}
lastActionApplied={previousContent !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
/>
)}
</div>
)
}

View File

@@ -1,69 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { NotesTabsView } from '@/components/notes-tabs-view'
const MasonryGridLazy = dynamic(
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
{
ssr: false,
loading: () => (
<div
className="min-h-[200px] rounded-xl border border-dashed border-muted-foreground/20 bg-muted/30 animate-pulse"
aria-hidden
/>
),
}
)
export type NotesViewMode = 'masonry' | 'tabs'
interface NotesMainSectionProps {
notes: Note[]
viewMode: NotesViewMode
onEdit?: (note: Note, readOnly?: boolean) => void
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
currentNotebookId?: string | null
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
}
export function NotesMainSection({
notes,
viewMode,
onEdit,
onSizeChange,
currentNotebookId,
noteHistoryEnabled = false,
onOpenHistory,
onEnableHistory,
}: NotesMainSectionProps) {
if (viewMode === 'tabs') {
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
<NotesTabsView
notes={notes}
onEdit={onEdit}
currentNotebookId={currentNotebookId}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
/>
</div>
)
}
return (
<div data-testid="notes-grid">
<MasonryGridLazy
notes={notes}
onEdit={onEdit}
onSizeChange={onSizeChange}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
)
}

View File

@@ -1,994 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import {
updateFullOrderWithoutRevalidation,
createNote,
deleteNote,
updateNote,
toggleArchive,
} from '@/app/actions/notes'
import { useNotebooks } from '@/context/notebooks-context'
import {
GripVertical,
ListChecks,
Pin,
PinOff,
FileText,
Plus,
Loader2,
Trash2,
ListFilter,
FolderInput,
Archive,
Share2,
Check,
Hash,
History,
PanelRightClose,
PanelRightOpen,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { toast } from 'sonner'
import { format, type Locale } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NotesTabsViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
currentNotebookId?: string | null
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
}
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
// Color accent strip for each note
const COLOR_ACCENT: Record<NoteColor, string> = {
default: 'bg-primary',
red: 'bg-red-400',
orange: 'bg-orange-400',
yellow: 'bg-amber-400',
green: 'bg-emerald-400',
teal: 'bg-teal-400',
blue: 'bg-sky-400',
purple: 'bg-violet-400',
pink: 'bg-fuchsia-400',
gray: 'bg-gray-400',
}
// Background tint gradient for selected note panel
const COLOR_PANEL_BG: Record<NoteColor, string> = {
default: 'from-background to-background',
red: 'from-red-50/60 dark:from-red-950/20 to-background',
orange: 'from-orange-50/60 dark:from-orange-950/20 to-background',
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
}
const COLOR_ICON: Record<NoteColor, string> = {
default: 'text-primary',
red: 'text-red-500',
orange: 'text-orange-500',
yellow: 'text-amber-500',
green: 'text-emerald-500',
teal: 'text-teal-500',
blue: 'text-sky-500',
purple: 'text-violet-500',
pink: 'text-fuchsia-500',
gray: 'text-gray-500',
}
function getColorKey(note: Note): NoteColor {
return (typeof note.color === 'string' && note.color in NOTE_COLORS
? note.color
: 'default') as NoteColor
}
function getDateLocale(language: string) {
if (language === 'fr') return fr
if (language === 'fa') return require('date-fns/locale').faIR
return enUS
}
function formatNoteDate(date: Date | string, locale: Locale): string {
const d = typeof date === 'string' ? new Date(date) : date
return format(d, 'd MMM yyyy', { locale })
}
function countWords(content: string | null | undefined): number {
if (!content) return 0
return content.trim().split(/\s+/).filter(Boolean).length
}
// ─── Sortable List Item ───────────────────────────────────────────────────────
function SortableNoteListItem({
note,
selected,
onSelect,
onDelete,
reorderLabel,
deleteLabel,
language,
untitledLabel,
}: {
note: Note
selected: boolean
onSelect: () => void
onDelete: () => void
reorderLabel: string
deleteLabel: string
language: string
untitledLabel: string
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: note.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
}
const ck = getColorKey(note)
const title = getNoteDisplayTitle(note, untitledLabel)
const snippet =
note.type === 'checklist'
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 200)
: (note.content || '').substring(0, 200)
const dateLocale = getDateLocale(language)
const dateStr = formatNoteDate(note.updatedAt, dateLocale)
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch transition-all duration-150',
'border-b border-border/50 last:border-b-0',
selected
? 'bg-primary/[0.06] dark:bg-primary/10'
: 'bg-background hover:bg-muted/40 dark:hover:bg-muted/20',
isDragging && 'opacity-75 shadow-lg ring-1 ring-primary/20'
)}
onClick={onSelect}
role="option"
aria-selected={selected}
>
{/* Left accent bar — solid when selected, transparent otherwise */}
<div
className={cn(
'w-[3px] shrink-0 rounded-r-full transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent'
)}
/>
{/* Main card content */}
<div className="min-w-0 flex-1 px-4 py-4">
{/* Row 1: type icon + date */}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-3.5 w-3.5 shrink-0',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
)}
/>
) : (
<FileText
className={cn(
'h-3.5 w-3.5 shrink-0',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
)}
/>
)}
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary/70" />
)}
</div>
<span className={cn(
'shrink-0 text-[11px] tabular-nums',
selected ? 'text-muted-foreground' : 'text-muted-foreground/60'
)}>
{dateStr}
</span>
</div>
{/* Row 2: title */}
<p
className={cn(
'mb-1.5 text-[13.5px] leading-snug transition-colors',
selected
? 'font-semibold text-foreground'
: 'font-medium text-foreground/85 group-hover:text-foreground'
)}
>
{title}
</p>
{/* Row 3: snippet */}
{snippet && (
<p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
{snippet}
</p>
)}
{/* Row 4: label chips */}
{Array.isArray(note.labels) && note.labels.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{note.labels.slice(0, 3).map((label) => (
<span
key={label}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium leading-none transition-colors',
selected
? 'border-primary/25 text-primary/70'
: 'border-border text-muted-foreground/65 group-hover:border-border/80'
)}
>
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
{label}
</span>
))}
{note.labels.length > 3 && (
<span className="inline-flex items-center rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground/50">
+{note.labels.length - 3}
</span>
)}
</div>
)}
</div>
{/* Actions column: drag + delete on hover */}
<div className="flex flex-col items-center justify-between py-3 pe-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="cursor-grab p-1 text-muted-foreground/30 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="p-1 text-muted-foreground/40 hover:text-destructive"
aria-label={deleteLabel}
title={deleteLabel}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
// ─── Note Meta Sidebar ────────────────────────────────────────────────────────
function SidebarSection({ title }: { title: string }) {
return (
<div className="mb-3 flex items-center gap-2">
<p className="text-[10px] font-black uppercase tracking-[0.12em] text-muted-foreground whitespace-nowrap">
{title}
</p>
<div className="flex-1 h-px bg-border/60" />
</div>
)
}
function SidebarActionBtn({
icon,
label,
onClick,
disabled = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
disabled
? "cursor-not-allowed text-muted-foreground/60 opacity-70"
: "text-foreground/70 hover:bg-sky-50 hover:text-sky-700"
)}
>
<span
className={cn(
"transition-colors",
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-sky-600"
)}
>
{icon}
</span>
{label}
</button>
)
}
function NoteMetaSidebar({
note,
onPinToggle,
onArchive,
noteHistoryEnabled = false,
onOpenHistory,
onEnableHistory,
}: {
note: Note
onPinToggle: (note: Note) => void
onArchive: (note: Note) => void
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
}) {
const { t } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [moveOpen, setMoveOpen] = useState(false)
const [isMoving, setIsMoving] = useState(false)
// t() returns the key itself when not found — use this wrapper for safe fallbacks
const ts = (key: string, fallback: string) => {
const v = t(key as Parameters<typeof t>[0])
return v === key ? fallback : v
}
const wordCount = countWords(note.content)
const noteTypeLabel =
note.type === 'checklist'
? ts('notes.typeChecklist', 'Checklist')
: note.isMarkdown
? ts('notes.typeMarkdown', 'Markdown')
: ts('notes.typeText', 'Text')
const handleMoveToNotebook = async (notebookId: string | null) => {
setIsMoving(true)
try {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setMoveOpen(false)
toast.success(ts('notebookSuggestion.movedToNotebook', 'Note déplacée'))
} catch {
toast.error(ts('notes.moveFailed', 'Déplacement échoué'))
} finally {
setIsMoving(false)
}
}
const handleHistory = async () => {
if (!noteHistoryEnabled) {
if (!onEnableHistory) {
toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour votre compte."))
return
}
try {
await onEnableHistory()
toast.success(ts('notes.historyEnabled', 'Historique activé'))
onOpenHistory?.(note)
} catch {
toast.error(ts('general.error', 'Erreur'))
}
return
}
onOpenHistory?.(note)
}
return (
<aside className="flex w-56 shrink-0 flex-col bg-muted border-l border-slate-300 border-t-2 border-t-primary/40 overflow-y-auto shadow-[-6px_0_16px_-4px_rgba(0,0,0,0.08)]">
{/* ── DOCUMENT INFO ── */}
<div className="px-4 pt-5 pb-4 border-b border-border">
<SidebarSection title={ts('notes.documentInfo', 'Document Info')} />
<div className="space-y-3">
{/* Type */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.type', 'Type')}</p>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
note.type === 'checklist'
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: note.isMarkdown
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-card border-slate-200 text-slate-600"
)}
>
{note.type === 'checklist'
? <ListChecks className="h-3 w-3" />
: <FileText className="h-3 w-3" />}
{noteTypeLabel}
</span>
</div>
{/* Word count — discreet inline row */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.wordCount', 'Mots')}</p>
<p className="text-[13px] font-semibold text-foreground/70 tabular-nums">
{wordCount.toLocaleString()} <span className="text-[10px] font-normal text-muted-foreground">{ts('notes.words', 'mots')}</span>
</p>
</div>
{/* Labels */}
{Array.isArray(note.labels) && note.labels.length > 0 && (
<div>
<p className="mb-1.5 text-[11px] font-semibold text-muted-foreground">{ts('notes.labels', 'Labels')}</p>
<div className="flex flex-wrap gap-1">
{note.labels.map((label) => (
<span
key={label}
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card px-2 py-0.5 text-[11px] font-medium text-foreground/70"
>
<Hash className="h-2.5 w-2.5" />
{label}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* ── ACTIONS ── */}
<div className="px-4 pt-4 pb-5 flex-1">
<SidebarSection title={ts('notes.actions', 'Actions')} />
<div className="space-y-0.5">
{/* Move to notebook */}
<Popover open={moveOpen} onOpenChange={setMoveOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-sky-50 hover:text-sky-700 transition-all duration-150"
>
<span className="text-muted-foreground group-hover:text-sky-600 transition-colors">
{isMoving
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <FolderInput className="h-3.5 w-3.5" />}
</span>
{t('notebookSuggestion.moveToNotebook')}
</button>
</PopoverTrigger>
<PopoverContent side="left" align="start" className="w-52 p-1.5">
<div className="mb-1 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
{t('notebookSuggestion.moveToNotebook')}
</div>
<button
type="button"
onClick={() => handleMoveToNotebook(null)}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
!note.notebookId ? 'text-primary' : 'text-foreground/70'
)}
>
{!note.notebookId
? <Check className="h-3 w-3 shrink-0" />
: <span className="h-3 w-3 shrink-0" />}
{t('notes.generalNotes')}
</button>
{notebooks.map((nb) => (
<button
key={nb.id}
type="button"
onClick={() => handleMoveToNotebook(nb.id)}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
note.notebookId === nb.id ? 'text-primary' : 'text-foreground/70'
)}
>
{note.notebookId === nb.id
? <Check className="h-3 w-3 shrink-0" />
: <span className="h-3 w-3 shrink-0" />}
{nb.name}
</button>
))}
</PopoverContent>
</Popover>
{/* Pin / Unpin */}
<SidebarActionBtn
icon={note.isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
onClick={() => onPinToggle(note)}
disabled
/>
{/* Archive */}
<SidebarActionBtn
icon={<Archive className="h-3.5 w-3.5" />}
label={t('notes.archive')}
onClick={() => onArchive(note)}
/>
{/* History */}
<SidebarActionBtn
icon={<History className="h-3.5 w-3.5" />}
label={
noteHistoryEnabled
? ts('notes.history', 'Historique')
: ts('notes.enableHistory', "Activer l'historique")
}
onClick={() => void handleHistory()}
/>
</div>
</div>
</aside>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function NotesTabsView({
notes,
onEdit,
currentNotebookId,
noteHistoryEnabled = false,
onOpenHistory,
onEnableHistory,
}: NotesTabsViewProps) {
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefreshOptional()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('date-desc')
const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => {
setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
if (prevIds === incomingIds) {
return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id)
if (!fresh) return p
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
return {
...fresh,
title: p.title,
content: p.content,
checkItems: p.checkItems,
labels: labelsChanged ? fresh.labels : p.labels
}
})
}
return notes.map((fresh) => {
const local = prev.find((p) => p.id === fresh.id)
if (!local) return fresh
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort())
return {
...fresh,
title: local.title,
content: local.content,
checkItems: local.checkItems,
labels: labelsChanged ? fresh.labels : local.labels
}
})
})
}, [notes])
useEffect(() => {
if (items.length === 0) {
setSelectedId(null)
return
}
setSelectedId((prev) =>
prev && items.some((n) => n.id === prev) ? prev : items[0].id
)
}, [items])
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
if (!name) return
setItems((prev) =>
prev.map((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 }
})
)
}
window.addEventListener('label-deleted', handler)
return () => window.removeEventListener('label-deleted', handler)
}, [])
// Sorted display items (does NOT affect persisted order)
const sortedItems = useMemo(() => {
if (sortOrder === 'date-desc') return [...items]
return [...items].sort((a, b) => {
if (sortOrder === 'date-asc') {
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
}
if (sortOrder === 'title-asc') {
return (a.title || '').localeCompare(b.title || '')
}
if (sortOrder === 'title-desc') {
return (b.title || '').localeCompare(a.title || '')
}
return 0
})
}, [items, sortOrder])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.findIndex((n) => n.id === active.id)
const newIndex = items.findIndex((n) => n.id === over.id)
if (oldIndex < 0 || newIndex < 0) return
const reordered = arrayMove(items, oldIndex, newIndex)
setItems(reordered)
try {
await updateFullOrderWithoutRevalidation(reordered.map((n) => n.id))
} catch {
setItems(notes)
toast.error(t('notes.moveFailed'))
}
},
[items, notes, t]
)
const selected = items.find((n) => n.id === selectedId) ?? null
const colorKey = selected ? getColorKey(selected) : 'default'
const handleCreateNote = () => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
title: undefined,
notebookId: currentNotebookId || undefined,
skipRevalidation: true
})
if (!newNote) return
setItems((prev) => {
const pinned = prev.filter(n => n.isPinned)
const unpinned = prev.filter(n => !n.isPinned)
return [...pinned, newNote, ...unpinned]
})
setSelectedId(newNote.id)
triggerRefresh()
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
}
})
}
const handlePinToggle = async (note: Note) => {
const next = !note.isPinned
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
try {
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
} catch {
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
toast.error(t('notes.updateFailed') || 'Mise à jour échouée')
}
}
const handleArchive = async (note: Note) => {
try {
await toggleArchive(note.id, true)
setItems((prev) => prev.filter((n) => n.id !== note.id))
setSelectedId((prev) => (prev === note.id ? null : prev))
triggerRefresh()
toast.success(t('notes.archived') || 'Note archivée')
} catch {
toast.error(t('notes.archiveFailed') || 'Archivage échoué')
}
}
return (
<div
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-xl border border-border/70 shadow-sm"
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
data-testid="notes-grid-tabs"
>
{/* ── Left panel: note list ── */}
<div className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">
{/* Header */}
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-4 py-3.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold tracking-tight text-foreground">
{t('notes.title')}
</span>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
{items.length}
</span>
</div>
<div className="flex items-center gap-1">
{/* Sort / filter button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary',
sortOrder !== 'date-desc' && 'text-primary bg-primary/8'
)}
title={t('notes.sort') || 'Trier'}
>
<ListFilter className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60 py-1">
{t('notes.sortBy') || 'Trier par'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={sortOrder} onValueChange={(v) => setSortOrder(v as SortOrder)}>
<DropdownMenuRadioItem value="date-desc" className="text-[13px]">
{t('notes.sortDateDesc') || 'Date (récent)'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="date-asc" className="text-[13px]">
{t('notes.sortDateAsc') || 'Date (ancien)'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-asc" className="text-[13px]">
{t('notes.sortTitleAsc') || 'Titre A → Z'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-desc" className="text-[13px]">
{t('notes.sortTitleDesc') || 'Titre Z → A'}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* New note button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Scrollable note list */}
<div
className="flex-1 overflow-y-auto overscroll-contain bg-background"
role="listbox"
aria-label={t('notes.viewTabs')}
>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 rounded-2xl border border-border/60 bg-muted/30 p-4">
<FileText className="h-6 w-6 text-muted-foreground/40" />
</div>
<p className="text-sm font-medium text-muted-foreground">{t('notes.emptyStateTabs') || 'Aucune note'}</p>
<p className="mt-1 text-xs text-muted-foreground/60">{t('notes.createFirst') || 'Créez votre première note'}</p>
</div>
) : (
<DndContext
id="notes-tabs-dnd"
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col">
{sortedItems.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
selected={note.id === selectedId}
onSelect={() => setSelectedId(note.id)}
onDelete={() => setNoteToDelete(note)}
reorderLabel={t('notes.reorderTabs')}
deleteLabel={t('notes.delete')}
language={language}
untitledLabel={t('notes.untitled')}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* ── Right content panel ── */}
{selected ? (
<div className="flex min-w-0 flex-1 overflow-hidden">
{/* Editor */}
<div
className={cn(
'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-b',
COLOR_PANEL_BG[colorKey]
)}
>
<NoteInlineEditor
key={selected.id}
note={selected}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
colorKey={colorKey}
defaultPreviewMode={true}
onChange={(noteId, fields) => {
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
)
}}
onDelete={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
onArchive={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
triggerRefresh()
}}
/>
{/* Toggle sidebar button — top-right of editor, always visible */}
<button
type="button"
onClick={() => setSidebarOpen((v) => !v)}
title={sidebarOpen ? 'Masquer le panneau' : 'Afficher le panneau'}
className="absolute top-3 right-3 z-20 flex h-7 w-7 items-center justify-center rounded-md border border-border/70 bg-background/90 backdrop-blur-sm shadow-sm text-muted-foreground hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
>
{sidebarOpen
? <PanelRightClose className="h-3.5 w-3.5" />
: <PanelRightOpen className="h-3.5 w-3.5" />}
</button>
</div>
{/* Meta sidebar — collapsible */}
{sidebarOpen && (
<NoteMetaSidebar
note={selected}
onPinToggle={handlePinToggle}
onArchive={handleArchive}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
/>
)}
</div>
) : (
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10">
<div className="px-10 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-border/60 bg-background shadow-sm">
<FileText className="h-7 w-7 text-muted-foreground/30" />
</div>
<p className="text-sm font-medium text-foreground/60">
{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}
</p>
<p className="mt-1.5 text-xs text-muted-foreground/50 max-w-[200px] mx-auto leading-relaxed">
{items.length === 0
? t('notes.emptyNotebookDesc')
: t('notes.selectOrCreateNote')}
</p>
</div>
</div>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={!!noteToDelete} onOpenChange={() => setNoteToDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</DialogTitle>
<DialogDescription>
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
{noteToDelete && (
<span className="mt-2 block font-medium text-foreground">
&quot;{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}&quot;
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setNoteToDelete(null)}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
if (!noteToDelete) return
try {
await deleteNote(noteToDelete.id, { skipRevalidation: true })
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
setNoteToDelete(null)
triggerRefresh()
toast.success(t('notes.deleted'))
} catch {
toast.error(t('notes.deleteFailed'))
}
}}
>
{t('notes.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,121 +0,0 @@
import prisma from '@/lib/prisma'
import { asArray } from '@/lib/utils'
import type { NoteHistoryEntry } from '@/lib/types'
const HISTORY_TRACKED_FIELDS = [
'title',
'content',
'color',
'isPinned',
'isArchived',
'type',
'checkItems',
'labels',
'images',
'links',
'isMarkdown',
'size',
'notebookId',
] as const
export function shouldCaptureHistorySnapshot(data: Record<string, unknown>): boolean {
return HISTORY_TRACKED_FIELDS.some((field) => field in data)
}
export async function isNoteHistoryEnabledForUser(userId: string): Promise<boolean> {
const settings = await prisma.userAISettings.findUnique({
where: { userId },
select: { noteHistory: true },
})
return settings?.noteHistory === true
}
export async function createNoteHistorySnapshot({
noteId,
userId,
reason,
}: {
noteId: string
userId: string
reason?: string
}): Promise<void> {
const note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: {
id: true,
userId: true,
title: true,
content: true,
color: true,
isPinned: true,
isArchived: true,
type: true,
checkItems: true,
labels: true,
images: true,
links: true,
isMarkdown: true,
size: true,
notebookId: true,
},
})
if (!note || !note.userId) return
await prisma.$transaction(async (tx) => {
const lastVersionEntry = await (tx as any).noteHistory.findFirst({
where: { noteId },
orderBy: { version: 'desc' },
select: { version: true },
})
const nextVersion = ((lastVersionEntry?.version as number | undefined) ?? 0) + 1
await (tx as any).noteHistory.create({
data: {
noteId: note.id,
userId: note.userId,
version: nextVersion,
reason: reason ?? null,
title: note.title,
content: note.content,
color: note.color,
isPinned: note.isPinned,
isArchived: note.isArchived,
type: note.type,
checkItems: note.checkItems,
labels: note.labels,
images: note.images,
links: note.links,
isMarkdown: note.isMarkdown,
size: note.size,
notebookId: note.notebookId,
},
})
})
}
export function parseNoteHistoryEntry(entry: any): NoteHistoryEntry {
return {
id: entry.id,
noteId: entry.noteId,
userId: entry.userId,
version: entry.version,
reason: entry.reason ?? null,
title: entry.title ?? null,
content: entry.content,
color: entry.color,
isPinned: entry.isPinned,
isArchived: entry.isArchived,
type: entry.type,
checkItems: asArray(entry.checkItems, null as any) ?? null,
labels: asArray(entry.labels) || null,
images: asArray(entry.images) || null,
links: asArray(entry.links) || null,
isMarkdown: entry.isMarkdown,
size: entry.size,
notebookId: entry.notebookId ?? null,
createdAt: entry.createdAt,
}
}

View File

@@ -1,232 +0,0 @@
export interface CheckItem {
id: string;
text: string;
checked: boolean;
}
/**
* Notebook model for organizing notes
*/
export interface Notebook {
id: string;
name: string;
icon: string | null;
color: string | null;
order: number;
userId: string;
createdAt: Date;
updatedAt: Date;
// Relations
notes?: Note[];
labels?: Label[];
}
/**
* Label model - contextual to notebooks
*/
export interface Label {
id: string;
name: string;
color: LabelColorName;
notebookId: string | null; // NEW: Belongs to a notebook
userId?: string | null; // DEPRECATED: Kept for backward compatibility
createdAt: Date;
updatedAt: Date;
// Relations
notebook?: Notebook | null;
}
export interface LinkMetadata {
url: string;
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
}
export interface Note {
id: string;
title: string | null;
content: string;
color: string;
isPinned: boolean;
isArchived: boolean;
trashedAt?: Date | null;
type: 'text' | 'checklist';
checkItems: CheckItem[] | null;
labels: string[] | null; // DEPRECATED: Array of label names stored as JSON string
images: string[] | null;
links: LinkMetadata[] | null;
reminder: Date | null;
isReminderDone: boolean;
reminderRecurrence: string | null;
reminderLocation: string | null;
isMarkdown: boolean;
dismissedFromRecent?: boolean;
size: NoteSize;
order: number;
createdAt: Date;
updatedAt: Date;
contentUpdatedAt: Date;
sharedWith?: string[];
userId?: string | null;
// Notebook relation (optional - null = "General Notes" / Inbox)
notebookId?: string | null;
notebook?: Notebook | null;
autoGenerated?: boolean | null;
aiProvider?: string | null;
// Search result metadata (optional)
matchType?: 'exact' | 'related' | null;
searchScore?: number | null;
}
export interface NoteHistoryEntry {
id: string;
noteId: string;
userId: string;
version: number;
reason: string | null;
title: string | null;
content: string;
color: string;
isPinned: boolean;
isArchived: boolean;
type: 'text' | 'checklist';
checkItems: CheckItem[] | null;
labels: string[] | null;
images: string[] | null;
links: LinkMetadata[] | null;
isMarkdown: boolean;
size: NoteSize;
notebookId: string | null;
createdAt: Date;
}
export type NoteSize = 'small' | 'medium' | 'large';
export interface LabelWithColor {
name: string;
color: LabelColorName;
}
export const LABEL_COLORS = {
gray: {
bg: 'bg-zinc-100 dark:bg-zinc-800',
text: 'text-zinc-700 dark:text-zinc-300',
border: 'border-zinc-200 dark:border-zinc-700',
icon: 'text-zinc-500 dark:text-zinc-400'
},
red: {
bg: 'bg-rose-100 dark:bg-rose-900/40',
text: 'text-rose-700 dark:text-rose-300',
border: 'border-rose-200 dark:border-rose-800',
icon: 'text-rose-500 dark:text-rose-400'
},
orange: {
bg: 'bg-orange-100 dark:bg-orange-900/40',
text: 'text-orange-700 dark:text-orange-300',
border: 'border-orange-200 dark:border-orange-800',
icon: 'text-orange-500 dark:text-orange-400'
},
yellow: {
bg: 'bg-amber-100 dark:bg-amber-900/40',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-200 dark:border-amber-800',
icon: 'text-amber-500 dark:text-amber-400'
},
green: {
bg: 'bg-emerald-100 dark:bg-emerald-900/40',
text: 'text-emerald-700 dark:text-emerald-300',
border: 'border-emerald-200 dark:border-emerald-800',
icon: 'text-emerald-500 dark:text-emerald-400'
},
teal: {
bg: 'bg-teal-100 dark:bg-teal-900/40',
text: 'text-teal-700 dark:text-teal-300',
border: 'border-teal-200 dark:border-teal-800',
icon: 'text-teal-500 dark:text-teal-400'
},
blue: {
bg: 'bg-sky-100 dark:bg-sky-900/40',
text: 'text-sky-700 dark:text-sky-300',
border: 'border-sky-200 dark:border-sky-800',
icon: 'text-sky-500 dark:text-sky-400'
},
purple: {
bg: 'bg-violet-100 dark:bg-violet-900/40',
text: 'text-violet-700 dark:text-violet-300',
border: 'border-violet-200 dark:border-violet-800',
icon: 'text-violet-500 dark:text-violet-400'
},
pink: {
bg: 'bg-fuchsia-100 dark:bg-fuchsia-900/40',
text: 'text-fuchsia-700 dark:text-fuchsia-300',
border: 'border-fuchsia-200 dark:border-fuchsia-800',
icon: 'text-fuchsia-500 dark:text-fuchsia-400'
},
} as const;
export type LabelColorName = keyof typeof LABEL_COLORS
export const NOTE_COLORS = {
default: {
bg: 'bg-white dark:bg-zinc-900',
hover: 'hover:bg-gray-50 dark:hover:bg-zinc-800',
card: 'bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-700'
},
red: {
bg: 'bg-red-50 dark:bg-red-950/30',
hover: 'hover:bg-red-100 dark:hover:bg-red-950/50',
card: 'bg-red-50 dark:bg-red-950/30 border-red-100 dark:border-red-900/50'
},
orange: {
bg: 'bg-orange-50 dark:bg-orange-950/30',
hover: 'hover:bg-orange-100 dark:hover:bg-orange-950/50',
card: 'bg-orange-50 dark:bg-orange-950/30 border-orange-100 dark:border-orange-900/50'
},
yellow: {
bg: 'bg-yellow-50 dark:bg-yellow-950/30',
hover: 'hover:bg-yellow-100 dark:hover:bg-yellow-950/50',
card: 'bg-yellow-50 dark:bg-yellow-950/30 border-yellow-100 dark:border-yellow-900/50'
},
green: {
bg: 'bg-green-50 dark:bg-green-950/30',
hover: 'hover:bg-green-100 dark:hover:bg-green-950/50',
card: 'bg-green-50 dark:bg-green-950/30 border-green-100 dark:border-green-900/50'
},
teal: {
bg: 'bg-teal-50 dark:bg-teal-950/30',
hover: 'hover:bg-teal-100 dark:hover:bg-teal-950/50',
card: 'bg-teal-50 dark:bg-teal-950/30 border-teal-100 dark:border-teal-900/50'
},
blue: {
bg: 'bg-blue-50 dark:bg-blue-950/30',
hover: 'hover:bg-blue-100 dark:hover:bg-blue-950/50',
card: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-900/50'
},
purple: {
bg: 'bg-purple-50 dark:bg-purple-950/30',
hover: 'hover:bg-purple-100 dark:hover:bg-purple-950/50',
card: 'bg-purple-50 dark:bg-purple-950/30 border-purple-100 dark:border-purple-900/50'
},
pink: {
bg: 'bg-pink-50 dark:bg-pink-950/30',
hover: 'hover:bg-pink-100 dark:hover:bg-pink-950/50',
card: 'bg-pink-50 dark:bg-pink-950/30 border-pink-100 dark:border-pink-900/50'
},
gray: {
bg: 'bg-gray-100 dark:bg-gray-800/50',
hover: 'hover:bg-gray-200 dark:hover:bg-gray-700/50',
card: 'bg-gray-100 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
},
} as const;
export type NoteColor = keyof typeof NOTE_COLORS;
/**
* Query types for adaptive search weighting
* - 'exact': User searched with quotes, looking for exact match (e.g., "Error 404")
* - 'conceptual': User is asking a question or looking for concepts (e.g., "how to cook")
* - 'mixed': No specific pattern detected, use default weights
*/
export type QueryType = 'exact' | 'conceptual' | 'mixed';

View File

@@ -1,107 +0,0 @@
{
"name": "memento",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "prisma generate && next build",
"start": "next start",
"db:generate": "prisma generate",
"db:migrate": "node scripts/safe-migrate.js",
"db:migrate:dev": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:reset": "prisma migrate reset",
"db:switch": "node scripts/switch-db.js",
"setup:env": "node scripts/setup-env.js",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"test:unit": "vitest run",
"test:unit:watch": "vitest watch",
"test:unit:coverage": "vitest run --coverage",
"test:migration": "vitest run tests/migration",
"test:migration:watch": "vitest watch tests/migration"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.7",
"@ai-sdk/react": "^3.0.170",
"@auth/prisma-adapter": "^2.11.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@excalidraw/excalidraw": "^0.18.0",
"@mozilla/readability": "^0.6.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/jsdom": "^28.0.1",
"ai": "^6.0.23",
"autoprefixer": "^10.4.23",
"bcryptjs": "^3.0.3",
"buffer": "^6.0.3",
"cheerio": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"jsdom": "^29.0.2",
"katex": "^0.16.27",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^8.0.4",
"postcss": "^8.5.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"resend": "^6.12.0",
"rss-parser": "^3.13.0",
"sonner": "^2.0.7",
"sharp": "^0.34.0",
"tailwind-merge": "^3.4.0",
"tinyld": "^1.3.4",
"vazirmatn": "^33.0.3",
"zod": "^4.3.5",
"@prisma/client": "^5.22.0"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/nodemailer": "^7.0.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18",
"prisma": "^5.22.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3",
"vitest": "^4.0.18"
},
"overrides": {
"serialize-javascript": "^7.0.5",
"nodemailer": "^8.0.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-slot": "^1.2.4",
"lodash-es": "^4.17.21",
"nanoid": "^3.3.8"
}
}

View File

@@ -1,43 +0,0 @@
-- CreateTable
CREATE TABLE "NoteHistory" (
"id" TEXT NOT NULL,
"noteId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"reason" TEXT,
"title" TEXT,
"content" TEXT NOT NULL,
"color" TEXT NOT NULL,
"isPinned" BOOLEAN NOT NULL,
"isArchived" BOOLEAN NOT NULL,
"type" TEXT NOT NULL,
"checkItems" TEXT,
"labels" TEXT,
"images" TEXT,
"links" TEXT,
"isMarkdown" BOOLEAN NOT NULL,
"size" TEXT NOT NULL,
"notebookId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NoteHistory_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "UserAISettings"
ADD COLUMN "noteHistory" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE UNIQUE INDEX "NoteHistory_noteId_version_key" ON "NoteHistory"("noteId", "version");
-- CreateIndex
CREATE INDEX "NoteHistory_noteId_createdAt_idx" ON "NoteHistory"("noteId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "NoteHistory_userId_noteId_createdAt_idx" ON "NoteHistory"("userId", "noteId", "createdAt" DESC);
-- AddForeignKey
ALTER TABLE "NoteHistory" ADD CONSTRAINT "NoteHistory_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NoteHistory" ADD CONSTRAINT "NoteHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,409 +0,0 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["debian-openssl-3.0.x", "native"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
password String?
role String @default("USER")
image String?
theme String @default("light")
resetToken String? @unique
resetTokenExpiry DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cardSizeMode String @default("variable")
accounts Account[]
agents Agent[]
aiFeedback AiFeedback[]
canvases Canvas[]
conversations Conversation[]
labels Label[]
memoryEchoInsights MemoryEchoInsight[]
notes Note[]
noteHistories NoteHistory[]
sentShares NoteShare[] @relation("SentShares")
receivedShares NoteShare[] @relation("ReceivedShares")
notebooks Notebook[]
sessions Session[]
aiSettings UserAISettings?
workflows Workflow[]
}
model Account {
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
model Session {
sessionToken String @unique
userId String
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String
expires DateTime
@@id([identifier, token])
}
model Notebook {
id String @id @default(cuid())
name String
icon String?
color String?
order Int
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agents Agent[]
conversations Conversation[]
labels Label[]
notes Note[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workflows Workflow[]
@@index([userId, order])
@@index([userId])
}
model Label {
id String @id @default(cuid())
name String
color String @default("gray")
notebookId String?
userId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
notes Note[] @relation("LabelToNote")
@@unique([notebookId, name])
@@index([notebookId])
@@index([userId])
}
model Note {
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
type String @default("text")
dismissedFromRecent Boolean @default(false)
checkItems String?
labels String?
images String?
links String?
reminder DateTime?
isReminderDone Boolean @default(false)
reminderRecurrence String?
reminderLocation String?
isMarkdown Boolean @default(false)
size String @default("small")
sharedWith String?
userId String?
order Int @default(0)
notebookId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contentUpdatedAt DateTime @default(now())
autoGenerated Boolean?
aiProvider String?
aiConfidence Int?
language String?
languageConfidence Float?
lastAiAnalysis DateTime?
trashedAt DateTime?
aiFeedback AiFeedback[]
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
notebook Notebook? @relation(fields: [notebookId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
noteEmbedding NoteEmbedding?
shares NoteShare[]
labelRelations Label[] @relation("LabelToNote")
historyEntries NoteHistory[]
@@index([isPinned])
@@index([isArchived])
@@index([trashedAt])
@@index([order])
@@index([reminder])
@@index([userId])
@@index([userId, notebookId])
}
model NoteHistory {
id String @id @default(cuid())
noteId String
userId String
version Int
reason String?
title String?
content String
color String
isPinned Boolean
isArchived Boolean
type String
checkItems String?
labels String?
images String?
links String?
isMarkdown Boolean
size String
notebookId String?
createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([noteId, version])
@@index([noteId, createdAt(sort: Desc)])
@@index([userId, noteId, createdAt(sort: Desc)])
}
model NoteShare {
id String @id @default(cuid())
noteId String
userId String
sharedBy String
status String @default("pending")
permission String @default("view")
notifiedAt DateTime?
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade)
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade)
@@unique([noteId, userId])
@@index([userId])
@@index([status])
@@index([sharedBy])
}
model SystemConfig {
key String @id
value String
}
model AiFeedback {
id String @id @default(cuid())
noteId String
userId String?
feedbackType String
feature String
originalContent String
correctedContent String?
metadata String?
createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([noteId])
@@index([userId])
@@index([feature])
}
model MemoryEchoInsight {
id String @id @default(cuid())
userId String?
note1Id String
note2Id String
similarityScore Float
insight String
insightDate DateTime @default(now())
viewed Boolean @default(false)
feedback String?
dismissed Boolean @default(false)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, insightDate])
@@index([userId, insightDate])
@@index([userId, dismissed])
}
model UserAISettings {
userId String @id
titleSuggestions Boolean @default(true)
semanticSearch Boolean @default(true)
paragraphRefactor Boolean @default(true)
memoryEcho Boolean @default(true)
memoryEchoFrequency String @default("daily")
aiProvider String @default("auto")
preferredLanguage String @default("auto")
fontSize String @default("medium")
demoMode Boolean @default(false)
showRecentNotes Boolean @default(true)
/// "masonry" = grille cartes Muuri ; "tabs" = onglets + panneau (type OneNote). Ancienne valeur "list" migrée vers "tabs" en lecture.
notesViewMode String @default("masonry")
emailNotifications Boolean @default(false)
desktopNotifications Boolean @default(false)
anonymousAnalytics Boolean @default(false)
autoLabeling Boolean @default(true)
noteHistory Boolean @default(false)
languageDetection Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([memoryEcho])
@@index([aiProvider])
@@index([memoryEchoFrequency])
@@index([preferredLanguage])
}
model NoteEmbedding {
id String @id @default(cuid())
noteId String @unique
embedding String
createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@index([noteId])
}
model Agent {
id String @id @default(cuid())
name String
description String?
type String? @default("scraper")
role String
sourceUrls String?
frequency String @default("manual")
lastRun DateTime?
nextRun DateTime?
isEnabled Boolean @default(true)
targetNotebookId String?
sourceNotebookId String?
tools String? @default("[]")
maxSteps Int @default(10)
notifyEmail Boolean @default(false)
includeImages Boolean @default(false)
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scheduledTime String? @default("08:00")
scheduledDay Int?
timezone String?
notebook Notebook? @relation(fields: [targetNotebookId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
actions AgentAction[]
@@index([userId])
@@index([isEnabled])
}
model AgentAction {
id String @id @default(cuid())
agentId String
status String @default("pending")
result String?
log String?
input String?
toolLog String?
tokensUsed Int?
createdAt DateTime @default(now())
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
@@index([agentId])
}
model Conversation {
id String @id @default(cuid())
title String?
userId String
notebookId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages ChatMessage[]
notebook Notebook? @relation(fields: [notebookId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([notebookId])
}
model ChatMessage {
id String @id @default(cuid())
conversationId String
role String
content String
createdAt DateTime @default(now())
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([conversationId])
}
model Canvas {
id String @id @default(cuid())
name String
data String
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model Workflow {
id String @id @default(cuid())
name String
description String?
graph String @default("{\"nodes\":[],\"edges\":[]}")
isEnabled Boolean @default(true)
userId String
notebookId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notebook Notebook? @relation(fields: [notebookId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
runs WorkflowRun[]
@@index([userId])
@@index([isEnabled])
}
model WorkflowRun {
id String @id @default(cuid())
workflowId String
status String @default("running")
log String?
createdAt DateTime @default(now())
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
@@index([workflowId])
@@index([status])
}

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
require('dotenv').config({ path: path.join(__dirname, '..', '.env') })
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
...options,
})
if (result.status !== 0) {
process.exit(result.status || 1)
}
}
function nowStamp() {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
}
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error('[safe-migrate] DATABASE_URL is missing in environment/.env')
process.exit(1)
}
const backupsDir = path.join(__dirname, '..', 'backups', 'migrations')
fs.mkdirSync(backupsDir, { recursive: true })
const isPostgres = databaseUrl.startsWith('postgres://') || databaseUrl.startsWith('postgresql://')
const isSqlite = databaseUrl.startsWith('file:')
console.log('[safe-migrate] Starting safe migration flow')
if (isPostgres) {
const backupFile = path.join(backupsDir, `pre_migrate_${nowStamp()}.sql`)
console.log(`[safe-migrate] Creating PostgreSQL backup: ${backupFile}`)
let dump = spawnSync(
'pg_dump',
['--no-owner', '--no-privileges', '--format=plain', '--file', backupFile, databaseUrl],
{ stdio: 'inherit', shell: process.platform === 'win32' }
)
if (dump.status !== 0) {
console.warn('[safe-migrate] Local pg_dump unavailable, trying Docker fallback...')
const pgUser = process.env.POSTGRES_USER || 'memento'
const pgDb = process.env.POSTGRES_DB || 'memento'
const dockerCmd = `docker exec memento-postgres pg_dump -U ${pgUser} -d ${pgDb} --no-owner --no-privileges --format=plain > "${backupFile}"`
dump = spawnSync(dockerCmd, { stdio: 'inherit', shell: true })
}
if (dump.status !== 0) {
console.error('[safe-migrate] Backup failed (local + docker). Migration aborted to protect data.')
process.exit(dump.status || 1)
}
} else if (isSqlite) {
const dbPath = databaseUrl.replace(/^file:/, '')
const absoluteDbPath = path.isAbsolute(dbPath) ? dbPath : path.join(__dirname, '..', dbPath)
if (fs.existsSync(absoluteDbPath)) {
const backupFile = path.join(backupsDir, `pre_migrate_${nowStamp()}.sqlite`)
console.log(`[safe-migrate] Creating SQLite backup: ${backupFile}`)
fs.copyFileSync(absoluteDbPath, backupFile)
} else {
console.warn(`[safe-migrate] SQLite file not found at ${absoluteDbPath}, skipping backup`)
}
} else {
console.warn('[safe-migrate] Unknown DATABASE_URL protocol, skipping backup step')
}
console.log('[safe-migrate] Applying migrations with prisma migrate deploy')
run('npx', ['prisma', 'migrate', 'deploy'])
console.log('[safe-migrate] Migration completed successfully')

View File

@@ -41,6 +41,7 @@ services:
- NEXT_TELEMETRY_DISABLED=1
volumes:
- uploads-data:/app/public/uploads
- backup-data:/app/data/backups
depends_on:
postgres:
condition: service_healthy
@@ -136,6 +137,8 @@ volumes:
driver: local
uploads-data:
driver: local
backup-data:
driver: local
ollama-data:
driver: local

View File

@@ -41,6 +41,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs

View File

@@ -4,6 +4,7 @@ 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<{
@@ -82,6 +83,19 @@ export async function applyTitleSuggestion(
}
})
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) {

View File

@@ -2,6 +2,7 @@ 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(
@@ -76,6 +77,19 @@ export async function POST(
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).

View File

@@ -51,6 +51,8 @@ services:
volumes:
# Persist uploaded images and files
- keep-uploads:/app/data/uploads
# Persist migration backups
- backup-data:/app/data/backups
# Optional: Mount custom SSL certificates
# - ./certs:/app/certs:ro
@@ -106,5 +108,7 @@ volumes:
driver: local
keep-uploads:
driver: local
backup-data:
driver: local
# ollama-data:
# driver: local

View File

@@ -1,8 +1,149 @@
#!/bin/sh
# ============================================================
# Memento Note — Docker Entrypoint
# Automatic DB migration with backup, retry, and cleanup.
# Safe for production: no data loss on image updates.
# ============================================================
set -e
echo "Running Prisma migrations..."
node ./node_modules/prisma/build/index.js migrate deploy
BACKUP_DIR="/app/data/backups"
MAX_BACKUPS=5
DB_WAIT_RETRIES=30
DB_WAIT_INTERVAL=2
MIGRATE_TIMEOUT=120
# --- Logging ---
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [entrypoint] $*"; }
warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [entrypoint] WARNING: $*" >&2; }
err() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [entrypoint] ERROR: $*" >&2; }
# --- Detect database type ---
DB_TYPE="unknown"
case "$DATABASE_URL" in
postgres://*|postgresql://*) DB_TYPE="postgres" ;;
file:*) DB_TYPE="sqlite" ;;
esac
log "Database type: $DB_TYPE"
# --- Wait for database to be reachable ---
wait_for_db() {
if [ "$DB_TYPE" = "sqlite" ]; then
log "SQLite — no connection check needed."
return 0
fi
log "Waiting for database connection..."
i=0
while [ "$i" -lt "$DB_WAIT_RETRIES" ]; do
if node -e "
const m = process.env.DATABASE_URL.match(/@([^:\/]+):(\d+)/);
if (!m) process.exit(1);
const net = require('net');
const s = net.createConnection(parseInt(m[2]), m[1], () => { s.end(); process.exit(0); });
s.setTimeout(3000, () => { s.destroy(); process.exit(1); });
s.on('error', () => process.exit(1));
" 2>/dev/null; then
log "Database is reachable."
return 0
fi
i=$((i + 1))
log " Attempt $i/$DB_WAIT_RETRIES — retrying in ${DB_WAIT_INTERVAL}s..."
sleep "$DB_WAIT_INTERVAL"
done
err "Database unreachable after $((DB_WAIT_RETRIES * DB_WAIT_INTERVAL)) seconds. Aborting."
exit 1
}
# --- Create backup before migration ---
create_backup() {
mkdir -p "$BACKUP_DIR"
ts=$(date '+%Y%m%d_%H%M%S')
case "$DB_TYPE" in
postgres)
backup_file="$BACKUP_DIR/pre_migrate_${ts}.sql.gz"
log "Creating PostgreSQL backup: $backup_file"
if command -v pg_dump >/dev/null 2>&1; then
if pg_dump --no-owner --no-privileges --format=plain "$DATABASE_URL" 2>/dev/null | gzip > "$backup_file" 2>/dev/null; then
size=$(du -h "$backup_file" | cut -f1)
log "Backup created successfully ($size)"
else
warn "pg_dump failed. Continuing without backup."
rm -f "$backup_file"
fi
else
warn "pg_dump not available. Skipping PostgreSQL backup."
warn "Install postgresql-client in the Docker image for automatic backups."
fi
;;
sqlite)
db_path="${DATABASE_URL#file:}"
# Handle relative paths
case "$db_path" in
/*) ;;
*) db_path="/app/$db_path" ;;
esac
backup_file="$BACKUP_DIR/pre_migrate_${ts}.sqlite"
if [ -f "$db_path" ]; then
log "Creating SQLite backup: $backup_file"
cp "$db_path" "$backup_file"
size=$(du -h "$backup_file" | cut -f1)
log "Backup created successfully ($size)"
else
warn "SQLite file not found at $db_path — skipping backup (first run)."
fi
;;
*)
warn "Unknown database type '$DB_TYPE'. Skipping backup."
;;
esac
}
# --- Clean up old backups (keep last N) ---
cleanup_old_backups() {
count=$(ls -1 "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | wc -l)
if [ "$count" -gt "$MAX_BACKUPS" ]; then
to_remove=$((count - MAX_BACKUPS))
log "Cleaning up $to_remove old backup(s), keeping last $MAX_BACKUPS..."
ls -1t "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | tail -n "$to_remove" | xargs rm -f
fi
}
# --- Run Prisma migrations ---
run_migrations() {
log "Running Prisma migrations..."
if timeout "$MIGRATE_TIMEOUT" node ./node_modules/prisma/build/index.js migrate deploy 2>&1; then
log "Migrations applied successfully."
return 0
else
exit_code=$?
err "Migration failed (exit code $exit_code)."
return "$exit_code"
fi
}
# ============================================================
# Main flow
# ============================================================
# Step 1: Wait for database
wait_for_db
# Step 2: Backup
create_backup
# Step 3: Cleanup old backups
cleanup_old_backups
# Step 4: Migrate
if ! run_migrations; then
err "Migration failed — server will NOT start to prevent data corruption."
err "A backup was saved in $BACKUP_DIR (if backup was successful)."
err "To restore: gunzip the .sql.gz file, then: psql DATABASE_URL < backup.sql"
exit 1
fi
# Background scheduler: call /api/cron/agents every 5 minutes
(
@@ -22,5 +163,6 @@ node ./node_modules/prisma/build/index.js migrate deploy
done
) &
echo "Starting server..."
# Step 5: Start server
log "Starting server..."
exec node server.js