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

- 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:
2026-04-28 21:05:55 +02:00
parent ed807d3b2a
commit 69ea064ca8
40 changed files with 2110 additions and 250 deletions

View File

@@ -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.
- **Organisation par lots** (Batch Organization) : Tri automatique des notes en vrac.
- **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.
## 📄 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
- **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
- **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
## 💻 Instructions de Développement
@@ -62,6 +66,19 @@ npx prisma generate
npx prisma db push
```
### Migrations sûres (anti-perte de données)
```bash
npm run db:migrate
```
- Cette commande passe par `scripts/safe-migrate.js`.
- Un backup est créé avant migration (`backups/migrations/`), puis `prisma migrate deploy` est exécuté.
- La migration est interrompue si le backup échoue.
Commande dev avancée (à utiliser explicitement seulement) :
```bash
npm run db:migrate:dev
```
### Lancement du serveur (avec Turbopack)
```bash
npm run dev

View File

@@ -21,6 +21,8 @@ export default async function HomePage() {
initialSettings={{
showRecentNotes: settings?.showRecentNotes !== false,
notesViewMode,
noteHistory: settings?.noteHistory === true,
noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
}}
/>
)

View File

@@ -21,6 +21,8 @@ export type UserAISettingsData = {
fontSize?: 'small' | 'medium' | 'large'
languageDetection?: boolean
autoLabeling?: boolean
noteHistory?: boolean
noteHistoryMode?: 'manual' | 'auto'
}
/** 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',
'languageDetection',
'autoLabeling',
'noteHistory',
'noteHistoryMode',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
@@ -151,6 +155,8 @@ const getCachedAISettings = unstable_cache(
fontSize: 'medium' as const,
languageDetection: 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',
languageDetection: settings.languageDetection ?? true,
autoLabeling: settings.autoLabeling ?? true,
noteHistory: settings.noteHistory ?? false,
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -202,6 +210,8 @@ const getCachedAISettings = unstable_cache(
fontSize: 'medium' as const,
languageDetection: true,
autoLabeling: true,
noteHistory: false,
noteHistoryMode: 'manual' as const,
}
}
},
@@ -237,6 +247,8 @@ export async function getAISettings(userId?: string) {
fontSize: 'medium' as const,
languageDetection: true,
autoLabeling: true,
noteHistory: false,
noteHistoryMode: 'manual' as const,
}
}
@@ -266,6 +278,8 @@ export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Pro
return settings.paragraphRefactor
case 'memoryEcho':
return settings.memoryEcho
case 'noteHistory':
return settings.noteHistory
default:
return true
}

View File

@@ -10,6 +10,14 @@ import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } f
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
import { getAISettings } from '@/app/actions/ai-settings'
import {
createNoteHistorySnapshot,
getNoteHistoryMode,
isNoteHistoryEnabledForUser,
parseNoteHistoryEntry,
shouldCaptureHistorySnapshot,
shouldCreateAutoSnapshot,
} from '@/lib/note-history'
/**
@@ -57,6 +65,24 @@ function parseNote(dbNote: any): Note {
return parseNoteUtil(dbNote)
}
async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) {
const fallbackEmail = `user-${sessionUser.id}@local.momento`
const safeEmail = sessionUser.email || fallbackEmail
await prisma.user.upsert({
where: { id: sessionUser.id },
update: {
...(sessionUser.email ? { email: sessionUser.email } : {}),
...(sessionUser.name !== undefined ? { name: sessionUser.name } : {}),
},
create: {
id: sessionUser.id,
email: safeEmail,
name: sessionUser.name || null,
},
})
}
// Helper to get hash color for labels (copied from utils)
function getHashColor(name: string): string {
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
@@ -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
// Supports contextual search within notebook (IA5)
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');
try {
// Defensive guard: after DB reset/migration, auth session can exist while User row is missing.
// Recreate user row to avoid Note_userId_fkey failures.
await ensureSessionUserExists({
id: session.user.id,
email: session.user.email,
name: session.user.name,
})
// Save note to DB immediately (fast!) — AI operations run in background after
const note = await prisma.note.create({
data: {
@@ -482,6 +640,19 @@ export async function createNote(data: {
await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id)
}
try {
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
if (historyEnabled) {
await createNoteHistorySnapshot({
noteId: note.id,
userId: session.user.id,
reason: 'create',
})
}
} catch (snapshotError) {
console.error('[HISTORY] Failed to create initial snapshot:', snapshotError)
}
if (!data.skipRevalidation) {
// Revalidate main page (handles both inbox and notebook views via query params)
revalidatePath('/')
@@ -612,7 +783,7 @@ export async function updateNote(id: string, data: {
try {
const oldNote = await prisma.note.findUnique({
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 oldNotebookId = oldNote?.notebookId
@@ -682,6 +853,33 @@ export async function updateNote(id: string, data: {
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
// Content edits (title, content, size, color) use optimistic UI — no refresh needed
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
@@ -690,6 +888,9 @@ export async function updateNote(id: string, data: {
if (isStructuralChange && !options?.skipRevalidation) {
revalidatePath('/')
revalidatePath(`/note/${id}`)
if (data.isArchived !== undefined) {
revalidatePath('/archive')
}
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
if (oldNotebookId) {

View 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 })
}
}

View File

@@ -2,6 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { parseNote } from '@/lib/utils'
import {
createNoteHistorySnapshot,
getNoteHistoryMode,
isNoteHistoryEnabledForUser,
shouldCaptureHistorySnapshot,
shouldCreateAutoSnapshot,
} from '@/lib/note-history'
// GET /api/notes/[id] - Get a single note
export async function GET(
@@ -134,6 +141,32 @@ export async function PUT(
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({
success: true,
data: parseNote(note)

View File

@@ -124,7 +124,7 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
<span className="text-sm font-medium text-foreground">
{t(statusKeys[action.status] || action.status)}
</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 })}
</span>
</div>

View File

@@ -24,6 +24,8 @@ interface AISettingsPanelProps {
demoMode: boolean
languageDetection: boolean
autoLabeling: boolean
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
}
}
@@ -179,6 +181,53 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
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 */}
<DemoModeToggle
demoMode={settings.demoMode}

View File

@@ -82,7 +82,7 @@ export function ChatSidebar({
{chat.title || t('chat.untitled')}
</span>
</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 })}
</span>
</div>

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
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 { NoteInput } from '@/components/note-input'
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 { useLanguage } from '@/lib/i18n'
import { useHomeView } from '@/context/home-view-context'
import { NoteHistoryModal } from '@/components/note-history-modal'
// Lazy-load heavy dialogs — uniquement chargés à la demande
const NoteEditor = dynamic(
@@ -42,6 +43,8 @@ const AutoLabelSuggestionDialog = dynamic(
type InitialSettings = {
showRecentNotes: boolean
notesViewMode: 'masonry' | 'tabs'
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
}
interface HomeClientProps {
@@ -59,10 +62,14 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
initialNotes.filter(n => n.isPinned)
)
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 [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyNote, setHistoryNote] = useState<Note | null>(null)
const { refreshKey, triggerRefresh } = useNoteRefresh()
const { labels } = useLabels()
const { setControls } = useHomeView()
@@ -133,6 +140,22 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
if (note) setEditingNote({ note, readOnly: false })
}
const handleOpenHistory = useCallback((note: Note) => {
setHistoryNote(note)
setHistoryOpen(true)
}, [])
const handleEnableHistory = useCallback(async () => {
await updateAISettings({ noteHistory: true })
setNoteHistoryEnabled(true)
}, [])
const handleHistoryRestored = useCallback((restored: Note) => {
setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev))
}, [])
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
@@ -388,6 +411,10 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={handleOpenHistory}
onEnableHistory={handleEnableHistory}
/>
</div>
)}
@@ -437,6 +464,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onClose={() => setEditingNote(null)}
/>
)}
<NoteHistoryModal
open={historyOpen}
onOpenChange={setHistoryOpen}
note={historyNote}
enabled={noteHistoryEnabled}
onEnableHistory={handleEnableHistory}
onRestored={handleHistoryRestored}
/>
</div>
)
}

View File

@@ -39,6 +39,9 @@ interface MasonryGridProps {
onEdit?: (note: Note, readOnly?: boolean) => void;
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
isTrashView?: boolean;
noteHistoryEnabled?: boolean;
noteHistoryMode?: 'manual' | 'auto';
onOpenHistory?: (note: Note) => void;
}
// ─────────────────────────────────────────────
@@ -53,6 +56,8 @@ interface SortableNoteProps {
isDragging?: boolean;
isOverlay?: boolean;
isTrashView?: boolean;
noteHistoryEnabled?: boolean;
onOpenHistory?: (note: Note) => void;
}
const SortableNoteItem = memo(function SortableNoteItem({
@@ -64,6 +69,8 @@ const SortableNoteItem = memo(function SortableNoteItem({
isDragging,
isOverlay,
isTrashView,
noteHistoryEnabled,
onOpenHistory,
}: SortableNoteProps) {
const {
attributes,
@@ -98,6 +105,8 @@ const SortableNoteItem = memo(function SortableNoteItem({
isDragging={isDragging}
isTrashView={isTrashView}
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
);
@@ -114,6 +123,8 @@ interface SortableGridSectionProps {
onDragStartNote: (noteId: string) => void;
onDragEndNote: () => void;
isTrashView?: boolean;
noteHistoryEnabled?: boolean;
onOpenHistory?: (note: Note) => void;
}
const SortableGridSection = memo(function SortableGridSection({
@@ -124,6 +135,8 @@ const SortableGridSection = memo(function SortableGridSection({
onDragStartNote,
onDragEndNote,
isTrashView,
noteHistoryEnabled,
onOpenHistory,
}: SortableGridSectionProps) {
const ids = useMemo(() => notes.map(n => n.id), [notes]);
@@ -140,6 +153,8 @@ const SortableGridSection = memo(function SortableGridSection({
onDragEndNote={onDragEndNote}
isDragging={draggedNoteId === note.id}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
))}
</div>
@@ -150,7 +165,14 @@ const SortableGridSection = memo(function SortableGridSection({
// ─────────────────────────────────────────────
// 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 [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
@@ -261,6 +283,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
onDragStartNote={startDrag}
onDragEndNote={endDrag}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
)}
@@ -280,6 +304,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
onDragStartNote={startDrag}
onDragEndNote={endDrag}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
)}
@@ -294,6 +320,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
onEdit={handleEdit}
isDragging={true}
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
) : null}

View File

@@ -17,6 +17,7 @@ import {
FileText,
Trash2,
RotateCcw,
History,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types"
@@ -38,6 +39,8 @@ interface NoteActionsProps {
isTrashView?: boolean
onRestore?: () => void
onPermanentDelete?: () => void
onOpenHistory?: () => void
historyEnabled?: boolean
className?: string
}
@@ -57,6 +60,8 @@ export function NoteActions({
isTrashView,
onRestore,
onPermanentDelete,
onOpenHistory,
historyEnabled = false,
className
}: NoteActionsProps) {
const { t } = useLanguage()
@@ -176,6 +181,15 @@ export function NoteActions({
)}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem onClick={onOpenHistory}>
<History className="h-4 w-4 mr-2" />
{historyEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>
)}
{/* Size Selector */}
{onSizeChange && (
<>

View File

@@ -115,6 +115,8 @@ interface NoteCardProps {
onResize?: () => void
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
isTrashView?: boolean
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
}
// Helper function to get initials from name
@@ -153,7 +155,9 @@ export const NoteCard = memo(function NoteCard({
isDragging,
onResize,
onSizeChange,
isTrashView
isTrashView,
noteHistoryEnabled = false,
onOpenHistory,
}: NoteCardProps) {
const router = useRouter()
const searchParams = useSearchParams()
@@ -610,7 +614,7 @@ export const NoteCard = memo(function NoteCard({
{/* Footer with Date only */}
<div className="mt-3 flex items-center justify-end">
{/* 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) })}
</div>
</div>
@@ -645,6 +649,8 @@ export const NoteCard = memo(function NoteCard({
isTrashView={isTrashView}
onRestore={handleRestore}
onPermanentDelete={handlePermanentDelete}
onOpenHistory={() => onOpenHistory?.(note)}
historyEnabled={noteHistoryEnabled}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}

View 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>
)
}

View File

@@ -18,8 +18,10 @@ import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import {
updateNote,
toggleArchive,
deleteNote,
createNote,
commitNoteHistory,
} from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import {
@@ -39,6 +41,8 @@ import {
Loader2,
Check,
RotateCcw,
History,
GitCommitHorizontal,
} from 'lucide-react'
import { toast } from 'sonner'
import { MarkdownContent } from '@/components/markdown-content'
@@ -62,6 +66,9 @@ interface NoteInlineEditorProps {
onDelete?: (noteId: string) => void
onArchive?: (noteId: string) => void
onChange?: (noteId: string, fields: Partial<Note>) => void
onOpenHistory?: (note: Note) => void
noteHistoryEnabled?: boolean
noteHistoryMode?: 'manual' | 'auto'
colorKey: NoteColor
/** If true and the note is a Markdown note, open directly in preview mode */
defaultPreviewMode?: boolean
@@ -90,6 +97,9 @@ export function NoteInlineEditor({
onDelete,
onArchive,
onChange,
onOpenHistory,
noteHistoryEnabled = false,
noteHistoryMode = 'manual',
colorKey,
defaultPreviewMode = false,
}: NoteInlineEditorProps) {
@@ -313,7 +323,8 @@ export function NoteInlineEditor({
startTransition(async () => {
onArchive?.(note.id)
try {
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
await toggleArchive(note.id, !note.isArchived)
triggerRefresh()
} catch {
// Cannot easily revert since onArchive removes from list
toast.error(t('general.error'))
@@ -479,7 +490,13 @@ export function NoteInlineEditor({
<Button variant="ghost" size="icon"
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">
<FileText className="h-4 w-4" />
</Button>
@@ -515,6 +532,26 @@ export function NoteInlineEditor({
{/* Right group: meta actions + save indicator */}
<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">
{isSaving ? (
<><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')}</>
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem
onClick={() => onOpenHistory(note)}
>
<History className="h-4 w-4 mr-2" />
{noteHistoryEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
@@ -781,9 +828,9 @@ export function NoteInlineEditor({
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span suppressHydrationWarning>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
<span suppressHydrationWarning>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
</div>

View File

@@ -25,20 +25,49 @@ interface NotesMainSectionProps {
onEdit?: (note: Note, readOnly?: boolean) => void
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
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') {
return (
<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>
)
}
return (
<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>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState, useTransition } from 'react'
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import {
DndContext,
@@ -24,17 +24,32 @@ import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { updateFullOrderWithoutRevalidation, createNote, deleteNote } from '@/app/actions/notes'
import {
updateFullOrderWithoutRevalidation,
createNote,
deleteNote,
updateNote,
toggleArchive,
} from '@/app/actions/notes'
import { useNotebooks } from '@/context/notebooks-context'
import {
GripVertical,
Hash,
ListChecks,
Pin,
PinOff,
FileText,
Clock,
Plus,
Loader2,
Trash2,
ListFilter,
FolderInput,
Archive,
Share2,
Check,
Hash,
History,
PanelRightClose,
PanelRightOpen,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -45,8 +60,22 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
import { format, type Locale } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
@@ -54,8 +83,14 @@ interface NotesTabsViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
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
const COLOR_ACCENT: Record<NoteColor, string> = {
default: 'bg-primary',
@@ -104,9 +139,19 @@ function getColorKey(note: Note): NoteColor {
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
if (language === 'fr') return fr
if (language === 'fa') return require('date-fns/locale').faIR
return enUS
}
function formatNoteDate(date: Date | string, locale: Locale): string {
const d = typeof date === 'string' ? new Date(date) : date
return format(d, 'd MMM yyyy', { locale })
}
function countWords(content: string | null | undefined): number {
if (!content) return 0
return content.trim().split(/\s+/).filter(Boolean).length
}
// ─── Sortable List Item ───────────────────────────────────────────────────────
@@ -144,161 +189,445 @@ function SortableNoteListItem({
const title = getNoteDisplayTitle(note, untitledLabel)
const snippet =
note.type === 'checklist'
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150)
: (note.content || '').substring(0, 150)
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 200)
: (note.content || '').substring(0, 200)
const dateLocale = getDateLocale(language)
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: dateLocale,
})
const dateStr = formatNoteDate(note.updatedAt, dateLocale)
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch gap-0 transition-all duration-150',
'border-b border-border/40 last:border-b-0',
'group relative flex cursor-pointer select-none items-stretch transition-all duration-150',
'border-b border-border/50 last:border-b-0',
selected
? 'bg-primary/5 dark:bg-primary/10 shadow-sm'
: 'hover:bg-muted/50',
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30 rounded-lg'
? 'bg-primary/[0.06] dark:bg-primary/10'
: 'bg-background hover:bg-muted/40 dark:hover:bg-muted/20',
isDragging && 'opacity-75 shadow-lg ring-1 ring-primary/20'
)}
onClick={onSelect}
role="option"
aria-selected={selected}
>
{/* Color accent bar */}
{/* Left accent bar — solid when selected, transparent otherwise */}
<div
className={cn(
'w-1 shrink-0 transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
'w-[3px] shrink-0 rounded-r-full transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent'
)}
/>
{/* Drag handle */}
<button
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>
{/* Main card content */}
<div className="min-w-0 flex-1 px-4 py-4">
{/* Note type icon */}
<div className="flex items-center py-4 pe-1">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
{/* Row 1: type icon + date */}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-3.5 w-3.5 shrink-0',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
)}
/>
) : (
<FileText
className={cn(
'h-3.5 w-3.5 shrink-0',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
)}
/>
)}
/>
) : (
<FileText
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary/70" />
)}
/>
)}
</div>
{/* Text content */}
<div className="min-w-0 flex-1 py-3.5 pe-3">
<div className="flex items-center gap-2">
<p
</div>
<span
suppressHydrationWarning
className={cn(
'truncate text-base font-heading font-medium transition-colors',
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
'shrink-0 text-[11px] tabular-nums',
selected ? 'text-muted-foreground' : 'text-muted-foreground/60'
)}
>
{title}
</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}
{dateStr}
</span>
</div>
{/* Row 2: title */}
<p
className={cn(
'mb-1.5 text-[13.5px] leading-snug transition-colors',
selected
? 'font-semibold text-foreground'
: 'font-medium text-foreground/85 group-hover:text-foreground'
)}
>
{title}
</p>
{/* Row 3: snippet */}
{snippet && (
<p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
{snippet}
</p>
)}
{/* Row 4: label chips */}
{Array.isArray(note.labels) && note.labels.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{note.labels.slice(0, 3).map((label) => (
<span
key={label}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium leading-none transition-colors',
selected
? 'border-primary/25 text-primary/70'
: 'border-border text-muted-foreground/65 group-hover:border-border/80'
)}
>
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
{label}
</span>
))}
{note.labels.length > 3 && (
<span className="inline-flex items-center rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground/50">
+{note.labels.length - 3}
</span>
)}
</div>
)}
</div>
{/* Actions column: drag + delete on hover */}
<div className="flex flex-col items-center justify-between py-3 pe-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="cursor-grab p-1 text-muted-foreground/30 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="p-1 text-muted-foreground/40 hover:text-destructive"
aria-label={deleteLabel}
title={deleteLabel}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
// ─── Note Meta Sidebar ────────────────────────────────────────────────────────
function SidebarSection({ title }: { title: string }) {
return (
<div className="mb-3 flex items-center gap-2">
<p className="text-[10px] font-black uppercase tracking-[0.12em] text-muted-foreground whitespace-nowrap">
{title}
</p>
<div className="flex-1 h-px bg-border/60" />
</div>
)
}
function SidebarActionBtn({
icon,
label,
onClick,
disabled = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
disabled
? "cursor-not-allowed text-muted-foreground/60 opacity-70"
: "text-foreground/70 hover:bg-sky-50 hover:text-sky-700"
)}
>
<span
className={cn(
"transition-colors",
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-sky-600"
)}
>
{icon}
</span>
{label}
</button>
)
}
function NoteMetaSidebar({
note,
onPinToggle,
onArchive,
noteHistoryEnabled = false,
onOpenHistory,
onEnableHistory,
}: {
note: Note
onPinToggle: (note: Note) => void
onArchive: (note: Note) => void
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
}) {
const { t } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [moveOpen, setMoveOpen] = useState(false)
const [isMoving, setIsMoving] = useState(false)
// t() returns the key itself when not found — use this wrapper for safe fallbacks
const ts = (key: string, fallback: string) => {
const v = t(key as Parameters<typeof t>[0])
return v === key ? fallback : v
}
const wordCount = countWords(note.content)
const noteTypeLabel =
note.type === 'checklist'
? ts('notes.typeChecklist', 'Checklist')
: note.isMarkdown
? ts('notes.typeMarkdown', 'Markdown')
: ts('notes.typeText', 'Text')
const handleMoveToNotebook = async (notebookId: string | null) => {
setIsMoving(true)
try {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setMoveOpen(false)
toast.success(ts('notebookSuggestion.movedToNotebook', 'Note déplacée'))
} catch {
toast.error(ts('notes.moveFailed', 'Déplacement échoué'))
} finally {
setIsMoving(false)
}
}
const handleHistory = async () => {
if (!noteHistoryEnabled) {
if (!onEnableHistory) {
toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour votre compte."))
return
}
try {
await onEnableHistory()
toast.success(ts('notes.historyEnabled', 'Historique activé'))
onOpenHistory?.(note)
} catch {
toast.error(ts('general.error', 'Erreur'))
}
return
}
onOpenHistory?.(note)
}
return (
<aside className="flex w-56 shrink-0 flex-col bg-muted border-l border-slate-300 border-t-2 border-t-primary/40 overflow-y-auto shadow-[-6px_0_16px_-4px_rgba(0,0,0,0.08)]">
{/* ── DOCUMENT INFO ── */}
<div className="px-4 pt-5 pb-4 border-b border-border">
<SidebarSection title={ts('notes.documentInfo', 'Document Info')} />
<div className="space-y-3">
{/* Type */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.type', 'Type')}</p>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
note.type === 'checklist'
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: note.isMarkdown
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-card border-slate-200 text-slate-600"
)}
>
{note.type === 'checklist'
? <ListChecks className="h-3 w-3" />
: <FileText className="h-3 w-3" />}
{noteTypeLabel}
</span>
</div>
{/* Word count — discreet inline row */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.wordCount', 'Mots')}</p>
<p className="text-[13px] font-semibold text-foreground/70 tabular-nums">
{wordCount.toLocaleString()} <span className="text-[10px] font-normal text-muted-foreground">{ts('notes.words', 'mots')}</span>
</p>
</div>
{/* Labels */}
{Array.isArray(note.labels) && note.labels.length > 0 && (
<>
<span className="text-muted-foreground/30">·</span>
<div className="flex items-center gap-1">
<Hash className="h-2.5 w-2.5 text-muted-foreground/40" />
<span className="truncate text-[11px] text-muted-foreground/50">
{note.labels.slice(0, 2).join(', ')}
{note.labels.length > 2 && ` +${note.labels.length - 2}`}
</span>
<div>
<p className="mb-1.5 text-[11px] font-semibold text-muted-foreground">{ts('notes.labels', 'Labels')}</p>
<div className="flex flex-wrap gap-1">
{note.labels.map((label) => (
<span
key={label}
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card px-2 py-0.5 text-[11px] font-medium text-foreground/70"
>
<Hash className="h-2.5 w-2.5" />
{label}
</span>
))}
</div>
</>
</div>
)}
</div>
</div>
{/* Delete button - visible on hover */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="flex items-center px-2 text-red-500/60 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
aria-label={deleteLabel}
title={deleteLabel}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{/* ── ACTIONS ── */}
<div className="px-4 pt-4 pb-5 flex-1">
<SidebarSection title={ts('notes.actions', 'Actions')} />
<div className="space-y-0.5">
{/* Move to notebook */}
<Popover open={moveOpen} onOpenChange={setMoveOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-sky-50 hover:text-sky-700 transition-all duration-150"
>
<span className="text-muted-foreground group-hover:text-sky-600 transition-colors">
{isMoving
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <FolderInput className="h-3.5 w-3.5" />}
</span>
{t('notebookSuggestion.moveToNotebook')}
</button>
</PopoverTrigger>
<PopoverContent side="left" align="start" className="w-52 p-1.5">
<div className="mb-1 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
{t('notebookSuggestion.moveToNotebook')}
</div>
<button
type="button"
onClick={() => handleMoveToNotebook(null)}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
!note.notebookId ? 'text-primary' : 'text-foreground/70'
)}
>
{!note.notebookId
? <Check className="h-3 w-3 shrink-0" />
: <span className="h-3 w-3 shrink-0" />}
{t('notes.generalNotes')}
</button>
{notebooks.map((nb) => (
<button
key={nb.id}
type="button"
onClick={() => handleMoveToNotebook(nb.id)}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
note.notebookId === nb.id ? 'text-primary' : 'text-foreground/70'
)}
>
{note.notebookId === nb.id
? <Check className="h-3 w-3 shrink-0" />
: <span className="h-3 w-3 shrink-0" />}
{nb.name}
</button>
))}
</PopoverContent>
</Popover>
{/* Pin / Unpin */}
<SidebarActionBtn
icon={note.isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
onClick={() => onPinToggle(note)}
disabled
/>
{/* Archive */}
<SidebarActionBtn
icon={<Archive className="h-3.5 w-3.5" />}
label={t('notes.archive')}
onClick={() => onArchive(note)}
/>
{/* History */}
<SidebarActionBtn
icon={<History className="h-3.5 w-3.5" />}
label={
noteHistoryEnabled
? ts('notes.history', 'Historique')
: ts('notes.enableHistory', "Activer l'historique")
}
onClick={() => void handleHistory()}
/>
</div>
</div>
</aside>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
export function NotesTabsView({
notes,
onEdit,
currentNotebookId,
noteHistoryEnabled = false,
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
}: NotesTabsViewProps) {
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefreshOptional()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('date-desc')
const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => {
// Only reset when notes are added or removed, NOT on content/field changes
// Field changes arrive through onChange -> setItems already
setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
if (prevIds === incomingIds) {
// Same set of notes: merge only structural fields (pin, color, archive)
return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id)
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())
return {
...fresh,
title: p.title,
content: p.content,
checkItems: p.checkItems,
isMarkdown: p.isMarkdown,
// Always use server labels if different (for global label changes)
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) => {
const local = prev.find((p) => p.id === fresh.id)
if (!local) return fresh
@@ -308,7 +637,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
title: local.title,
content: local.content,
checkItems: local.checkItems,
isMarkdown: local.isMarkdown,
labels: labelsChanged ? fresh.labels : local.labels
}
})
@@ -325,7 +653,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
)
}, [items])
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
@@ -343,7 +670,22 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
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(
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 colorKey = selected ? getColorKey(selected) : 'default'
/** Create a new blank note, add it to the sidebar and select it immediately */
const handleCreateNote = () => {
startCreating(async () => {
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 (
<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)))' }}
data-testid="notes-grid-tabs"
>
{/* ── Left sidebar: note list ── */}
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 bg-muted/20">
{/* Sidebar header with note count + new note button */}
<div className="border-b border-border/40 px-3 py-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
{/* ── Left panel: note list ── */}
<div className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">
{/* Header */}
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-4 py-3.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold tracking-tight text-foreground">
{t('notes.title')}
<span 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 className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
{items.length}
</span>
</div>
<div className="flex items-center gap-1">
{/* Sort / filter button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary',
sortOrder !== 'date-desc' && 'text-primary bg-primary/8'
)}
title={t('notes.sort') || 'Trier'}
>
<ListFilter className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60 py-1">
{t('notes.sortBy') || 'Trier par'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={sortOrder} onValueChange={(v) => setSortOrder(v as SortOrder)}>
<DropdownMenuRadioItem value="date-desc" className="text-[13px]">
{t('notes.sortDateDesc') || 'Date (récent)'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="date-asc" className="text-[13px]">
{t('notes.sortDateAsc') || 'Date (ancien)'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-asc" className="text-[13px]">
{t('notes.sortTitleAsc') || 'Titre A → Z'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-desc" className="text-[13px]">
{t('notes.sortTitleDesc') || 'Titre Z → A'}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* New note button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote') }
title={t('notes.newNote')}
>
{isCreating
? <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>
</div>
</div>
{/* Scrollable note list */}
<div
className="flex-1 overflow-y-auto overscroll-contain p-2"
className="flex-1 overflow-y-auto overscroll-contain bg-background"
role="listbox"
aria-label={t('notes.viewTabs')}
>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="mb-3 rounded-full bg-background p-3 shadow-sm border border-border/50">
<FileText className="h-5 w-5 text-muted-foreground/40" />
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 rounded-2xl border border-border/60 bg-muted/30 p-4">
<FileText className="h-6 w-6 text-muted-foreground/40" />
</div>
<p className="text-sm font-medium text-muted-foreground">{t('notes.emptyStateTabs') || 'Aucune note'}</p>
<p className="mt-1 text-xs text-muted-foreground/60">{t('notes.createFirst') || 'Créez votre première note'}</p>
</div>
) : (
<DndContext
@@ -454,8 +859,8 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{items.map((note) => (
<div className="flex flex-col">
{sortedItems.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
@@ -475,42 +880,74 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
</div>
</div>
{/* ── Right content panel — always in edit mode ── */}
{/* ── Right content panel ── */}
{selected ? (
<div
className={cn(
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
COLOR_PANEL_BG[colorKey]
<div className="flex min-w-0 flex-1 overflow-hidden">
{/* Editor */}
<div
className={cn(
'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-b',
COLOR_PANEL_BG[colorKey]
)}
>
<NoteInlineEditor
key={selected.id}
note={selected}
noteHistoryEnabled={noteHistoryEnabled}
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 className="flex min-w-0 flex-1 items-center justify-center bg-muted/10 border-l border-border/40">
<div className="text-center px-6">
<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">
<FileText className="h-8 w-8 text-muted-foreground/30" />
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10">
<div className="px-10 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-border/60 bg-background shadow-sm">
<FileText className="h-7 w-7 text-muted-foreground/30" />
</div>
<h3 className="text-lg font-heading font-medium text-foreground">{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
<p className="text-sm font-medium text-foreground/60">
{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}
</p>
<p className="mt-1.5 text-xs text-muted-foreground/50 max-w-[200px] mx-auto leading-relaxed">
{items.length === 0
? t('notes.emptyNotebookDesc')
: t('notes.selectOrCreateNote')}
@@ -528,7 +965,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
{noteToDelete && (
<span className="mt-2 block font-medium text-foreground">
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
&quot;{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}&quot;
</span>
)}
</DialogDescription>
@@ -561,4 +998,3 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
</div>
)
}

View File

@@ -4,7 +4,7 @@ import Link from 'next/link'
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
Lightbulb,
FileText,
Bell,
Archive,
Trash2,
@@ -81,7 +81,8 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
return pathname === '/' &&
!searchParams.get('label') &&
!searchParams.get('archived') &&
!searchParams.get('trashed')
!searchParams.get('trashed') &&
!searchParams.get('notebook')
}
// For labels
@@ -131,7 +132,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
<div className="flex flex-col gap-1 px-3">
<NavItem
href="/"
icon={Lightbulb}
icon={FileText}
label={t('sidebar.notes') || 'Notes'}
active={isActive('/')}
/>

View File

@@ -15,16 +15,16 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
if (suggestions.length === 0) return null
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 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" />
<span>{t('titleSuggestions.title')}</span>
</div>
<button
type="button"
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" />
</button>
@@ -38,19 +38,19 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
onClick={() => onSelect(suggestion.title)}
className={cn(
"w-full text-left px-3 py-2 rounded-md transition-all",
"hover:bg-amber-100 dark:hover:bg-amber-900",
"text-sm text-amber-900 dark:text-amber-100",
"border border-transparent hover:border-amber-300 dark:hover:border-amber-700"
"hover:bg-sky-100 dark:hover:bg-sky-900/50",
"text-sm text-sky-900 dark:text-sky-100",
"border border-transparent hover:border-sky-300 dark:hover:border-sky-700"
)}
>
<div className="flex items-start justify-between gap-2">
<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}%
</span>
</div>
{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}
</p>
)}

View 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
}

View File

@@ -80,6 +80,28 @@ export interface Note {
searchScore?: number | null;
}
export interface NoteHistoryEntry {
id: string;
noteId: string;
userId: string;
version: number;
reason: string | null;
title: string | null;
content: string;
color: string;
isPinned: boolean;
isArchived: boolean;
type: 'text' | 'checklist';
checkItems: CheckItem[] | null;
labels: string[] | null;
images: string[] | null;
links: LinkMetadata[] | null;
isMarkdown: boolean;
size: NoteSize;
notebookId: string | null;
createdAt: Date;
}
export type NoteSize = 'small' | 'medium' | 'large';
export interface LabelWithColor {

View File

@@ -979,7 +979,29 @@
"notes.emptyNotebook": "دفتر فارغ",
"notes.emptyNotebookDesc": "لا توجد ملاحظات. انقر على + لإنشاء واحدة.",
"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": {
"next": "→",

View File

@@ -1002,7 +1002,29 @@
"notes.emptyNotebook": "Leeres Notizbuch",
"notes.emptyNotebookDesc": "Keine Notizen vorhanden. Klicke auf + um eine zu erstellen.",
"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": {
"next": "→",

View File

@@ -161,7 +161,29 @@
"notes.emptyNotebook": "Empty notebook",
"notes.emptyNotebookDesc": "This notebook has no notes. Click + to create one.",
"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": {
"previous": "←",

View File

@@ -974,7 +974,29 @@
"notes.emptyNotebook": "Cuaderno vacío",
"notes.emptyNotebookDesc": "Este cuaderno no tiene notas. Haz clic en + para crear una.",
"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": {
"next": "→",

View File

@@ -1032,7 +1032,29 @@
"notes.emptyNotebook": "دفترچه خالی",
"notes.emptyNotebookDesc": "این دفترچه یادداشتی ندارد. روی + کلیک کنید تا یکی بسازید.",
"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": {
"next": "→",

View File

@@ -362,56 +362,56 @@
"undo": "Annuler IA",
"undoAI": "Annuler la transformation IA",
"undoApplied": "Texte original restauré",
"minWordsError": "Note must contain at least 5 words to use AI actions.",
"genericError": "AI error",
"actionError": "Error during AI action",
"appliedToNote": "Applied to note",
"applyToNote": "Apply to note",
"undoLastAction": "Undo last AI action",
"selectContext": "Select context...",
"selectNotebook": "Select notebook",
"chatPlaceholder": "Ask AI to edit, summarize, or draft...",
"assistantTitle": "AI Assistant",
"currentNote": "Current note",
"shrinkPanel": "Shrink panel",
"expandPanel": "Expand panel",
"chatTab": "Chat",
"noteActions": "Note Actions",
"askToStart": "Ask the Assistant something to get started.",
"contextLabel": "Context",
"thisNote": "This note",
"allMyNotes": "All my notes",
"notebookGeneric": "Notebook",
"writingTone": "Writing Tone",
"askAboutThisNote": "Ask AI something about this note...",
"askAboutYourNotes": "Ask AI something about your notes...",
"webSearchLabel": "Web Search",
"newLineHint": "Shift+Enter = new line",
"resultLabel": "Result",
"discardAction": "Discard",
"transformationsDesc": "Transformations — applied directly to the note",
"writeMinWordsAction": "Write at least 5 words to activate AI actions.",
"processingAction": "Processing...",
"minWordsError": "La note doit contenir au moins 5 mots pour utiliser les actions IA.",
"genericError": "Erreur IA",
"actionError": "Erreur lors de l'action IA",
"appliedToNote": "Appliqué à la note",
"applyToNote": "Appliquer à la note",
"undoLastAction": "Annuler la dernière action IA",
"selectContext": "Sélectionner le contexte...",
"selectNotebook": "Sélectionner un carnet",
"chatPlaceholder": "Demandez à l'IA de modifier, résumer ou rédiger...",
"assistantTitle": "Assistant IA",
"currentNote": "Note actuelle",
"shrinkPanel": "Réduire le panneau",
"expandPanel": "Agrandir le panneau",
"chatTab": "Discussion",
"noteActions": "Actions sur la note",
"askToStart": "Posez une question à l'Assistant pour commencer.",
"contextLabel": "Contexte",
"thisNote": "Cette note",
"allMyNotes": "Toutes mes notes",
"notebookGeneric": "Carnet",
"writingTone": "Ton d'écriture",
"askAboutThisNote": "Posez une question sur cette note...",
"askAboutYourNotes": "Posez une question sur vos notes...",
"webSearchLabel": "Recherche web",
"newLineHint": "Maj+Entrée = nouvelle ligne",
"resultLabel": "Résultat",
"discardAction": "Ignorer",
"transformationsDesc": "Transformations — appliquées directement à la note",
"writeMinWordsAction": "Écrivez au moins 5 mots pour activer les actions IA.",
"processingAction": "Traitement en cours...",
"action": {
"clarify": "Clarify",
"shorten": "Shorten",
"improve": "Improve",
"toMarkdown": "To Markdown"
"clarify": "Clarifier",
"shorten": "Raccourcir",
"improve": "Améliorer",
"toMarkdown": "Convertir en Markdown"
},
"openAssistant": "Open AI Assistant",
"poweredByMomento": "Powered by 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.",
"summaryLast5": "Summary of your last 5 notes",
"analyzingProgress": "Analyzing...",
"generateInsightsBtn": "Generate Insights",
"newDiscussion": "New discussion",
"noRecentConversations": "No recent conversations.",
"discussionContextLabel": "Discussion Context",
"webSearchNotConfigured": "Web Search (Not configured)",
"historyTab": "History",
"openAssistant": "Ouvrir l'Assistant IA",
"poweredByMomento": "Propulsé par Momento AI",
"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": "Résumé de vos 5 dernières notes",
"analyzingProgress": "Analyse en cours...",
"generateInsightsBtn": "Générer des insights",
"newDiscussion": "Nouvelle discussion",
"noRecentConversations": "Aucune conversation récente.",
"discussionContextLabel": "Contexte de discussion",
"webSearchNotConfigured": "Recherche web (non configurée)",
"historyTab": "Historique",
"insightsTab": "Insights",
"aiCopilot": "AI Copilot",
"suggestTitle": "AI title suggestion"
"aiCopilot": "Copilote IA",
"suggestTitle": "Suggestion de titre IA"
},
"aiSettings": {
"description": "Configurez vos fonctionnalités IA et préférences",
@@ -985,7 +985,29 @@
"notes.emptyNotebook": "Empty notebook",
"notes.emptyNotebookDesc": "This notebook has no notes. Click + to create one.",
"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": {
"next": "→",

View File

@@ -979,7 +979,29 @@
"notes.emptyNotebook": "खाली नोटबुक",
"notes.emptyNotebookDesc": "इस नोटबुक में कोई नोट नहीं है। एक बनाने के लिए + पर क्लिक करें।",
"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": {
"next": "→",

View File

@@ -1024,7 +1024,29 @@
"notes.emptyNotebook": "Quaderno vuoto",
"notes.emptyNotebookDesc": "Questo quaderno non ha note. Clicca + per crearne una.",
"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": {
"next": "→",

View File

@@ -1002,7 +1002,29 @@
"notes.emptyNotebook": "空のノートブック",
"notes.emptyNotebookDesc": "このノートブックにはノートがありません。+ をクリックして作成。",
"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": {
"next": "→",

View File

@@ -979,7 +979,29 @@
"notes.emptyNotebook": "빈 노트북",
"notes.emptyNotebookDesc": "이 노트북에 노트가 없습니다. +를 클릭하여 만드세요.",
"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": {
"next": "→",

View File

@@ -1024,7 +1024,29 @@
"notes.emptyNotebook": "Leeg notitieboek",
"notes.emptyNotebookDesc": "Dit notitieboek heeft geen notities. Klik op + om er een te maken.",
"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": {
"next": "→",

View File

@@ -1046,7 +1046,29 @@
"notes.emptyNotebook": "Pusty notatnik",
"notes.emptyNotebookDesc": "Ten notatnik nie ma notatek. Kliknij + aby utworzyć.",
"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": {
"next": "→",

View File

@@ -974,7 +974,29 @@
"notes.emptyNotebook": "Caderno vazio",
"notes.emptyNotebookDesc": "Este caderno não tem notas. Clique em + para criar uma.",
"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": {
"next": "→",

View File

@@ -974,7 +974,29 @@
"notes.emptyNotebook": "Пустой блокнот",
"notes.emptyNotebookDesc": "В этом блокноте нет заметок. Нажмите +, чтобы создать.",
"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": {
"next": "→",

View File

@@ -1002,7 +1002,29 @@
"notes.emptyNotebook": "空笔记本",
"notes.emptyNotebookDesc": "此笔记本没有笔记。点击 + 创建一个。",
"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": {
"next": "→",

View File

@@ -7,7 +7,8 @@
"build": "prisma generate && next build",
"start": "next start",
"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:push": "prisma db push",
"db:studio": "prisma studio",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserAISettings" ADD COLUMN "noteHistoryMode" TEXT NOT NULL DEFAULT 'manual';

View File

@@ -30,6 +30,7 @@ model User {
labels Label[]
memoryEchoInsights MemoryEchoInsight[]
notes Note[]
noteHistories NoteHistory[]
sentShares NoteShare[] @relation("SentShares")
receivedShares NoteShare[] @relation("ReceivedShares")
notebooks Notebook[]
@@ -152,6 +153,7 @@ model Note {
noteEmbedding NoteEmbedding?
shares NoteShare[]
labelRelations Label[] @relation("LabelToNote")
historyEntries NoteHistory[]
@@index([isPinned])
@@index([isArchived])
@@ -162,6 +164,34 @@ model Note {
@@index([userId, notebookId])
}
model NoteHistory {
id String @id @default(cuid())
noteId String
userId String
version Int
reason String?
title String?
content String
color String
isPinned Boolean
isArchived Boolean
type String
checkItems String?
labels String?
images String?
links String?
isMarkdown Boolean
size String
notebookId String?
createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([noteId, version])
@@index([noteId, createdAt(sort: Desc)])
@@index([userId, noteId, createdAt(sort: Desc)])
}
model NoteShare {
id String @id @default(cuid())
noteId String
@@ -244,6 +274,8 @@ model UserAISettings {
desktopNotifications Boolean @default(false)
anonymousAnalytics Boolean @default(false)
autoLabeling Boolean @default(true)
noteHistory Boolean @default(false)
noteHistoryMode String @default("manual")
languageDetection Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View 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')