feat: robust automatic DB migration for Docker deployments
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
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:
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"worktrees": {},
|
||||
"sessions": {},
|
||||
"tabOrder": {
|
||||
"local": [
|
||||
"pending:1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user