feat: smart note history with manual/auto modes, delete entries, i18n fixes
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m16s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m16s
- Add noteHistoryMode setting (manual default / auto) with DB migration - Manual mode: commit button in editor toolbar creates snapshots on demand - Auto mode: smart snapshots with 20-char diff threshold + 5min cooldown, structural changes (color, pin, archive, labels) bypass cooldown - Add delete individual history entries from history modal - Fix sidebar: Notes nav no longer active on notebook pages - Fix sidebar icon: replace filled Lightbulb with outlined FileText - Fix title suggestions: change from amber to sky blue color scheme - Fix hydration mismatch: add suppressHydrationWarning on locale dates - Complete i18n: add history, sort, and AI chat translations for all 16 languages - Translate French AI assistant section (40+ keys) from English to French - Update README with new features and stack info Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,10 @@ Keep Notes est une application avancée de prise de notes hybride, combinant la
|
|||||||
- **Auto-Tagging** : Création automatique d'étiquettes pertinentes.
|
- **Auto-Tagging** : Création automatique d'étiquettes pertinentes.
|
||||||
- **Organisation par lots** (Batch Organization) : Tri automatique des notes en vrac.
|
- **Organisation par lots** (Batch Organization) : Tri automatique des notes en vrac.
|
||||||
- **Amélioration textuelle** : Reformulation, synthèse, ou traduction propulsés par l'IA.
|
- **Amélioration textuelle** : Reformulation, synthèse, ou traduction propulsés par l'IA.
|
||||||
|
- **Historique des notes** : Snapshots de versions avec deux modes :
|
||||||
|
- **Manuel** (par défaut) : Création de snapshots via un bouton "Commit" dans l'éditeur.
|
||||||
|
- **Automatique (intelligent)** : Snapshots automatiques avec détection de changements significatifs (diff 20+ chars) et cooldown de 5 min. Les changements structurels (couleur, épingle, labels) contournent le cooldown.
|
||||||
|
- **Assistant IA** : Chat contextuel avec support de recherche web, insights et historique des conversations.
|
||||||
- **Haute Performance (RSC & Turbopack)** : Rendu Server Components natif pour une hydratation sans délai et développement accéléré via Turbopack.
|
- **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 et Droits d'Auteur
|
||||||
@@ -41,10 +45,10 @@ Une version complète de **Keep Notes** destinée au grand public est prévue et
|
|||||||
|
|
||||||
## 🛠️ Stack Technique
|
## 🛠️ Stack Technique
|
||||||
|
|
||||||
- **Framework** : Next.js 15 (App Router, Server Components)
|
- **Framework** : Next.js 16 (App Router, Server Components, Turbopack)
|
||||||
- **Frontend** : React 19, Tailwind CSS, Radix UI primitives
|
- **Frontend** : React 19, Tailwind CSS, Radix UI primitives
|
||||||
- **Drag & Drop** : `@dnd-kit/core` & `sortable`
|
- **Drag & Drop** : `@dnd-kit/core` & `sortable`
|
||||||
- **Base de Données** : Prisma ORM, SQLite en env de développement (bientôt PostgreSQL)
|
- **Base de Données** : Prisma ORM, PostgreSQL (prod) / SQLite (dev)
|
||||||
- **Outillage** : Turbopack, TypeScript
|
- **Outillage** : Turbopack, TypeScript
|
||||||
|
|
||||||
## 💻 Instructions de Développement
|
## 💻 Instructions de Développement
|
||||||
@@ -62,6 +66,19 @@ npx prisma generate
|
|||||||
npx prisma db push
|
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)
|
### Lancement du serveur (avec Turbopack)
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export default async function HomePage() {
|
|||||||
initialSettings={{
|
initialSettings={{
|
||||||
showRecentNotes: settings?.showRecentNotes !== false,
|
showRecentNotes: settings?.showRecentNotes !== false,
|
||||||
notesViewMode,
|
notesViewMode,
|
||||||
|
noteHistory: settings?.noteHistory === true,
|
||||||
|
noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export type UserAISettingsData = {
|
|||||||
fontSize?: 'small' | 'medium' | 'large'
|
fontSize?: 'small' | 'medium' | 'large'
|
||||||
languageDetection?: boolean
|
languageDetection?: boolean
|
||||||
autoLabeling?: boolean
|
autoLabeling?: boolean
|
||||||
|
noteHistory?: boolean
|
||||||
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
||||||
@@ -41,6 +43,8 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
|
|||||||
'anonymousAnalytics',
|
'anonymousAnalytics',
|
||||||
'languageDetection',
|
'languageDetection',
|
||||||
'autoLabeling',
|
'autoLabeling',
|
||||||
|
'noteHistory',
|
||||||
|
'noteHistoryMode',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
||||||
@@ -151,6 +155,8 @@ const getCachedAISettings = unstable_cache(
|
|||||||
fontSize: 'medium' as const,
|
fontSize: 'medium' as const,
|
||||||
languageDetection: true,
|
languageDetection: true,
|
||||||
autoLabeling: true,
|
autoLabeling: true,
|
||||||
|
noteHistory: false,
|
||||||
|
noteHistoryMode: 'manual' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +186,8 @@ const getCachedAISettings = unstable_cache(
|
|||||||
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large',
|
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large',
|
||||||
languageDetection: settings.languageDetection ?? true,
|
languageDetection: settings.languageDetection ?? true,
|
||||||
autoLabeling: settings.autoLabeling ?? true,
|
autoLabeling: settings.autoLabeling ?? true,
|
||||||
|
noteHistory: settings.noteHistory ?? false,
|
||||||
|
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting AI settings:', error)
|
console.error('Error getting AI settings:', error)
|
||||||
@@ -202,6 +210,8 @@ const getCachedAISettings = unstable_cache(
|
|||||||
fontSize: 'medium' as const,
|
fontSize: 'medium' as const,
|
||||||
languageDetection: true,
|
languageDetection: true,
|
||||||
autoLabeling: true,
|
autoLabeling: true,
|
||||||
|
noteHistory: false,
|
||||||
|
noteHistoryMode: 'manual' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -237,6 +247,8 @@ export async function getAISettings(userId?: string) {
|
|||||||
fontSize: 'medium' as const,
|
fontSize: 'medium' as const,
|
||||||
languageDetection: true,
|
languageDetection: true,
|
||||||
autoLabeling: true,
|
autoLabeling: true,
|
||||||
|
noteHistory: false,
|
||||||
|
noteHistoryMode: 'manual' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +278,8 @@ export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Pro
|
|||||||
return settings.paragraphRefactor
|
return settings.paragraphRefactor
|
||||||
case 'memoryEcho':
|
case 'memoryEcho':
|
||||||
return settings.memoryEcho
|
return settings.memoryEcho
|
||||||
|
case 'noteHistory':
|
||||||
|
return settings.noteHistory
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } f
|
|||||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||||
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
|
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
|
import {
|
||||||
|
createNoteHistorySnapshot,
|
||||||
|
getNoteHistoryMode,
|
||||||
|
isNoteHistoryEnabledForUser,
|
||||||
|
parseNoteHistoryEntry,
|
||||||
|
shouldCaptureHistorySnapshot,
|
||||||
|
shouldCreateAutoSnapshot,
|
||||||
|
} from '@/lib/note-history'
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +65,24 @@ function parseNote(dbNote: any): Note {
|
|||||||
return parseNoteUtil(dbNote)
|
return parseNoteUtil(dbNote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) {
|
||||||
|
const fallbackEmail = `user-${sessionUser.id}@local.momento`
|
||||||
|
const safeEmail = sessionUser.email || fallbackEmail
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { id: sessionUser.id },
|
||||||
|
update: {
|
||||||
|
...(sessionUser.email ? { email: sessionUser.email } : {}),
|
||||||
|
...(sessionUser.name !== undefined ? { name: sessionUser.name } : {}),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: sessionUser.id,
|
||||||
|
email: safeEmail,
|
||||||
|
name: sessionUser.name || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get hash color for labels (copied from utils)
|
// Helper to get hash color for labels (copied from utils)
|
||||||
function getHashColor(name: string): string {
|
function getHashColor(name: string): string {
|
||||||
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
|
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
|
||||||
@@ -316,6 +342,130 @@ export async function getArchivedNotes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getNoteHistory(noteId: string, limit = 30) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return []
|
||||||
|
|
||||||
|
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
||||||
|
if (!enabled) return []
|
||||||
|
|
||||||
|
const clampedLimit = Math.min(Math.max(limit, 1), 100)
|
||||||
|
|
||||||
|
const note = await prisma.note.findFirst({
|
||||||
|
where: { id: noteId, userId: session.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!note) return []
|
||||||
|
|
||||||
|
const entries = await (prisma as any).noteHistory.findMany({
|
||||||
|
where: { noteId: note.id, userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: clampedLimit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries.map(parseNoteHistoryEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreNoteVersion(noteId: string, historyEntryId: string) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||||
|
|
||||||
|
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
||||||
|
if (!enabled) throw new Error('History is disabled')
|
||||||
|
|
||||||
|
const [note, historyEntry] = await Promise.all([
|
||||||
|
prisma.note.findFirst({
|
||||||
|
where: { id: noteId, userId: session.user.id },
|
||||||
|
select: { id: true, notebookId: true },
|
||||||
|
}),
|
||||||
|
(prisma as any).noteHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
id: historyEntryId,
|
||||||
|
noteId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!note || !historyEntry) {
|
||||||
|
throw new Error('History entry not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored = await prisma.note.update({
|
||||||
|
where: { id: note.id, userId: session.user.id },
|
||||||
|
data: {
|
||||||
|
title: historyEntry.title,
|
||||||
|
content: historyEntry.content,
|
||||||
|
color: historyEntry.color,
|
||||||
|
isPinned: historyEntry.isPinned,
|
||||||
|
isArchived: historyEntry.isArchived,
|
||||||
|
type: historyEntry.type,
|
||||||
|
checkItems: historyEntry.checkItems,
|
||||||
|
labels: historyEntry.labels,
|
||||||
|
images: historyEntry.images,
|
||||||
|
links: historyEntry.links,
|
||||||
|
isMarkdown: historyEntry.isMarkdown,
|
||||||
|
size: historyEntry.size,
|
||||||
|
notebookId: historyEntry.notebookId,
|
||||||
|
contentUpdatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createNoteHistorySnapshot({
|
||||||
|
noteId: note.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
reason: `restore:v${historyEntry.version}`,
|
||||||
|
})
|
||||||
|
} catch (snapshotError) {
|
||||||
|
console.error('[HISTORY] Failed to create snapshot after restore:', snapshotError)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/')
|
||||||
|
revalidatePath(`/note/${note.id}`)
|
||||||
|
revalidatePath('/archive')
|
||||||
|
if (note.notebookId) revalidatePath(`/notebook/${note.notebookId}`)
|
||||||
|
if (historyEntry.notebookId && historyEntry.notebookId !== note.notebookId) {
|
||||||
|
revalidatePath(`/notebook/${historyEntry.notebookId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseNote(restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitNoteHistory(noteId: string) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||||
|
|
||||||
|
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
||||||
|
if (!enabled) throw new Error('History is disabled')
|
||||||
|
|
||||||
|
const note = await prisma.note.findFirst({
|
||||||
|
where: { id: noteId, userId: session.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!note) throw new Error('Note not found')
|
||||||
|
|
||||||
|
await createNoteHistorySnapshot({
|
||||||
|
noteId: note.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
reason: 'manual-commit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNoteHistoryEntry(noteId: string, historyEntryId: string) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||||
|
|
||||||
|
const entry = await (prisma as any).noteHistory.findFirst({
|
||||||
|
where: { id: historyEntryId, noteId, userId: session.user.id },
|
||||||
|
})
|
||||||
|
if (!entry) throw new Error('History entry not found')
|
||||||
|
|
||||||
|
await (prisma as any).noteHistory.delete({
|
||||||
|
where: { id: historyEntryId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Search notes - DB-side filtering (fast) with optional semantic search
|
// Search notes - DB-side filtering (fast) with optional semantic search
|
||||||
// Supports contextual search within notebook (IA5)
|
// Supports contextual search within notebook (IA5)
|
||||||
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
|
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
|
||||||
@@ -455,6 +605,14 @@ export async function createNote(data: {
|
|||||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Defensive guard: after DB reset/migration, auth session can exist while User row is missing.
|
||||||
|
// Recreate user row to avoid Note_userId_fkey failures.
|
||||||
|
await ensureSessionUserExists({
|
||||||
|
id: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
name: session.user.name,
|
||||||
|
})
|
||||||
|
|
||||||
// Save note to DB immediately (fast!) — AI operations run in background after
|
// Save note to DB immediately (fast!) — AI operations run in background after
|
||||||
const note = await prisma.note.create({
|
const note = await prisma.note.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -482,6 +640,19 @@ export async function createNote(data: {
|
|||||||
await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id)
|
await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
|
||||||
|
if (historyEnabled) {
|
||||||
|
await createNoteHistorySnapshot({
|
||||||
|
noteId: note.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
reason: 'create',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (snapshotError) {
|
||||||
|
console.error('[HISTORY] Failed to create initial snapshot:', snapshotError)
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.skipRevalidation) {
|
if (!data.skipRevalidation) {
|
||||||
// Revalidate main page (handles both inbox and notebook views via query params)
|
// Revalidate main page (handles both inbox and notebook views via query params)
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
@@ -612,7 +783,7 @@ export async function updateNote(id: string, data: {
|
|||||||
try {
|
try {
|
||||||
const oldNote = await prisma.note.findUnique({
|
const oldNote = await prisma.note.findUnique({
|
||||||
where: { id, userId: session.user.id },
|
where: { id, userId: session.user.id },
|
||||||
select: { labels: true, notebookId: true, reminder: true }
|
select: { labels: true, notebookId: true, reminder: true, content: true, title: true }
|
||||||
})
|
})
|
||||||
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
|
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
|
||||||
const oldNotebookId = oldNote?.notebookId
|
const oldNotebookId = oldNote?.notebookId
|
||||||
@@ -682,6 +853,33 @@ export async function updateNote(id: string, data: {
|
|||||||
await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id)
|
await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
|
||||||
|
if (historyEnabled && shouldCaptureHistorySnapshot(data as Record<string, unknown>)) {
|
||||||
|
const mode = await getNoteHistoryMode(session.user.id)
|
||||||
|
if (mode === 'manual') {
|
||||||
|
// No auto-snapshot in manual mode — user commits explicitly
|
||||||
|
} else {
|
||||||
|
const shouldAuto = await shouldCreateAutoSnapshot({
|
||||||
|
noteId: id,
|
||||||
|
userId: session.user.id,
|
||||||
|
updateData: data as Record<string, unknown>,
|
||||||
|
existingContent: oldNote?.content ?? '',
|
||||||
|
existingTitle: oldNote?.title ?? null,
|
||||||
|
})
|
||||||
|
if (shouldAuto) {
|
||||||
|
await createNoteHistorySnapshot({
|
||||||
|
noteId: id,
|
||||||
|
userId: session.user.id,
|
||||||
|
reason: 'update',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (snapshotError) {
|
||||||
|
console.error('[HISTORY] Failed to create snapshot after update:', snapshotError)
|
||||||
|
}
|
||||||
|
|
||||||
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
|
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
|
||||||
// Content edits (title, content, size, color) use optimistic UI — no refresh needed
|
// Content edits (title, content, size, color) use optimistic UI — no refresh needed
|
||||||
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
||||||
@@ -690,6 +888,9 @@ export async function updateNote(id: string, data: {
|
|||||||
if (isStructuralChange && !options?.skipRevalidation) {
|
if (isStructuralChange && !options?.skipRevalidation) {
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
revalidatePath(`/note/${id}`)
|
revalidatePath(`/note/${id}`)
|
||||||
|
if (data.isArchived !== undefined) {
|
||||||
|
revalidatePath('/archive')
|
||||||
|
}
|
||||||
|
|
||||||
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
|
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
|
||||||
if (oldNotebookId) {
|
if (oldNotebookId) {
|
||||||
|
|||||||
51
memento-note/app/api/notes/[id]/history/route.ts
Normal file
51
memento-note/app/api/notes/[id]/history/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { getNoteHistory, restoreNoteVersion } from '@/app/actions/notes'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const limitRaw = request.nextUrl.searchParams.get('limit')
|
||||||
|
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 30
|
||||||
|
const entries = await getNoteHistory(id, Number.isNaN(limit) ? 30 : limit)
|
||||||
|
return NextResponse.json({ success: true, data: entries })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching note history:', error)
|
||||||
|
return NextResponse.json({ success: false, error: 'Failed to fetch history' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const historyEntryId = body?.historyEntryId
|
||||||
|
|
||||||
|
if (!historyEntryId || typeof historyEntryId !== 'string') {
|
||||||
|
return NextResponse.json({ success: false, error: 'historyEntryId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredNote = await restoreNoteVersion(id, historyEntryId)
|
||||||
|
return NextResponse.json({ success: true, data: restoredNote })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to restore history version'
|
||||||
|
console.error('Error restoring note history:', error)
|
||||||
|
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { parseNote } from '@/lib/utils'
|
import { parseNote } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
createNoteHistorySnapshot,
|
||||||
|
getNoteHistoryMode,
|
||||||
|
isNoteHistoryEnabledForUser,
|
||||||
|
shouldCaptureHistorySnapshot,
|
||||||
|
shouldCreateAutoSnapshot,
|
||||||
|
} from '@/lib/note-history'
|
||||||
|
|
||||||
// GET /api/notes/[id] - Get a single note
|
// GET /api/notes/[id] - Get a single note
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -134,6 +141,32 @@ export async function PUT(
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
|
||||||
|
if (historyEnabled && shouldCaptureHistorySnapshot(updateData)) {
|
||||||
|
const mode = await getNoteHistoryMode(session.user.id)
|
||||||
|
if (mode === 'auto') {
|
||||||
|
const shouldAuto = await shouldCreateAutoSnapshot({
|
||||||
|
noteId: id,
|
||||||
|
userId: session.user.id,
|
||||||
|
updateData,
|
||||||
|
existingContent: existingNote.content ?? '',
|
||||||
|
existingTitle: existingNote.title ?? null,
|
||||||
|
})
|
||||||
|
if (shouldAuto) {
|
||||||
|
await createNoteHistorySnapshot({
|
||||||
|
noteId: id,
|
||||||
|
userId: session.user.id,
|
||||||
|
reason: 'api:update',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// manual mode: no auto-snapshot
|
||||||
|
}
|
||||||
|
} catch (snapshotError) {
|
||||||
|
console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: parseNote(note)
|
data: parseNote(note)
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
|||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{t(statusKeys[action.status] || action.status)}
|
{t(statusKeys[action.status] || action.status)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground" suppressHydrationWarning>
|
||||||
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
|
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface AISettingsPanelProps {
|
|||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
languageDetection: boolean
|
languageDetection: boolean
|
||||||
autoLabeling: boolean
|
autoLabeling: boolean
|
||||||
|
noteHistory: boolean
|
||||||
|
noteHistoryMode: 'manual' | 'auto'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +181,53 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
onChange={(checked) => handleToggle('autoLabeling', checked)}
|
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)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{settings.noteHistory && (
|
||||||
|
<div className="space-y-2 rounded-lg border border-border/50 bg-muted/30 p-3">
|
||||||
|
<p className="text-sm font-medium">{t('notes.historyMode') || 'Mode d\'historique'}</p>
|
||||||
|
<RadioGroup
|
||||||
|
value={settings.noteHistoryMode ?? 'manual'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const mode = value as 'manual' | 'auto'
|
||||||
|
setSettings((s) => ({ ...s, noteHistoryMode: mode }))
|
||||||
|
updateAISettings({ noteHistoryMode: mode }).then(() => {
|
||||||
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<RadioGroupItem value="manual" id="history-manual" />
|
||||||
|
<div className="grid gap-0.5 leading-none">
|
||||||
|
<Label htmlFor="history-manual" className="text-sm font-medium">
|
||||||
|
{t('notes.historyModeManual') || 'Manuel'}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('notes.historyModeManualDesc') || 'Créer des snapshots avec le bouton commit'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<RadioGroupItem value="auto" id="history-auto" />
|
||||||
|
<div className="grid gap-0.5 leading-none">
|
||||||
|
<Label htmlFor="history-auto" className="text-sm font-medium">
|
||||||
|
{t('notes.historyModeAuto') || 'Automatique'}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('notes.historyModeAutoDesc') || 'Snapshots automatiques avec détection intelligente'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Demo Mode Toggle */}
|
{/* Demo Mode Toggle */}
|
||||||
<DemoModeToggle
|
<DemoModeToggle
|
||||||
demoMode={settings.demoMode}
|
demoMode={settings.demoMode}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function ChatSidebar({
|
|||||||
{chat.title || t('chat.untitled')}
|
{chat.title || t('chat.untitled')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] opacity-60 ml-6">
|
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
|
||||||
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
|
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
|||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { Note } from '@/lib/types'
|
import { Note } from '@/lib/types'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { getAllNotes, searchNotes } from '@/app/actions/notes'
|
import { getAllNotes, searchNotes } from '@/app/actions/notes'
|
||||||
import { NoteInput } from '@/components/note-input'
|
import { NoteInput } from '@/components/note-input'
|
||||||
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
||||||
@@ -24,6 +24,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { LabelFilter } from '@/components/label-filter'
|
import { LabelFilter } from '@/components/label-filter'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useHomeView } from '@/context/home-view-context'
|
import { useHomeView } from '@/context/home-view-context'
|
||||||
|
import { NoteHistoryModal } from '@/components/note-history-modal'
|
||||||
|
|
||||||
// Lazy-load heavy dialogs — uniquement chargés à la demande
|
// Lazy-load heavy dialogs — uniquement chargés à la demande
|
||||||
const NoteEditor = dynamic(
|
const NoteEditor = dynamic(
|
||||||
@@ -42,6 +43,8 @@ const AutoLabelSuggestionDialog = dynamic(
|
|||||||
type InitialSettings = {
|
type InitialSettings = {
|
||||||
showRecentNotes: boolean
|
showRecentNotes: boolean
|
||||||
notesViewMode: 'masonry' | 'tabs'
|
notesViewMode: 'masonry' | 'tabs'
|
||||||
|
noteHistory: boolean
|
||||||
|
noteHistoryMode: 'manual' | 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HomeClientProps {
|
interface HomeClientProps {
|
||||||
@@ -59,10 +62,14 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
initialNotes.filter(n => n.isPinned)
|
initialNotes.filter(n => n.isPinned)
|
||||||
)
|
)
|
||||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
||||||
|
const [noteHistoryEnabled, setNoteHistoryEnabled] = useState(initialSettings.noteHistory)
|
||||||
|
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
|
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
|
||||||
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
||||||
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [historyNote, setHistoryNote] = useState<Note | null>(null)
|
||||||
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
||||||
const { labels } = useLabels()
|
const { labels } = useLabels()
|
||||||
const { setControls } = useHomeView()
|
const { setControls } = useHomeView()
|
||||||
@@ -133,6 +140,22 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
if (note) setEditingNote({ note, readOnly: false })
|
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') => {
|
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
|
||||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||||
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||||
@@ -388,6 +411,10 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||||
onSizeChange={handleSizeChange}
|
onSizeChange={handleSizeChange}
|
||||||
currentNotebookId={searchParams.get('notebook')}
|
currentNotebookId={searchParams.get('notebook')}
|
||||||
|
noteHistoryEnabled={noteHistoryEnabled}
|
||||||
|
noteHistoryMode={noteHistoryMode}
|
||||||
|
onOpenHistory={handleOpenHistory}
|
||||||
|
onEnableHistory={handleEnableHistory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -437,6 +464,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
onClose={() => setEditingNote(null)}
|
onClose={() => setEditingNote(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<NoteHistoryModal
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={setHistoryOpen}
|
||||||
|
note={historyNote}
|
||||||
|
enabled={noteHistoryEnabled}
|
||||||
|
onEnableHistory={handleEnableHistory}
|
||||||
|
onRestored={handleHistoryRestored}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ interface MasonryGridProps {
|
|||||||
onEdit?: (note: Note, readOnly?: boolean) => void;
|
onEdit?: (note: Note, readOnly?: boolean) => void;
|
||||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
||||||
isTrashView?: boolean;
|
isTrashView?: boolean;
|
||||||
|
noteHistoryEnabled?: boolean;
|
||||||
|
noteHistoryMode?: 'manual' | 'auto';
|
||||||
|
onOpenHistory?: (note: Note) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
@@ -53,6 +56,8 @@ interface SortableNoteProps {
|
|||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
isOverlay?: boolean;
|
isOverlay?: boolean;
|
||||||
isTrashView?: boolean;
|
isTrashView?: boolean;
|
||||||
|
noteHistoryEnabled?: boolean;
|
||||||
|
onOpenHistory?: (note: Note) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableNoteItem = memo(function SortableNoteItem({
|
const SortableNoteItem = memo(function SortableNoteItem({
|
||||||
@@ -64,6 +69,8 @@ const SortableNoteItem = memo(function SortableNoteItem({
|
|||||||
isDragging,
|
isDragging,
|
||||||
isOverlay,
|
isOverlay,
|
||||||
isTrashView,
|
isTrashView,
|
||||||
|
noteHistoryEnabled,
|
||||||
|
onOpenHistory,
|
||||||
}: SortableNoteProps) {
|
}: SortableNoteProps) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -98,6 +105,8 @@ const SortableNoteItem = memo(function SortableNoteItem({
|
|||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
isTrashView={isTrashView}
|
isTrashView={isTrashView}
|
||||||
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
||||||
|
noteHistoryEnabled={noteHistoryEnabled}
|
||||||
|
onOpenHistory={onOpenHistory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -114,6 +123,8 @@ interface SortableGridSectionProps {
|
|||||||
onDragStartNote: (noteId: string) => void;
|
onDragStartNote: (noteId: string) => void;
|
||||||
onDragEndNote: () => void;
|
onDragEndNote: () => void;
|
||||||
isTrashView?: boolean;
|
isTrashView?: boolean;
|
||||||
|
noteHistoryEnabled?: boolean;
|
||||||
|
onOpenHistory?: (note: Note) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableGridSection = memo(function SortableGridSection({
|
const SortableGridSection = memo(function SortableGridSection({
|
||||||
@@ -124,6 +135,8 @@ const SortableGridSection = memo(function SortableGridSection({
|
|||||||
onDragStartNote,
|
onDragStartNote,
|
||||||
onDragEndNote,
|
onDragEndNote,
|
||||||
isTrashView,
|
isTrashView,
|
||||||
|
noteHistoryEnabled,
|
||||||
|
onOpenHistory,
|
||||||
}: SortableGridSectionProps) {
|
}: SortableGridSectionProps) {
|
||||||
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
||||||
|
|
||||||
@@ -140,6 +153,8 @@ const SortableGridSection = memo(function SortableGridSection({
|
|||||||
onDragEndNote={onDragEndNote}
|
onDragEndNote={onDragEndNote}
|
||||||
isDragging={draggedNoteId === note.id}
|
isDragging={draggedNoteId === note.id}
|
||||||
isTrashView={isTrashView}
|
isTrashView={isTrashView}
|
||||||
|
noteHistoryEnabled={noteHistoryEnabled}
|
||||||
|
onOpenHistory={onOpenHistory}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +165,14 @@ const SortableGridSection = memo(function SortableGridSection({
|
|||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
// Main MasonryGrid component
|
// Main MasonryGrid component
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: MasonryGridProps) {
|
export function MasonryGrid({
|
||||||
|
notes,
|
||||||
|
onEdit,
|
||||||
|
onSizeChange,
|
||||||
|
isTrashView,
|
||||||
|
noteHistoryEnabled = false,
|
||||||
|
onOpenHistory,
|
||||||
|
}: MasonryGridProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
||||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
||||||
@@ -261,6 +283,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
|
|||||||
onDragStartNote={startDrag}
|
onDragStartNote={startDrag}
|
||||||
onDragEndNote={endDrag}
|
onDragEndNote={endDrag}
|
||||||
isTrashView={isTrashView}
|
isTrashView={isTrashView}
|
||||||
|
noteHistoryEnabled={noteHistoryEnabled}
|
||||||
|
onOpenHistory={onOpenHistory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -280,6 +304,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
|
|||||||
onDragStartNote={startDrag}
|
onDragStartNote={startDrag}
|
||||||
onDragEndNote={endDrag}
|
onDragEndNote={endDrag}
|
||||||
isTrashView={isTrashView}
|
isTrashView={isTrashView}
|
||||||
|
noteHistoryEnabled={noteHistoryEnabled}
|
||||||
|
onOpenHistory={onOpenHistory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -294,6 +320,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
isDragging={true}
|
isDragging={true}
|
||||||
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
|
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
|
||||||
|
noteHistoryEnabled={noteHistoryEnabled}
|
||||||
|
onOpenHistory={onOpenHistory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Trash2,
|
Trash2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
History,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { NOTE_COLORS } from "@/lib/types"
|
import { NOTE_COLORS } from "@/lib/types"
|
||||||
@@ -38,6 +39,8 @@ interface NoteActionsProps {
|
|||||||
isTrashView?: boolean
|
isTrashView?: boolean
|
||||||
onRestore?: () => void
|
onRestore?: () => void
|
||||||
onPermanentDelete?: () => void
|
onPermanentDelete?: () => void
|
||||||
|
onOpenHistory?: () => void
|
||||||
|
historyEnabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +60,8 @@ export function NoteActions({
|
|||||||
isTrashView,
|
isTrashView,
|
||||||
onRestore,
|
onRestore,
|
||||||
onPermanentDelete,
|
onPermanentDelete,
|
||||||
|
onOpenHistory,
|
||||||
|
historyEnabled = false,
|
||||||
className
|
className
|
||||||
}: NoteActionsProps) {
|
}: NoteActionsProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
@@ -176,6 +181,15 @@ export function NoteActions({
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</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 */}
|
{/* Size Selector */}
|
||||||
{onSizeChange && (
|
{onSizeChange && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ interface NoteCardProps {
|
|||||||
onResize?: () => void
|
onResize?: () => void
|
||||||
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
||||||
isTrashView?: boolean
|
isTrashView?: boolean
|
||||||
|
noteHistoryEnabled?: boolean
|
||||||
|
onOpenHistory?: (note: Note) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get initials from name
|
// Helper function to get initials from name
|
||||||
@@ -153,7 +155,9 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
isDragging,
|
isDragging,
|
||||||
onResize,
|
onResize,
|
||||||
onSizeChange,
|
onSizeChange,
|
||||||
isTrashView
|
isTrashView,
|
||||||
|
noteHistoryEnabled = false,
|
||||||
|
onOpenHistory,
|
||||||
}: NoteCardProps) {
|
}: NoteCardProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@@ -610,7 +614,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
{/* Footer with Date only */}
|
{/* Footer with Date only */}
|
||||||
<div className="mt-3 flex items-center justify-end">
|
<div className="mt-3 flex items-center justify-end">
|
||||||
{/* Creation Date */}
|
{/* Creation Date */}
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground" suppressHydrationWarning>
|
||||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
|
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -645,6 +649,8 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
isTrashView={isTrashView}
|
isTrashView={isTrashView}
|
||||||
onRestore={handleRestore}
|
onRestore={handleRestore}
|
||||||
onPermanentDelete={handlePermanentDelete}
|
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"
|
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
249
memento-note/components/note-history-modal.tsx
Normal file
249
memento-note/components/note-history-modal.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
'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, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { getNoteHistory, restoreNoteVersion, deleteNoteHistoryEntry } 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'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteEntry = (entryId: string) => {
|
||||||
|
if (!note) return
|
||||||
|
if (!confirm(t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?')) return
|
||||||
|
startRestoring(async () => {
|
||||||
|
try {
|
||||||
|
await deleteNoteHistoryEntry(note.id, entryId)
|
||||||
|
setEntries((prev) => prev.filter((e) => e.id !== entryId))
|
||||||
|
if (selectedId === entryId) {
|
||||||
|
setSelectedId(null)
|
||||||
|
}
|
||||||
|
toast.success(t('notes.versionDeleted') || 'Version supprimée')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete history entry:', 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) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={cn(
|
||||||
|
'group/entry relative 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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(entry.id)}
|
||||||
|
className="w-full text-left"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold text-foreground">
|
||||||
|
v{entry.version}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground" suppressHydrationWarning>
|
||||||
|
{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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteEntry(entry.id) }}
|
||||||
|
className="absolute right-1.5 top-1.5 rounded p-0.5 text-muted-foreground/40 opacity-0 transition-opacity hover:text-red-500 group-hover/entry:opacity-100"
|
||||||
|
title={t('notes.deleteVersion') || 'Supprimer'}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,8 +18,10 @@ import { useLanguage } from '@/lib/i18n'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
updateNote,
|
updateNote,
|
||||||
|
toggleArchive,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
createNote,
|
createNote,
|
||||||
|
commitNoteHistory,
|
||||||
} from '@/app/actions/notes'
|
} from '@/app/actions/notes'
|
||||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +41,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Check,
|
Check,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
History,
|
||||||
|
GitCommitHorizontal,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
@@ -62,6 +66,9 @@ interface NoteInlineEditorProps {
|
|||||||
onDelete?: (noteId: string) => void
|
onDelete?: (noteId: string) => void
|
||||||
onArchive?: (noteId: string) => void
|
onArchive?: (noteId: string) => void
|
||||||
onChange?: (noteId: string, fields: Partial<Note>) => void
|
onChange?: (noteId: string, fields: Partial<Note>) => void
|
||||||
|
onOpenHistory?: (note: Note) => void
|
||||||
|
noteHistoryEnabled?: boolean
|
||||||
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
colorKey: NoteColor
|
colorKey: NoteColor
|
||||||
/** If true and the note is a Markdown note, open directly in preview mode */
|
/** If true and the note is a Markdown note, open directly in preview mode */
|
||||||
defaultPreviewMode?: boolean
|
defaultPreviewMode?: boolean
|
||||||
@@ -90,6 +97,9 @@ export function NoteInlineEditor({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onChange,
|
onChange,
|
||||||
|
onOpenHistory,
|
||||||
|
noteHistoryEnabled = false,
|
||||||
|
noteHistoryMode = 'manual',
|
||||||
colorKey,
|
colorKey,
|
||||||
defaultPreviewMode = false,
|
defaultPreviewMode = false,
|
||||||
}: NoteInlineEditorProps) {
|
}: NoteInlineEditorProps) {
|
||||||
@@ -313,7 +323,8 @@ export function NoteInlineEditor({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
onArchive?.(note.id)
|
onArchive?.(note.id)
|
||||||
try {
|
try {
|
||||||
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
|
await toggleArchive(note.id, !note.isArchived)
|
||||||
|
triggerRefresh()
|
||||||
} catch {
|
} catch {
|
||||||
// Cannot easily revert since onArchive removes from list
|
// Cannot easily revert since onArchive removes from list
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
@@ -479,7 +490,13 @@ export function NoteInlineEditor({
|
|||||||
|
|
||||||
<Button variant="ghost" size="icon"
|
<Button variant="ghost" size="icon"
|
||||||
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
|
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
|
||||||
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false); scheduleSave() }}
|
onClick={() => {
|
||||||
|
const nextIsMarkdown = !isMarkdown
|
||||||
|
setIsMarkdown(nextIsMarkdown)
|
||||||
|
onChange?.(note.id, { isMarkdown: nextIsMarkdown })
|
||||||
|
if (!nextIsMarkdown) setShowMarkdownPreview(false)
|
||||||
|
scheduleSave()
|
||||||
|
}}
|
||||||
title="Markdown">
|
title="Markdown">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -515,6 +532,26 @@ export function NoteInlineEditor({
|
|||||||
|
|
||||||
{/* Right group: meta actions + save indicator */}
|
{/* Right group: meta actions + save indicator */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{noteHistoryEnabled && noteHistoryMode === 'manual' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs text-primary/70 hover:text-primary"
|
||||||
|
title={t('notes.commitVersion')}
|
||||||
|
onClick={() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await commitNoteHistory(note.id)
|
||||||
|
toast.success(t('notes.versionSaved'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('general.error'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
|
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
|
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
|
||||||
@@ -557,6 +594,16 @@ export function NoteInlineEditor({
|
|||||||
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
|
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
|
||||||
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
|
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
|
||||||
</DropdownMenuItem>
|
</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 />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
|
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
|
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
|
||||||
@@ -781,9 +828,9 @@ export function NoteInlineEditor({
|
|||||||
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
||||||
<div className="shrink-0 border-t border-border/20 px-8 py-2">
|
<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">
|
<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 suppressHydrationWarning>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
|
<span suppressHydrationWarning>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,20 +25,49 @@ interface NotesMainSectionProps {
|
|||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
||||||
currentNotebookId?: string | null
|
currentNotebookId?: string | null
|
||||||
|
noteHistoryEnabled?: boolean
|
||||||
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
|
onOpenHistory?: (note: Note) => void
|
||||||
|
onEnableHistory?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) {
|
export function NotesMainSection({
|
||||||
|
notes,
|
||||||
|
viewMode,
|
||||||
|
onEdit,
|
||||||
|
onSizeChange,
|
||||||
|
currentNotebookId,
|
||||||
|
noteHistoryEnabled = false,
|
||||||
|
noteHistoryMode = 'manual',
|
||||||
|
onOpenHistory,
|
||||||
|
onEnableHistory,
|
||||||
|
}: NotesMainSectionProps) {
|
||||||
if (viewMode === 'tabs') {
|
if (viewMode === 'tabs') {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
||||||
<NotesTabsView notes={notes} onEdit={onEdit} currentNotebookId={currentNotebookId} />
|
<NotesTabsView
|
||||||
|
notes={notes}
|
||||||
|
onEdit={onEdit}
|
||||||
|
currentNotebookId={currentNotebookId}
|
||||||
|
noteHistoryEnabled={noteHistoryEnabled}
|
||||||
|
noteHistoryMode={noteHistoryMode}
|
||||||
|
onOpenHistory={onOpenHistory}
|
||||||
|
onEnableHistory={onEnableHistory}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="notes-grid">
|
<div data-testid="notes-grid">
|
||||||
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
|
<MasonryGridLazy
|
||||||
|
notes={notes}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onSizeChange={onSizeChange}
|
||||||
|
noteHistoryEnabled={noteHistoryEnabled}
|
||||||
|
noteHistoryMode={noteHistoryMode}
|
||||||
|
onOpenHistory={onOpenHistory}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, useTransition } from 'react'
|
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
|
||||||
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -24,17 +24,32 @@ import { cn } from '@/lib/utils'
|
|||||||
import { NoteInlineEditor } from '@/components/note-inline-editor'
|
import { NoteInlineEditor } from '@/components/note-inline-editor'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||||
import { updateFullOrderWithoutRevalidation, createNote, deleteNote } from '@/app/actions/notes'
|
import {
|
||||||
|
updateFullOrderWithoutRevalidation,
|
||||||
|
createNote,
|
||||||
|
deleteNote,
|
||||||
|
updateNote,
|
||||||
|
toggleArchive,
|
||||||
|
} from '@/app/actions/notes'
|
||||||
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import {
|
import {
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Hash,
|
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Pin,
|
Pin,
|
||||||
|
PinOff,
|
||||||
FileText,
|
FileText,
|
||||||
Clock,
|
|
||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ListFilter,
|
||||||
|
FolderInput,
|
||||||
|
Archive,
|
||||||
|
Share2,
|
||||||
|
Check,
|
||||||
|
Hash,
|
||||||
|
History,
|
||||||
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -45,8 +60,22 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} 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 { toast } from 'sonner'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { format, type Locale } from 'date-fns'
|
||||||
import { fr } from 'date-fns/locale/fr'
|
import { fr } from 'date-fns/locale/fr'
|
||||||
import { enUS } from 'date-fns/locale/en-US'
|
import { enUS } from 'date-fns/locale/en-US'
|
||||||
|
|
||||||
@@ -54,8 +83,14 @@ interface NotesTabsViewProps {
|
|||||||
notes: Note[]
|
notes: Note[]
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||||
currentNotebookId?: string | null
|
currentNotebookId?: string | null
|
||||||
|
noteHistoryEnabled?: boolean
|
||||||
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
|
onOpenHistory?: (note: Note) => void
|
||||||
|
onEnableHistory?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
|
||||||
|
|
||||||
// Color accent strip for each note
|
// Color accent strip for each note
|
||||||
const COLOR_ACCENT: Record<NoteColor, string> = {
|
const COLOR_ACCENT: Record<NoteColor, string> = {
|
||||||
default: 'bg-primary',
|
default: 'bg-primary',
|
||||||
@@ -104,9 +139,19 @@ function getColorKey(note: Note): NoteColor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDateLocale(language: string) {
|
function getDateLocale(language: string) {
|
||||||
if (language === 'fr') return fr;
|
if (language === 'fr') return fr
|
||||||
if (language === 'fa') return require('date-fns/locale').faIR;
|
if (language === 'fa') return require('date-fns/locale').faIR
|
||||||
return enUS;
|
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 ───────────────────────────────────────────────────────
|
// ─── Sortable List Item ───────────────────────────────────────────────────────
|
||||||
@@ -144,161 +189,445 @@ function SortableNoteListItem({
|
|||||||
const title = getNoteDisplayTitle(note, untitledLabel)
|
const title = getNoteDisplayTitle(note, untitledLabel)
|
||||||
const snippet =
|
const snippet =
|
||||||
note.type === 'checklist'
|
note.type === 'checklist'
|
||||||
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150)
|
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 200)
|
||||||
: (note.content || '').substring(0, 150)
|
: (note.content || '').substring(0, 200)
|
||||||
|
|
||||||
const dateLocale = getDateLocale(language)
|
const dateLocale = getDateLocale(language)
|
||||||
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
|
const dateStr = formatNoteDate(note.updatedAt, dateLocale)
|
||||||
addSuffix: true,
|
|
||||||
locale: dateLocale,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex cursor-pointer select-none items-stretch gap-0 transition-all duration-150',
|
'group relative flex cursor-pointer select-none items-stretch transition-all duration-150',
|
||||||
'border-b border-border/40 last:border-b-0',
|
'border-b border-border/50 last:border-b-0',
|
||||||
selected
|
selected
|
||||||
? 'bg-primary/5 dark:bg-primary/10 shadow-sm'
|
? 'bg-primary/[0.06] dark:bg-primary/10'
|
||||||
: 'hover:bg-muted/50',
|
: 'bg-background hover:bg-muted/40 dark:hover:bg-muted/20',
|
||||||
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30 rounded-lg'
|
isDragging && 'opacity-75 shadow-lg ring-1 ring-primary/20'
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
>
|
>
|
||||||
{/* Color accent bar */}
|
{/* Left accent bar — solid when selected, transparent otherwise */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-1 shrink-0 transition-all duration-200',
|
'w-[3px] shrink-0 rounded-r-full transition-all duration-200',
|
||||||
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
|
selected ? COLOR_ACCENT[ck] : 'bg-transparent'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Drag handle */}
|
{/* Main card content */}
|
||||||
<button
|
<div className="min-w-0 flex-1 px-4 py-4">
|
||||||
type="button"
|
|
||||||
className="flex cursor-grab items-center px-1.5 text-muted-foreground/30 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
|
|
||||||
aria-label={reorderLabel}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Note type icon */}
|
{/* Row 1: type icon + date */}
|
||||||
<div className="flex items-center py-4 pe-1">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
{note.type === 'checklist' ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<ListChecks
|
{note.type === 'checklist' ? (
|
||||||
className={cn(
|
<ListChecks
|
||||||
'h-4 w-4 shrink-0 transition-colors',
|
className={cn(
|
||||||
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
'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" />
|
||||||
<FileText
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 shrink-0 transition-colors',
|
|
||||||
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
)}
|
<span
|
||||||
</div>
|
suppressHydrationWarning
|
||||||
|
|
||||||
{/* Text content */}
|
|
||||||
<div className="min-w-0 flex-1 py-3.5 pe-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'truncate text-base font-heading font-medium transition-colors',
|
'shrink-0 text-[11px] tabular-nums',
|
||||||
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
|
selected ? 'text-muted-foreground' : 'text-muted-foreground/60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title}
|
{dateStr}
|
||||||
</p>
|
|
||||||
{note.isPinned && (
|
|
||||||
<Pin className="h-3 w-3 shrink-0 fill-current text-primary" aria-label="Épinglée" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{snippet && (
|
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">{snippet}</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
|
|
||||||
<Clock className="h-2.5 w-2.5" />
|
|
||||||
{timeAgo}
|
|
||||||
</span>
|
</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 && (
|
{Array.isArray(note.labels) && note.labels.length > 0 && (
|
||||||
<>
|
<div>
|
||||||
<span className="text-muted-foreground/30">·</span>
|
<p className="mb-1.5 text-[11px] font-semibold text-muted-foreground">{ts('notes.labels', 'Labels')}</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<Hash className="h-2.5 w-2.5 text-muted-foreground/40" />
|
{note.labels.map((label) => (
|
||||||
<span className="truncate text-[11px] text-muted-foreground/50">
|
<span
|
||||||
{note.labels.slice(0, 2).join(', ')}
|
key={label}
|
||||||
{note.labels.length > 2 && ` +${note.labels.length - 2}`}
|
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"
|
||||||
</span>
|
>
|
||||||
|
<Hash className="h-2.5 w-2.5" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete button - visible on hover */}
|
{/* ── ACTIONS ── */}
|
||||||
<button
|
<div className="px-4 pt-4 pb-5 flex-1">
|
||||||
type="button"
|
<SidebarSection title={ts('notes.actions', 'Actions')} />
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
<div className="space-y-0.5">
|
||||||
onDelete()
|
|
||||||
}}
|
{/* Move to notebook */}
|
||||||
className="flex items-center px-2 text-red-500/60 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
|
<Popover open={moveOpen} onOpenChange={setMoveOpen}>
|
||||||
aria-label={deleteLabel}
|
<PopoverTrigger asChild>
|
||||||
title={deleteLabel}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Trash2 className="h-4 w-4" />
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
<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 ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
|
export function NotesTabsView({
|
||||||
|
notes,
|
||||||
|
onEdit,
|
||||||
|
currentNotebookId,
|
||||||
|
noteHistoryEnabled = false,
|
||||||
|
noteHistoryMode = 'manual',
|
||||||
|
onOpenHistory,
|
||||||
|
onEnableHistory,
|
||||||
|
}: NotesTabsViewProps) {
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { triggerRefresh } = useNoteRefreshOptional()
|
const { triggerRefresh } = useNoteRefreshOptional()
|
||||||
const [items, setItems] = useState<Note[]>(notes)
|
const [items, setItems] = useState<Note[]>(notes)
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [isCreating, startCreating] = useTransition()
|
const [isCreating, startCreating] = useTransition()
|
||||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
|
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('date-desc')
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only reset when notes are added or removed, NOT on content/field changes
|
|
||||||
// Field changes arrive through onChange -> setItems already
|
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const prevIds = prev.map((n) => n.id).join(',')
|
const prevIds = prev.map((n) => n.id).join(',')
|
||||||
const incomingIds = notes.map((n) => n.id).join(',')
|
const incomingIds = notes.map((n) => n.id).join(',')
|
||||||
if (prevIds === incomingIds) {
|
if (prevIds === incomingIds) {
|
||||||
// Same set of notes: merge only structural fields (pin, color, archive)
|
|
||||||
return prev.map((p) => {
|
return prev.map((p) => {
|
||||||
const fresh = notes.find((n) => n.id === p.id)
|
const fresh = notes.find((n) => n.id === p.id)
|
||||||
if (!fresh) return p
|
if (!fresh) return p
|
||||||
// Use fresh labels from server if they've changed (e.g., global label deletion)
|
|
||||||
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
|
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
|
||||||
return {
|
return {
|
||||||
...fresh,
|
...fresh,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
content: p.content,
|
content: p.content,
|
||||||
checkItems: p.checkItems,
|
checkItems: p.checkItems,
|
||||||
isMarkdown: p.isMarkdown,
|
|
||||||
// Always use server labels if different (for global label changes)
|
|
||||||
labels: labelsChanged ? fresh.labels : p.labels
|
labels: labelsChanged ? fresh.labels : p.labels
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Different set (add/remove) or reordered from server: full sync
|
|
||||||
// CRITICAL: We MUST preserve local text edits so inline editor state isn't lost
|
|
||||||
return notes.map((fresh) => {
|
return notes.map((fresh) => {
|
||||||
const local = prev.find((p) => p.id === fresh.id)
|
const local = prev.find((p) => p.id === fresh.id)
|
||||||
if (!local) return fresh
|
if (!local) return fresh
|
||||||
@@ -308,7 +637,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
title: local.title,
|
title: local.title,
|
||||||
content: local.content,
|
content: local.content,
|
||||||
checkItems: local.checkItems,
|
checkItems: local.checkItems,
|
||||||
isMarkdown: local.isMarkdown,
|
|
||||||
labels: labelsChanged ? fresh.labels : local.labels
|
labels: labelsChanged ? fresh.labels : local.labels
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -325,7 +653,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
)
|
)
|
||||||
}, [items])
|
}, [items])
|
||||||
|
|
||||||
// Listen for global label deletion and immediately update local state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
const { name } = (e as CustomEvent).detail
|
const { name } = (e as CustomEvent).detail
|
||||||
@@ -343,7 +670,22 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
return () => window.removeEventListener('label-deleted', handler)
|
return () => window.removeEventListener('label-deleted', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally
|
// 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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
@@ -372,7 +714,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
const selected = items.find((n) => n.id === selectedId) ?? null
|
const selected = items.find((n) => n.id === selectedId) ?? null
|
||||||
const colorKey = selected ? getColorKey(selected) : 'default'
|
const colorKey = selected ? getColorKey(selected) : 'default'
|
||||||
|
|
||||||
/** Create a new blank note, add it to the sidebar and select it immediately */
|
|
||||||
const handleCreateNote = () => {
|
const handleCreateNote = () => {
|
||||||
startCreating(async () => {
|
startCreating(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -396,52 +737,116 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-2xl border border-border/60 shadow-sm"
|
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)))' }}
|
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
|
||||||
data-testid="notes-grid-tabs"
|
data-testid="notes-grid-tabs"
|
||||||
>
|
>
|
||||||
{/* ── Left sidebar: note list ── */}
|
{/* ── Left panel: note list ── */}
|
||||||
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 bg-muted/20">
|
<div className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">
|
||||||
{/* Sidebar header with note count + new note button */}
|
|
||||||
<div className="border-b border-border/40 px-3 py-2.5">
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-4 py-3.5">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold tracking-tight text-foreground">
|
||||||
{t('notes.title')}
|
{t('notes.title')}
|
||||||
<span className="ms-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
|
||||||
{items.length}
|
|
||||||
</span>
|
|
||||||
</span>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
|
||||||
onClick={handleCreateNote}
|
onClick={handleCreateNote}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
title={t('notes.newNote') }
|
title={t('notes.newNote')}
|
||||||
>
|
>
|
||||||
{isCreating
|
{isCreating
|
||||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
: <Plus className="h-3.5 w-3.5" />}
|
: <Plus className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable note list */}
|
{/* Scrollable note list */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto overscroll-contain p-2"
|
className="flex-1 overflow-y-auto overscroll-contain bg-background"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={t('notes.viewTabs')}
|
aria-label={t('notes.viewTabs')}
|
||||||
>
|
>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
<div className="mb-3 rounded-full bg-background p-3 shadow-sm border border-border/50">
|
<div className="mb-4 rounded-2xl border border-border/60 bg-muted/30 p-4">
|
||||||
<FileText className="h-5 w-5 text-muted-foreground/40" />
|
<FileText className="h-6 w-6 text-muted-foreground/40" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">{t('notes.emptyStateTabs') || 'Aucune note'}</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -454,8 +859,8 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
items={items.map((n) => n.id)}
|
items={items.map((n) => n.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col">
|
||||||
{items.map((note) => (
|
{sortedItems.map((note) => (
|
||||||
<SortableNoteListItem
|
<SortableNoteListItem
|
||||||
key={note.id}
|
key={note.id}
|
||||||
note={note}
|
note={note}
|
||||||
@@ -475,42 +880,74 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right content panel — always in edit mode ── */}
|
{/* ── Right content panel ── */}
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div
|
<div className="flex min-w-0 flex-1 overflow-hidden">
|
||||||
className={cn(
|
{/* Editor */}
|
||||||
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
|
<div
|
||||||
COLOR_PANEL_BG[colorKey]
|
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}
|
||||||
|
noteHistoryMode={noteHistoryMode}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<NoteInlineEditor
|
|
||||||
key={selected.id}
|
|
||||||
note={selected}
|
|
||||||
colorKey={colorKey}
|
|
||||||
defaultPreviewMode={true}
|
|
||||||
onChange={(noteId, fields) => {
|
|
||||||
setItems((prev) =>
|
|
||||||
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
onDelete={(noteId) => {
|
|
||||||
setItems((prev) => prev.filter((n) => n.id !== noteId))
|
|
||||||
setSelectedId((prev) => (prev === noteId ? null : prev))
|
|
||||||
}}
|
|
||||||
onArchive={(noteId) => {
|
|
||||||
setItems((prev) => prev.filter((n) => n.id !== noteId))
|
|
||||||
setSelectedId((prev) => (prev === noteId ? null : prev))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10 border-l border-border/40">
|
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10">
|
||||||
<div className="text-center px-6">
|
<div className="px-10 text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-background shadow-sm border border-border/50">
|
<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-8 w-8 text-muted-foreground/30" />
|
<FileText className="h-7 w-7 text-muted-foreground/30" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-heading font-medium text-foreground">{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}</h3>
|
<p className="text-sm font-medium text-foreground/60">
|
||||||
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
|
{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
|
{items.length === 0
|
||||||
? t('notes.emptyNotebookDesc')
|
? t('notes.emptyNotebookDesc')
|
||||||
: t('notes.selectOrCreateNote')}
|
: t('notes.selectOrCreateNote')}
|
||||||
@@ -528,7 +965,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
|
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
|
||||||
{noteToDelete && (
|
{noteToDelete && (
|
||||||
<span className="mt-2 block font-medium text-foreground">
|
<span className="mt-2 block font-medium text-foreground">
|
||||||
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
|
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -561,4 +998,3 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from 'next/link'
|
|||||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
Lightbulb,
|
FileText,
|
||||||
Bell,
|
Bell,
|
||||||
Archive,
|
Archive,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -81,7 +81,8 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
|||||||
return pathname === '/' &&
|
return pathname === '/' &&
|
||||||
!searchParams.get('label') &&
|
!searchParams.get('label') &&
|
||||||
!searchParams.get('archived') &&
|
!searchParams.get('archived') &&
|
||||||
!searchParams.get('trashed')
|
!searchParams.get('trashed') &&
|
||||||
|
!searchParams.get('notebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
// For labels
|
// For labels
|
||||||
@@ -131,7 +132,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
|||||||
<div className="flex flex-col gap-1 px-3">
|
<div className="flex flex-col gap-1 px-3">
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/"
|
href="/"
|
||||||
icon={Lightbulb}
|
icon={FileText}
|
||||||
label={t('sidebar.notes') || 'Notes'}
|
label={t('sidebar.notes') || 'Notes'}
|
||||||
active={isActive('/')}
|
active={isActive('/')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
|
|||||||
if (suggestions.length === 0) return null
|
if (suggestions.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 p-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
|
<div className="mt-2 p-3 bg-sky-50 dark:bg-sky-950/50 border border-sky-200 dark:border-sky-800 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-900 dark:text-amber-100">
|
<div className="flex items-center gap-2 text-sm font-medium text-sky-900 dark:text-sky-100">
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
<span>{t('titleSuggestions.title')}</span>
|
<span>{t('titleSuggestions.title')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 transition-colors"
|
className="text-sky-500 hover:text-sky-900 dark:text-sky-400 dark:hover:text-sky-200 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -38,19 +38,19 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
|
|||||||
onClick={() => onSelect(suggestion.title)}
|
onClick={() => onSelect(suggestion.title)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left px-3 py-2 rounded-md transition-all",
|
"w-full text-left px-3 py-2 rounded-md transition-all",
|
||||||
"hover:bg-amber-100 dark:hover:bg-amber-900",
|
"hover:bg-sky-100 dark:hover:bg-sky-900/50",
|
||||||
"text-sm text-amber-900 dark:text-amber-100",
|
"text-sm text-sky-900 dark:text-sky-100",
|
||||||
"border border-transparent hover:border-amber-300 dark:hover:border-amber-700"
|
"border border-transparent hover:border-sky-300 dark:hover:border-sky-700"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="font-medium">{suggestion.title}</span>
|
<span className="font-medium">{suggestion.title}</span>
|
||||||
<span className="text-xs text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
<span className="text-xs text-sky-500 dark:text-sky-400 whitespace-nowrap">
|
||||||
{suggestion.confidence}%
|
{suggestion.confidence}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{suggestion.reasoning && (
|
{suggestion.reasoning && (
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
<p className="text-xs text-sky-600 dark:text-sky-300 mt-1">
|
||||||
{suggestion.reasoning}
|
{suggestion.reasoning}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
184
memento-note/lib/note-history.ts
Normal file
184
memento-note/lib/note-history.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { asArray } from '@/lib/utils'
|
||||||
|
import type { NoteHistoryEntry } from '@/lib/types'
|
||||||
|
|
||||||
|
const COOLDOWN_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
const CONTENT_DIFF_THRESHOLD = 20 // characters
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNoteHistoryMode(userId: string): Promise<'manual' | 'auto'> {
|
||||||
|
const settings = await prisma.userAISettings.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { noteHistoryMode: true },
|
||||||
|
})
|
||||||
|
const mode = settings?.noteHistoryMode
|
||||||
|
return mode === 'auto' ? 'auto' : 'manual'
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRUCTURAL_FIELDS = ['color', 'isPinned', 'isArchived', 'labels', 'notebookId'] as const
|
||||||
|
|
||||||
|
export async function shouldCreateAutoSnapshot(params: {
|
||||||
|
noteId: string
|
||||||
|
userId: string
|
||||||
|
updateData: Record<string, unknown>
|
||||||
|
existingContent: string
|
||||||
|
existingTitle: string | null
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { noteId, userId, updateData, existingContent, existingTitle } = params
|
||||||
|
|
||||||
|
// Structural changes (color, pin, archive, labels, notebook) always create a snapshot
|
||||||
|
const hasStructuralChange = STRUCTURAL_FIELDS.some((f) => f in updateData)
|
||||||
|
if (hasStructuralChange) return true
|
||||||
|
|
||||||
|
// Content changes: check diff threshold
|
||||||
|
const newContent = typeof updateData.content === 'string' ? updateData.content : null
|
||||||
|
const newTitle = typeof updateData.title === 'string' ? updateData.title : null
|
||||||
|
|
||||||
|
const contentChanged = newContent !== null
|
||||||
|
const titleChanged = newTitle !== null
|
||||||
|
|
||||||
|
if (!contentChanged && !titleChanged) return false
|
||||||
|
|
||||||
|
// Check cooldown: find the most recent snapshot for this note
|
||||||
|
const lastSnapshot = await (prisma as any).noteHistory.findFirst({
|
||||||
|
where: { noteId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { createdAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (lastSnapshot) {
|
||||||
|
const elapsed = Date.now() - new Date(lastSnapshot.createdAt).getTime()
|
||||||
|
if (elapsed < COOLDOWN_MS) {
|
||||||
|
// Within cooldown — only skip if the diff is trivial
|
||||||
|
const contentDiff = contentChanged
|
||||||
|
? Math.abs((newContent as string).length - existingContent.length)
|
||||||
|
: 0
|
||||||
|
const titleDiff = titleChanged
|
||||||
|
? Math.abs((newTitle ?? '').length - (existingTitle ?? '').length)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (contentDiff < CONTENT_DIFF_THRESHOLD && titleDiff === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -80,6 +80,28 @@ export interface Note {
|
|||||||
searchScore?: number | 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 type NoteSize = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
export interface LabelWithColor {
|
export interface LabelWithColor {
|
||||||
|
|||||||
@@ -979,7 +979,29 @@
|
|||||||
"notes.emptyNotebook": "دفتر فارغ",
|
"notes.emptyNotebook": "دفتر فارغ",
|
||||||
"notes.emptyNotebookDesc": "لا توجد ملاحظات. انقر على + لإنشاء واحدة.",
|
"notes.emptyNotebookDesc": "لا توجد ملاحظات. انقر على + لإنشاء واحدة.",
|
||||||
"notes.noNoteSelected": "لم يتم تحديد ملاحظة",
|
"notes.noNoteSelected": "لم يتم تحديد ملاحظة",
|
||||||
"notes.selectOrCreateNote": "اختر ملاحظة من القائمة أو أنشئ واحدة جديدة."
|
"notes.selectOrCreateNote": "اختر ملاحظة من القائمة أو أنشئ واحدة جديدة.",
|
||||||
|
"commitVersion": "حفظ النسخة",
|
||||||
|
"versionSaved": "تم حفظ النسخة",
|
||||||
|
"deleteVersion": "حذف هذه النسخة",
|
||||||
|
"versionDeleted": "تم حذف النسخة",
|
||||||
|
"deleteVersionConfirm": "حذف هذه النسخة نهائياً؟",
|
||||||
|
"historyMode": "وضع السجل",
|
||||||
|
"historyModeManual": "يدوي (زر الالتزام)",
|
||||||
|
"historyModeAuto": "تلقائي (ذكي)",
|
||||||
|
"historyModeManualDesc": "إنشاء لقطات يدوياً بزر الالتزام",
|
||||||
|
"historyModeAutoDesc": "لقاطات تلقائية بالكشف الذكي",
|
||||||
|
"history": "السجل",
|
||||||
|
"historyRestored": "تم استعادة النسخة",
|
||||||
|
"historyEnabled": "تم تفعيل السجل",
|
||||||
|
"historyDisabledDesc": "السجل معطل لحسابك.",
|
||||||
|
"enableHistory": "تفعيل السجل",
|
||||||
|
"historyEmpty": "لا توجد نسخ متاحة",
|
||||||
|
"historySelectVersion": "اختر نسخة لمعاينة محتواها",
|
||||||
|
"sortBy": "ترتيب حسب",
|
||||||
|
"sortDateDesc": "التاريخ (الأحدث)",
|
||||||
|
"sortDateAsc": "التاريخ (الأقدم)",
|
||||||
|
"sortTitleAsc": "العنوان أ ← ي",
|
||||||
|
"sortTitleDesc": "العنوان ي ← أ"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -1002,7 +1002,29 @@
|
|||||||
"notes.emptyNotebook": "Leeres Notizbuch",
|
"notes.emptyNotebook": "Leeres Notizbuch",
|
||||||
"notes.emptyNotebookDesc": "Keine Notizen vorhanden. Klicke auf + um eine zu erstellen.",
|
"notes.emptyNotebookDesc": "Keine Notizen vorhanden. Klicke auf + um eine zu erstellen.",
|
||||||
"notes.noNoteSelected": "Keine Notiz ausgewählt",
|
"notes.noNoteSelected": "Keine Notiz ausgewählt",
|
||||||
"notes.selectOrCreateNote": "Wähle eine Notiz aus der Liste oder erstelle eine neue."
|
"notes.selectOrCreateNote": "Wähle eine Notiz aus der Liste oder erstelle eine neue.",
|
||||||
|
"commitVersion": "Version speichern",
|
||||||
|
"versionSaved": "Version gespeichert",
|
||||||
|
"deleteVersion": "Diese Version löschen",
|
||||||
|
"versionDeleted": "Version gelöscht",
|
||||||
|
"deleteVersionConfirm": "Diese Version endgültig löschen?",
|
||||||
|
"historyMode": "Verlaufsmodus",
|
||||||
|
"historyModeManual": "Manuell (Commit-Schaltfläche)",
|
||||||
|
"historyModeAuto": "Automatisch (intelligent)",
|
||||||
|
"historyModeManualDesc": "Snapshots manuell mit der Commit-Schaltfläche erstellen",
|
||||||
|
"historyModeAutoDesc": "Automatische Snapshots mit intelligenter Erkennung",
|
||||||
|
"history": "Verlauf",
|
||||||
|
"historyRestored": "Version wiederhergestellt",
|
||||||
|
"historyEnabled": "Verlauf aktiviert",
|
||||||
|
"historyDisabledDesc": "Der Verlauf ist für Ihr Konto deaktiviert.",
|
||||||
|
"enableHistory": "Verlauf aktivieren",
|
||||||
|
"historyEmpty": "Keine Versionen verfügbar",
|
||||||
|
"historySelectVersion": "Wählen Sie eine Version zur Vorschau aus",
|
||||||
|
"sortBy": "Sortieren nach",
|
||||||
|
"sortDateDesc": "Datum (neueste)",
|
||||||
|
"sortDateAsc": "Datum (älteste)",
|
||||||
|
"sortTitleAsc": "Titel A → Z",
|
||||||
|
"sortTitleDesc": "Titel Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -161,7 +161,29 @@
|
|||||||
"notes.emptyNotebook": "Empty notebook",
|
"notes.emptyNotebook": "Empty notebook",
|
||||||
"notes.emptyNotebookDesc": "This notebook has no notes. Click + to create one.",
|
"notes.emptyNotebookDesc": "This notebook has no notes. Click + to create one.",
|
||||||
"notes.noNoteSelected": "No note selected",
|
"notes.noNoteSelected": "No note selected",
|
||||||
"notes.selectOrCreateNote": "Select a note from the list or create a new one."
|
"notes.selectOrCreateNote": "Select a note from the list or create a new one.",
|
||||||
|
"commitVersion": "Save version",
|
||||||
|
"versionSaved": "Version saved",
|
||||||
|
"deleteVersion": "Delete this version",
|
||||||
|
"versionDeleted": "Version deleted",
|
||||||
|
"deleteVersionConfirm": "Delete this version permanently?",
|
||||||
|
"historyMode": "History mode",
|
||||||
|
"historyModeManual": "Manual (commit button)",
|
||||||
|
"historyModeAuto": "Automatic (smart)",
|
||||||
|
"historyModeManualDesc": "Create snapshots manually with the commit button",
|
||||||
|
"historyModeAutoDesc": "Automatic snapshots with smart detection",
|
||||||
|
"history": "History",
|
||||||
|
"historyRestored": "Version restored",
|
||||||
|
"historyEnabled": "History enabled",
|
||||||
|
"historyDisabledDesc": "History is disabled for your account.",
|
||||||
|
"enableHistory": "Enable history",
|
||||||
|
"historyEmpty": "No versions available",
|
||||||
|
"historySelectVersion": "Select a version to preview its content",
|
||||||
|
"sortBy": "Sort by",
|
||||||
|
"sortDateDesc": "Date (newest)",
|
||||||
|
"sortDateAsc": "Date (oldest)",
|
||||||
|
"sortTitleAsc": "Title A → Z",
|
||||||
|
"sortTitleDesc": "Title Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
|
|||||||
@@ -974,7 +974,29 @@
|
|||||||
"notes.emptyNotebook": "Cuaderno vacío",
|
"notes.emptyNotebook": "Cuaderno vacío",
|
||||||
"notes.emptyNotebookDesc": "Este cuaderno no tiene notas. Haz clic en + para crear una.",
|
"notes.emptyNotebookDesc": "Este cuaderno no tiene notas. Haz clic en + para crear una.",
|
||||||
"notes.noNoteSelected": "Ninguna nota seleccionada",
|
"notes.noNoteSelected": "Ninguna nota seleccionada",
|
||||||
"notes.selectOrCreateNote": "Selecciona una nota de la lista o crea una nueva."
|
"notes.selectOrCreateNote": "Selecciona una nota de la lista o crea una nueva.",
|
||||||
|
"commitVersion": "Guardar versión",
|
||||||
|
"versionSaved": "Versión guardada",
|
||||||
|
"deleteVersion": "Eliminar esta versión",
|
||||||
|
"versionDeleted": "Versión eliminada",
|
||||||
|
"deleteVersionConfirm": "¿Eliminar esta versión permanentemente?",
|
||||||
|
"historyMode": "Modo de historial",
|
||||||
|
"historyModeManual": "Manual (botón commit)",
|
||||||
|
"historyModeAuto": "Automático (inteligente)",
|
||||||
|
"historyModeManualDesc": "Crear snapshots manualmente con el botón commit",
|
||||||
|
"historyModeAutoDesc": "Snapshots automáticos con detección inteligente",
|
||||||
|
"history": "Historial",
|
||||||
|
"historyRestored": "Versión restaurada",
|
||||||
|
"historyEnabled": "Historial activado",
|
||||||
|
"historyDisabledDesc": "El historial está desactivado para tu cuenta.",
|
||||||
|
"enableHistory": "Activar historial",
|
||||||
|
"historyEmpty": "No hay versiones disponibles",
|
||||||
|
"historySelectVersion": "Selecciona una versión para previsualizar su contenido",
|
||||||
|
"sortBy": "Ordenar por",
|
||||||
|
"sortDateDesc": "Fecha (reciente)",
|
||||||
|
"sortDateAsc": "Fecha (antigua)",
|
||||||
|
"sortTitleAsc": "Título A → Z",
|
||||||
|
"sortTitleDesc": "Título Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -1032,7 +1032,29 @@
|
|||||||
"notes.emptyNotebook": "دفترچه خالی",
|
"notes.emptyNotebook": "دفترچه خالی",
|
||||||
"notes.emptyNotebookDesc": "این دفترچه یادداشتی ندارد. روی + کلیک کنید تا یکی بسازید.",
|
"notes.emptyNotebookDesc": "این دفترچه یادداشتی ندارد. روی + کلیک کنید تا یکی بسازید.",
|
||||||
"notes.noNoteSelected": "یادداشتی انتخاب نشده",
|
"notes.noNoteSelected": "یادداشتی انتخاب نشده",
|
||||||
"notes.selectOrCreateNote": "یک یادداشت از لیست انتخاب کنید یا یکی جدید بسازید."
|
"notes.selectOrCreateNote": "یک یادداشت از لیست انتخاب کنید یا یکی جدید بسازید.",
|
||||||
|
"commitVersion": "ذخیره نسخه",
|
||||||
|
"versionSaved": "نسخه ذخیره شد",
|
||||||
|
"deleteVersion": "حذف این نسخه",
|
||||||
|
"versionDeleted": "نسخه حذف شد",
|
||||||
|
"deleteVersionConfirm": "این نسخه برای همیشه حذف شود؟",
|
||||||
|
"historyMode": "حالت تاریخچه",
|
||||||
|
"historyModeManual": "دستی (دکمه ثبت)",
|
||||||
|
"historyModeAuto": "خودکار (هوشمند)",
|
||||||
|
"historyModeManualDesc": "ایجاد اسنپشات دستی با دکمه ثبت",
|
||||||
|
"historyModeAutoDesc": "اسنپشات خودکار با تشخیص هوشمند",
|
||||||
|
"history": "تاریخچه",
|
||||||
|
"historyRestored": "نسخه بازیابی شد",
|
||||||
|
"historyEnabled": "تاریخچه فعال شد",
|
||||||
|
"historyDisabledDesc": "تاریخچه برای حساب شما غیرفعال است.",
|
||||||
|
"enableHistory": "فعالسازی تاریخچه",
|
||||||
|
"historyEmpty": "نسخهای موجود نیست",
|
||||||
|
"historySelectVersion": "نسخهای را برای پیشنمایش انتخاب کنید",
|
||||||
|
"sortBy": "مرتبسازی بر اساس",
|
||||||
|
"sortDateDesc": "تاریخ (جدیدترین)",
|
||||||
|
"sortDateAsc": "تاریخ (قدیمیترین)",
|
||||||
|
"sortTitleAsc": "عنوان الف ← ی",
|
||||||
|
"sortTitleDesc": "عنوان ی ← الف"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -362,56 +362,56 @@
|
|||||||
"undo": "Annuler IA",
|
"undo": "Annuler IA",
|
||||||
"undoAI": "Annuler la transformation IA",
|
"undoAI": "Annuler la transformation IA",
|
||||||
"undoApplied": "Texte original restauré",
|
"undoApplied": "Texte original restauré",
|
||||||
"minWordsError": "Note must contain at least 5 words to use AI actions.",
|
"minWordsError": "La note doit contenir au moins 5 mots pour utiliser les actions IA.",
|
||||||
"genericError": "AI error",
|
"genericError": "Erreur IA",
|
||||||
"actionError": "Error during AI action",
|
"actionError": "Erreur lors de l'action IA",
|
||||||
"appliedToNote": "Applied to note",
|
"appliedToNote": "Appliqué à la note",
|
||||||
"applyToNote": "Apply to note",
|
"applyToNote": "Appliquer à la note",
|
||||||
"undoLastAction": "Undo last AI action",
|
"undoLastAction": "Annuler la dernière action IA",
|
||||||
"selectContext": "Select context...",
|
"selectContext": "Sélectionner le contexte...",
|
||||||
"selectNotebook": "Select notebook",
|
"selectNotebook": "Sélectionner un carnet",
|
||||||
"chatPlaceholder": "Ask AI to edit, summarize, or draft...",
|
"chatPlaceholder": "Demandez à l'IA de modifier, résumer ou rédiger...",
|
||||||
"assistantTitle": "AI Assistant",
|
"assistantTitle": "Assistant IA",
|
||||||
"currentNote": "Current note",
|
"currentNote": "Note actuelle",
|
||||||
"shrinkPanel": "Shrink panel",
|
"shrinkPanel": "Réduire le panneau",
|
||||||
"expandPanel": "Expand panel",
|
"expandPanel": "Agrandir le panneau",
|
||||||
"chatTab": "Chat",
|
"chatTab": "Discussion",
|
||||||
"noteActions": "Note Actions",
|
"noteActions": "Actions sur la note",
|
||||||
"askToStart": "Ask the Assistant something to get started.",
|
"askToStart": "Posez une question à l'Assistant pour commencer.",
|
||||||
"contextLabel": "Context",
|
"contextLabel": "Contexte",
|
||||||
"thisNote": "This note",
|
"thisNote": "Cette note",
|
||||||
"allMyNotes": "All my notes",
|
"allMyNotes": "Toutes mes notes",
|
||||||
"notebookGeneric": "Notebook",
|
"notebookGeneric": "Carnet",
|
||||||
"writingTone": "Writing Tone",
|
"writingTone": "Ton d'écriture",
|
||||||
"askAboutThisNote": "Ask AI something about this note...",
|
"askAboutThisNote": "Posez une question sur cette note...",
|
||||||
"askAboutYourNotes": "Ask AI something about your notes...",
|
"askAboutYourNotes": "Posez une question sur vos notes...",
|
||||||
"webSearchLabel": "Web Search",
|
"webSearchLabel": "Recherche web",
|
||||||
"newLineHint": "Shift+Enter = new line",
|
"newLineHint": "Maj+Entrée = nouvelle ligne",
|
||||||
"resultLabel": "Result",
|
"resultLabel": "Résultat",
|
||||||
"discardAction": "Discard",
|
"discardAction": "Ignorer",
|
||||||
"transformationsDesc": "Transformations — applied directly to the note",
|
"transformationsDesc": "Transformations — appliquées directement à la note",
|
||||||
"writeMinWordsAction": "Write at least 5 words to activate AI actions.",
|
"writeMinWordsAction": "Écrivez au moins 5 mots pour activer les actions IA.",
|
||||||
"processingAction": "Processing...",
|
"processingAction": "Traitement en cours...",
|
||||||
"action": {
|
"action": {
|
||||||
"clarify": "Clarify",
|
"clarify": "Clarifier",
|
||||||
"shorten": "Shorten",
|
"shorten": "Raccourcir",
|
||||||
"improve": "Improve",
|
"improve": "Améliorer",
|
||||||
"toMarkdown": "To Markdown"
|
"toMarkdown": "Convertir en Markdown"
|
||||||
},
|
},
|
||||||
"openAssistant": "Open AI Assistant",
|
"openAssistant": "Ouvrir l'Assistant IA",
|
||||||
"poweredByMomento": "Powered by Momento AI",
|
"poweredByMomento": "Propulsé par Momento AI",
|
||||||
"welcomeMsg": "Hello! I'm your AI assistant. How can I help you with your notes today? I can help refine tone, expand messaging, or summarize content.",
|
"welcomeMsg": "Bonjour ! Je suis votre assistant IA. Comment puis-je vous aider avec vos notes ? Je peux affiner le ton, développer un message ou résumer le contenu.",
|
||||||
"summaryLast5": "Summary of your last 5 notes",
|
"summaryLast5": "Résumé de vos 5 dernières notes",
|
||||||
"analyzingProgress": "Analyzing...",
|
"analyzingProgress": "Analyse en cours...",
|
||||||
"generateInsightsBtn": "Generate Insights",
|
"generateInsightsBtn": "Générer des insights",
|
||||||
"newDiscussion": "New discussion",
|
"newDiscussion": "Nouvelle discussion",
|
||||||
"noRecentConversations": "No recent conversations.",
|
"noRecentConversations": "Aucune conversation récente.",
|
||||||
"discussionContextLabel": "Discussion Context",
|
"discussionContextLabel": "Contexte de discussion",
|
||||||
"webSearchNotConfigured": "Web Search (Not configured)",
|
"webSearchNotConfigured": "Recherche web (non configurée)",
|
||||||
"historyTab": "History",
|
"historyTab": "Historique",
|
||||||
"insightsTab": "Insights",
|
"insightsTab": "Insights",
|
||||||
"aiCopilot": "AI Copilot",
|
"aiCopilot": "Copilote IA",
|
||||||
"suggestTitle": "AI title suggestion"
|
"suggestTitle": "Suggestion de titre IA"
|
||||||
},
|
},
|
||||||
"aiSettings": {
|
"aiSettings": {
|
||||||
"description": "Configurez vos fonctionnalités IA et préférences",
|
"description": "Configurez vos fonctionnalités IA et préférences",
|
||||||
@@ -985,7 +985,29 @@
|
|||||||
"notes.emptyNotebook": "Empty notebook",
|
"notes.emptyNotebook": "Empty notebook",
|
||||||
"notes.emptyNotebookDesc": "This notebook has no notes. Click + to create one.",
|
"notes.emptyNotebookDesc": "This notebook has no notes. Click + to create one.",
|
||||||
"notes.noNoteSelected": "No note selected",
|
"notes.noNoteSelected": "No note selected",
|
||||||
"notes.selectOrCreateNote": "Select a note from the list or create a new one."
|
"notes.selectOrCreateNote": "Select a note from the list or create a new one.",
|
||||||
|
"commitVersion": "Enregistrer la version",
|
||||||
|
"versionSaved": "Version enregistrée",
|
||||||
|
"deleteVersion": "Supprimer cette version",
|
||||||
|
"versionDeleted": "Version supprimée",
|
||||||
|
"deleteVersionConfirm": "Supprimer cette version définitivement ?",
|
||||||
|
"historyMode": "Mode d'historique",
|
||||||
|
"historyModeManual": "Manuel (bouton commit)",
|
||||||
|
"historyModeAuto": "Automatique (intelligent)",
|
||||||
|
"historyModeManualDesc": "Créer des snapshots manuellement avec le bouton commit",
|
||||||
|
"historyModeAutoDesc": "Snapshots automatiques avec détection intelligente",
|
||||||
|
"history": "Historique",
|
||||||
|
"historyRestored": "Version restaurée",
|
||||||
|
"historyEnabled": "Historique activé",
|
||||||
|
"historyDisabledDesc": "L'historique est désactivé pour votre compte.",
|
||||||
|
"enableHistory": "Activer l'historique",
|
||||||
|
"historyEmpty": "Aucune version disponible",
|
||||||
|
"historySelectVersion": "Sélectionnez une version pour prévisualiser son contenu",
|
||||||
|
"sortBy": "Trier par",
|
||||||
|
"sortDateDesc": "Date (récent)",
|
||||||
|
"sortDateAsc": "Date (ancien)",
|
||||||
|
"sortTitleAsc": "Titre A → Z",
|
||||||
|
"sortTitleDesc": "Titre Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -979,7 +979,29 @@
|
|||||||
"notes.emptyNotebook": "खाली नोटबुक",
|
"notes.emptyNotebook": "खाली नोटबुक",
|
||||||
"notes.emptyNotebookDesc": "इस नोटबुक में कोई नोट नहीं है। एक बनाने के लिए + पर क्लिक करें।",
|
"notes.emptyNotebookDesc": "इस नोटबुक में कोई नोट नहीं है। एक बनाने के लिए + पर क्लिक करें।",
|
||||||
"notes.noNoteSelected": "कोई नोट चुना नहीं गया",
|
"notes.noNoteSelected": "कोई नोट चुना नहीं गया",
|
||||||
"notes.selectOrCreateNote": "सूची से एक नोट चुनें या एक नया बनाएं।"
|
"notes.selectOrCreateNote": "सूची से एक नोट चुनें या एक नया बनाएं।",
|
||||||
|
"commitVersion": "संस्करण सहेजें",
|
||||||
|
"versionSaved": "संस्करण सहेजा गया",
|
||||||
|
"deleteVersion": "इस संस्करण को हटाएं",
|
||||||
|
"versionDeleted": "संस्करण हटाया गया",
|
||||||
|
"deleteVersionConfirm": "क्या आप इस संस्करण को स्थायी रूप से हटाना चाहते हैं?",
|
||||||
|
"historyMode": "इतिहास मोड",
|
||||||
|
"historyModeManual": "मैनुअल (कमिट बटन)",
|
||||||
|
"historyModeAuto": "स्वचालित (स्मार्ट)",
|
||||||
|
"historyModeManualDesc": "कमिट बटन से मैन्युअल स्नैपशॉट बनाएं",
|
||||||
|
"historyModeAutoDesc": "स्मार्ट डिटेक्शन के साथ ऑटो स्नैपशॉट",
|
||||||
|
"history": "इतिहास",
|
||||||
|
"historyRestored": "संस्करण पुनर्स्थापित",
|
||||||
|
"historyEnabled": "इतिहास सक्षम किया गया",
|
||||||
|
"historyDisabledDesc": "आपके खाते के लिए इतिहास अक्षम है।",
|
||||||
|
"enableHistory": "इतिहास सक्षम करें",
|
||||||
|
"historyEmpty": "कोई संस्करण उपलब्ध नहीं",
|
||||||
|
"historySelectVersion": "पूर्वावलोकन के लिए एक संस्करण चुनें",
|
||||||
|
"sortBy": "इसके अनुसार क्रमबद्ध करें",
|
||||||
|
"sortDateDesc": "तिथि (नवीनतम)",
|
||||||
|
"sortDateAsc": "तिथि (पुराना)",
|
||||||
|
"sortTitleAsc": "शीर्षक A → Z",
|
||||||
|
"sortTitleDesc": "शीर्षक Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -1024,7 +1024,29 @@
|
|||||||
"notes.emptyNotebook": "Quaderno vuoto",
|
"notes.emptyNotebook": "Quaderno vuoto",
|
||||||
"notes.emptyNotebookDesc": "Questo quaderno non ha note. Clicca + per crearne una.",
|
"notes.emptyNotebookDesc": "Questo quaderno non ha note. Clicca + per crearne una.",
|
||||||
"notes.noNoteSelected": "Nessuna nota selezionata",
|
"notes.noNoteSelected": "Nessuna nota selezionata",
|
||||||
"notes.selectOrCreateNote": "Seleziona una nota dalla lista o creane una nuova."
|
"notes.selectOrCreateNote": "Seleziona una nota dalla lista o creane una nuova.",
|
||||||
|
"commitVersion": "Salva versione",
|
||||||
|
"versionSaved": "Versione salvata",
|
||||||
|
"deleteVersion": "Elimina questa versione",
|
||||||
|
"versionDeleted": "Versione eliminata",
|
||||||
|
"deleteVersionConfirm": "Eliminare questa versione definitivamente?",
|
||||||
|
"historyMode": "Modalità cronologia",
|
||||||
|
"historyModeManual": "Manuale (pulsante commit)",
|
||||||
|
"historyModeAuto": "Automatico (intelligente)",
|
||||||
|
"historyModeManualDesc": "Crea snapshot manualmente con il pulsante commit",
|
||||||
|
"historyModeAutoDesc": "Snapshot automatici con rilevamento intelligente",
|
||||||
|
"history": "Cronologia",
|
||||||
|
"historyRestored": "Versione ripristinata",
|
||||||
|
"historyEnabled": "Cronologia attivata",
|
||||||
|
"historyDisabledDesc": "La cronologia è disattivata per il tuo account.",
|
||||||
|
"enableHistory": "Attiva cronologia",
|
||||||
|
"historyEmpty": "Nessuna versione disponibile",
|
||||||
|
"historySelectVersion": "Seleziona una versione per visualizzarne l'anteprima",
|
||||||
|
"sortBy": "Ordina per",
|
||||||
|
"sortDateDesc": "Data (recente)",
|
||||||
|
"sortDateAsc": "Data (meno recente)",
|
||||||
|
"sortTitleAsc": "Titolo A → Z",
|
||||||
|
"sortTitleDesc": "Titolo Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -1002,7 +1002,29 @@
|
|||||||
"notes.emptyNotebook": "空のノートブック",
|
"notes.emptyNotebook": "空のノートブック",
|
||||||
"notes.emptyNotebookDesc": "このノートブックにはノートがありません。+ をクリックして作成。",
|
"notes.emptyNotebookDesc": "このノートブックにはノートがありません。+ をクリックして作成。",
|
||||||
"notes.noNoteSelected": "ノート未選択",
|
"notes.noNoteSelected": "ノート未選択",
|
||||||
"notes.selectOrCreateNote": "リストからノートを選択または新規作成してください。"
|
"notes.selectOrCreateNote": "リストからノートを選択または新規作成してください。",
|
||||||
|
"commitVersion": "バージョンを保存",
|
||||||
|
"versionSaved": "バージョンを保存しました",
|
||||||
|
"deleteVersion": "このバージョンを削除",
|
||||||
|
"versionDeleted": "バージョンを削除しました",
|
||||||
|
"deleteVersionConfirm": "このバージョンを完全に削除しますか?",
|
||||||
|
"historyMode": "履歴モード",
|
||||||
|
"historyModeManual": "手動(コミットボタン)",
|
||||||
|
"historyModeAuto": "自動(スマート)",
|
||||||
|
"historyModeManualDesc": "コミットボタンで手動スナップショットを作成",
|
||||||
|
"historyModeAutoDesc": "スマート検出で自動スナップショットを作成",
|
||||||
|
"history": "履歴",
|
||||||
|
"historyRestored": "バージョンを復元しました",
|
||||||
|
"historyEnabled": "履歴を有効にしました",
|
||||||
|
"historyDisabledDesc": "履歴は無効になっています。",
|
||||||
|
"enableHistory": "履歴を有効にする",
|
||||||
|
"historyEmpty": "バージョンがありません",
|
||||||
|
"historySelectVersion": "プレビューするバージョンを選択してください",
|
||||||
|
"sortBy": "並び替え",
|
||||||
|
"sortDateDesc": "日付(新しい)",
|
||||||
|
"sortDateAsc": "日付(古い)",
|
||||||
|
"sortTitleAsc": "タイトル A → Z",
|
||||||
|
"sortTitleDesc": "タイトル Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -979,7 +979,29 @@
|
|||||||
"notes.emptyNotebook": "빈 노트북",
|
"notes.emptyNotebook": "빈 노트북",
|
||||||
"notes.emptyNotebookDesc": "이 노트북에 노트가 없습니다. +를 클릭하여 만드세요.",
|
"notes.emptyNotebookDesc": "이 노트북에 노트가 없습니다. +를 클릭하여 만드세요.",
|
||||||
"notes.noNoteSelected": "선택된 노트 없음",
|
"notes.noNoteSelected": "선택된 노트 없음",
|
||||||
"notes.selectOrCreateNote": "목록에서 노트를 선택하거나 새로 만드세요."
|
"notes.selectOrCreateNote": "목록에서 노트를 선택하거나 새로 만드세요.",
|
||||||
|
"commitVersion": "버전 저장",
|
||||||
|
"versionSaved": "버전이 저장되었습니다",
|
||||||
|
"deleteVersion": "이 버전 삭제",
|
||||||
|
"versionDeleted": "버전이 삭제되었습니다",
|
||||||
|
"deleteVersionConfirm": "이 버전을 영구적으로 삭제하시겠습니까?",
|
||||||
|
"historyMode": "기록 모드",
|
||||||
|
"historyModeManual": "수동 (커밋 버튼)",
|
||||||
|
"historyModeAuto": "자동 (스마트)",
|
||||||
|
"historyModeManualDesc": "커밋 버튼으로 수동 스냅샷 생성",
|
||||||
|
"historyModeAutoDesc": "스마트 감지로 자동 스냅샷 생성",
|
||||||
|
"history": "기록",
|
||||||
|
"historyRestored": "버전이 복원되었습니다",
|
||||||
|
"historyEnabled": "기록이 활성화되었습니다",
|
||||||
|
"historyDisabledDesc": "기록이 비활성화되어 있습니다.",
|
||||||
|
"enableHistory": "기록 활성화",
|
||||||
|
"historyEmpty": "사용 가능한 버전이 없습니다",
|
||||||
|
"historySelectVersion": "미리볼 버전을 선택하세요",
|
||||||
|
"sortBy": "정렬",
|
||||||
|
"sortDateDesc": "날짜 (최신)",
|
||||||
|
"sortDateAsc": "날짜 (오래된)",
|
||||||
|
"sortTitleAsc": "제목 A → Z",
|
||||||
|
"sortTitleDesc": "제목 Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -1024,7 +1024,29 @@
|
|||||||
"notes.emptyNotebook": "Leeg notitieboek",
|
"notes.emptyNotebook": "Leeg notitieboek",
|
||||||
"notes.emptyNotebookDesc": "Dit notitieboek heeft geen notities. Klik op + om er een te maken.",
|
"notes.emptyNotebookDesc": "Dit notitieboek heeft geen notities. Klik op + om er een te maken.",
|
||||||
"notes.noNoteSelected": "Geen notitie geselecteerd",
|
"notes.noNoteSelected": "Geen notitie geselecteerd",
|
||||||
"notes.selectOrCreateNote": "Selecteer een notitie uit de lijst of maak een nieuwe."
|
"notes.selectOrCreateNote": "Selecteer een notitie uit de lijst of maak een nieuwe.",
|
||||||
|
"commitVersion": "Versie opslaan",
|
||||||
|
"versionSaved": "Versie opgeslagen",
|
||||||
|
"deleteVersion": "Deze versie verwijderen",
|
||||||
|
"versionDeleted": "Versie verwijderd",
|
||||||
|
"deleteVersionConfirm": "Deze versie definitief verwijderen?",
|
||||||
|
"historyMode": "Geschiedenismodus",
|
||||||
|
"historyModeManual": "Handmatig (commit-knop)",
|
||||||
|
"historyModeAuto": "Automatisch (slim)",
|
||||||
|
"historyModeManualDesc": "Handmatig snapshots maken met de commit-knop",
|
||||||
|
"historyModeAutoDesc": "Automatische snapshots met slimme detectie",
|
||||||
|
"history": "Geschiedenis",
|
||||||
|
"historyRestored": "Versie hersteld",
|
||||||
|
"historyEnabled": "Geschiedenis ingeschakeld",
|
||||||
|
"historyDisabledDesc": "Geschiedenis is uitgeschakeld voor uw account.",
|
||||||
|
"enableHistory": "Geschiedenis inschakelen",
|
||||||
|
"historyEmpty": "Geen versies beschikbaar",
|
||||||
|
"historySelectVersion": "Selecteer een versie om de inhoud te bekijken",
|
||||||
|
"sortBy": "Sorteren op",
|
||||||
|
"sortDateDesc": "Datum (nieuwste)",
|
||||||
|
"sortDateAsc": "Datum (oudste)",
|
||||||
|
"sortTitleAsc": "Titel A → Z",
|
||||||
|
"sortTitleDesc": "Titel Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -1046,7 +1046,29 @@
|
|||||||
"notes.emptyNotebook": "Pusty notatnik",
|
"notes.emptyNotebook": "Pusty notatnik",
|
||||||
"notes.emptyNotebookDesc": "Ten notatnik nie ma notatek. Kliknij + aby utworzyć.",
|
"notes.emptyNotebookDesc": "Ten notatnik nie ma notatek. Kliknij + aby utworzyć.",
|
||||||
"notes.noNoteSelected": "Nie wybrano notatki",
|
"notes.noNoteSelected": "Nie wybrano notatki",
|
||||||
"notes.selectOrCreateNote": "Wybierz notatkę z listy lub utwórz nową."
|
"notes.selectOrCreateNote": "Wybierz notatkę z listy lub utwórz nową.",
|
||||||
|
"commitVersion": "Zapisz wersję",
|
||||||
|
"versionSaved": "Wersja zapisana",
|
||||||
|
"deleteVersion": "Usuń tę wersję",
|
||||||
|
"versionDeleted": "Wersja usunięta",
|
||||||
|
"deleteVersionConfirm": "Usunąć tę wersję trwale?",
|
||||||
|
"historyMode": "Tryb historii",
|
||||||
|
"historyModeManual": "Ręczny (przycisk commit)",
|
||||||
|
"historyModeAuto": "Automatyczny (inteligentny)",
|
||||||
|
"historyModeManualDesc": "Ręczne tworzenie snapshotów przyciskiem commit",
|
||||||
|
"historyModeAutoDesc": "Automatyczne snapshoty z inteligentnym wykrywaniem",
|
||||||
|
"history": "Historia",
|
||||||
|
"historyRestored": "Wersja przywrócona",
|
||||||
|
"historyEnabled": "Historia włączona",
|
||||||
|
"historyDisabledDesc": "Historia jest wyłączona dla Twojego konta.",
|
||||||
|
"enableHistory": "Włącz historię",
|
||||||
|
"historyEmpty": "Brak dostępnych wersji",
|
||||||
|
"historySelectVersion": "Wybierz wersję, aby zobaczyć podgląd",
|
||||||
|
"sortBy": "Sortuj według",
|
||||||
|
"sortDateDesc": "Data (najnowsze)",
|
||||||
|
"sortDateAsc": "Data (najstarsze)",
|
||||||
|
"sortTitleAsc": "Tytuł A → Z",
|
||||||
|
"sortTitleDesc": "Tytuł Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -974,7 +974,29 @@
|
|||||||
"notes.emptyNotebook": "Caderno vazio",
|
"notes.emptyNotebook": "Caderno vazio",
|
||||||
"notes.emptyNotebookDesc": "Este caderno não tem notas. Clique em + para criar uma.",
|
"notes.emptyNotebookDesc": "Este caderno não tem notas. Clique em + para criar uma.",
|
||||||
"notes.noNoteSelected": "Nenhuma nota selecionada",
|
"notes.noNoteSelected": "Nenhuma nota selecionada",
|
||||||
"notes.selectOrCreateNote": "Selecione uma nota da lista ou crie uma nova."
|
"notes.selectOrCreateNote": "Selecione uma nota da lista ou crie uma nova.",
|
||||||
|
"commitVersion": "Salvar versão",
|
||||||
|
"versionSaved": "Versão salva",
|
||||||
|
"deleteVersion": "Excluir esta versão",
|
||||||
|
"versionDeleted": "Versão excluída",
|
||||||
|
"deleteVersionConfirm": "Excluir esta versão permanentemente?",
|
||||||
|
"historyMode": "Modo de histórico",
|
||||||
|
"historyModeManual": "Manual (botão commit)",
|
||||||
|
"historyModeAuto": "Automático (inteligente)",
|
||||||
|
"historyModeManualDesc": "Criar snapshots manualmente com o botão commit",
|
||||||
|
"historyModeAutoDesc": "Snapshots automáticos com detecção inteligente",
|
||||||
|
"history": "Histórico",
|
||||||
|
"historyRestored": "Versão restaurada",
|
||||||
|
"historyEnabled": "Histórico ativado",
|
||||||
|
"historyDisabledDesc": "O histórico está desativado para sua conta.",
|
||||||
|
"enableHistory": "Ativar histórico",
|
||||||
|
"historyEmpty": "Nenhuma versão disponível",
|
||||||
|
"historySelectVersion": "Selecione uma versão para visualizar seu conteúdo",
|
||||||
|
"sortBy": "Ordenar por",
|
||||||
|
"sortDateDesc": "Data (recente)",
|
||||||
|
"sortDateAsc": "Data (antiga)",
|
||||||
|
"sortTitleAsc": "Título A → Z",
|
||||||
|
"sortTitleDesc": "Título Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -974,7 +974,29 @@
|
|||||||
"notes.emptyNotebook": "Пустой блокнот",
|
"notes.emptyNotebook": "Пустой блокнот",
|
||||||
"notes.emptyNotebookDesc": "В этом блокноте нет заметок. Нажмите +, чтобы создать.",
|
"notes.emptyNotebookDesc": "В этом блокноте нет заметок. Нажмите +, чтобы создать.",
|
||||||
"notes.noNoteSelected": "Заметка не выбрана",
|
"notes.noNoteSelected": "Заметка не выбрана",
|
||||||
"notes.selectOrCreateNote": "Выберите заметку из списка или создайте новую."
|
"notes.selectOrCreateNote": "Выберите заметку из списка или создайте новую.",
|
||||||
|
"commitVersion": "Сохранить версию",
|
||||||
|
"versionSaved": "Версия сохранена",
|
||||||
|
"deleteVersion": "Удалить эту версию",
|
||||||
|
"versionDeleted": "Версия удалена",
|
||||||
|
"deleteVersionConfirm": "Удалить эту версию навсегда?",
|
||||||
|
"historyMode": "Режим истории",
|
||||||
|
"historyModeManual": "Ручной (кнопка фиксации)",
|
||||||
|
"historyModeAuto": "Автоматический (умный)",
|
||||||
|
"historyModeManualDesc": "Создавать снимки вручную кнопкой фиксации",
|
||||||
|
"historyModeAutoDesc": "Автоматические снимки с умным обнаружением",
|
||||||
|
"history": "История",
|
||||||
|
"historyRestored": "Версия восстановлена",
|
||||||
|
"historyEnabled": "История включена",
|
||||||
|
"historyDisabledDesc": "История отключена для вашей учётной записи.",
|
||||||
|
"enableHistory": "Включить историю",
|
||||||
|
"historyEmpty": "Нет доступных версий",
|
||||||
|
"historySelectVersion": "Выберите версию для предпросмотра",
|
||||||
|
"sortBy": "Сортировать по",
|
||||||
|
"sortDateDesc": "Дата (новые)",
|
||||||
|
"sortDateAsc": "Дата (старые)",
|
||||||
|
"sortTitleAsc": "Заголовок А → Я",
|
||||||
|
"sortTitleDesc": "Заголовок Я → А"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -1002,7 +1002,29 @@
|
|||||||
"notes.emptyNotebook": "空笔记本",
|
"notes.emptyNotebook": "空笔记本",
|
||||||
"notes.emptyNotebookDesc": "此笔记本没有笔记。点击 + 创建一个。",
|
"notes.emptyNotebookDesc": "此笔记本没有笔记。点击 + 创建一个。",
|
||||||
"notes.noNoteSelected": "未选择笔记",
|
"notes.noNoteSelected": "未选择笔记",
|
||||||
"notes.selectOrCreateNote": "从列表中选择笔记或创建新笔记。"
|
"notes.selectOrCreateNote": "从列表中选择笔记或创建新笔记。",
|
||||||
|
"commitVersion": "保存版本",
|
||||||
|
"versionSaved": "版本已保存",
|
||||||
|
"deleteVersion": "删除此版本",
|
||||||
|
"versionDeleted": "版本已删除",
|
||||||
|
"deleteVersionConfirm": "确定永久删除此版本?",
|
||||||
|
"historyMode": "历史模式",
|
||||||
|
"historyModeManual": "手动(提交按钮)",
|
||||||
|
"historyModeAuto": "自动(智能)",
|
||||||
|
"historyModeManualDesc": "使用提交按钮手动创建快照",
|
||||||
|
"historyModeAutoDesc": "智能检测自动创建快照",
|
||||||
|
"history": "历史",
|
||||||
|
"historyRestored": "版本已恢复",
|
||||||
|
"historyEnabled": "历史已启用",
|
||||||
|
"historyDisabledDesc": "您的账户已禁用历史记录。",
|
||||||
|
"enableHistory": "启用历史",
|
||||||
|
"historyEmpty": "暂无版本",
|
||||||
|
"historySelectVersion": "选择一个版本以预览其内容",
|
||||||
|
"sortBy": "排序方式",
|
||||||
|
"sortDateDesc": "日期(最新)",
|
||||||
|
"sortDateAsc": "日期(最早)",
|
||||||
|
"sortTitleAsc": "标题 A → Z",
|
||||||
|
"sortTitleDesc": "标题 Z → A"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"build": "prisma generate && next build",
|
"build": "prisma generate && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "node scripts/safe-migrate.js",
|
||||||
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
"db:migrate:deploy": "prisma migrate deploy",
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "UserAISettings" ADD COLUMN "noteHistoryMode" TEXT NOT NULL DEFAULT 'manual';
|
||||||
@@ -30,6 +30,7 @@ model User {
|
|||||||
labels Label[]
|
labels Label[]
|
||||||
memoryEchoInsights MemoryEchoInsight[]
|
memoryEchoInsights MemoryEchoInsight[]
|
||||||
notes Note[]
|
notes Note[]
|
||||||
|
noteHistories NoteHistory[]
|
||||||
sentShares NoteShare[] @relation("SentShares")
|
sentShares NoteShare[] @relation("SentShares")
|
||||||
receivedShares NoteShare[] @relation("ReceivedShares")
|
receivedShares NoteShare[] @relation("ReceivedShares")
|
||||||
notebooks Notebook[]
|
notebooks Notebook[]
|
||||||
@@ -152,6 +153,7 @@ model Note {
|
|||||||
noteEmbedding NoteEmbedding?
|
noteEmbedding NoteEmbedding?
|
||||||
shares NoteShare[]
|
shares NoteShare[]
|
||||||
labelRelations Label[] @relation("LabelToNote")
|
labelRelations Label[] @relation("LabelToNote")
|
||||||
|
historyEntries NoteHistory[]
|
||||||
|
|
||||||
@@index([isPinned])
|
@@index([isPinned])
|
||||||
@@index([isArchived])
|
@@index([isArchived])
|
||||||
@@ -162,6 +164,34 @@ model Note {
|
|||||||
@@index([userId, notebookId])
|
@@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 {
|
model NoteShare {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
noteId String
|
noteId String
|
||||||
@@ -244,6 +274,8 @@ model UserAISettings {
|
|||||||
desktopNotifications Boolean @default(false)
|
desktopNotifications Boolean @default(false)
|
||||||
anonymousAnalytics Boolean @default(false)
|
anonymousAnalytics Boolean @default(false)
|
||||||
autoLabeling Boolean @default(true)
|
autoLabeling Boolean @default(true)
|
||||||
|
noteHistory Boolean @default(false)
|
||||||
|
noteHistoryMode String @default("manual")
|
||||||
languageDetection Boolean @default(true)
|
languageDetection Boolean @default(true)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
76
memento-note/scripts/safe-migrate.js
Normal file
76
memento-note/scripts/safe-migrate.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/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')
|
||||||
Reference in New Issue
Block a user