feat: auto-save 2s + indicateur save + reminders inline actions (compléter/snooze)
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m23s
CI / Deploy production (on server) (push) Has been cancelled

- Auto-save debounce 2s dans note-editor-context
- lastSavedAt state + setIsDirty(false) dans handleSave
- Indicateur toolbar: ✓ sauvegardé / ● non enregistré avec timer relatif
- Reminders sidebar: bouton ✓ compléter + bouton +1h snooze (hover inline)
- i18n: clés reminders.markDone/snooze1h + notes.savedJustNow/unsaved (15 locales)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Antigravity
2026-05-29 18:58:19 +00:00
parent 0fa8978395
commit 1b56af9743
19 changed files with 221 additions and 66 deletions

View File

@@ -81,6 +81,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const [color, setColor] = useState(note.color)
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [isMarkdown, setIsMarkdown] = useState(note.type === 'markdown')
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
@@ -691,6 +692,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
emitNoteChange({ type: 'updated', note: result })
setIsDirty(false)
setLastSavedAt(new Date())
toast.success(t('notes.saved') || 'Note sauvegardée !')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
@@ -816,6 +819,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
emitNoteChange({ type: 'updated', note: result })
setIsDirty(false)
setLastSavedAt(new Date())
toast.success(t('notes.saved') || 'Saved')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
@@ -830,6 +834,23 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleSaveRef = useRef(handleSave)
handleSaveRef.current = handleSave
// Auto-save : 2s après le dernier changement si isDirty
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!isDirty || isSaving || readOnly) return
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = setTimeout(() => {
if (fullPage) {
void handleSaveInPlaceRef.current()
} else {
void handleSaveRef.current()
}
}, 2000)
return () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
}
}, [isDirty, isSaving, readOnly, fullPage])
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
@@ -870,6 +891,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
removedImageUrls,
isSaving,
isDirty,
lastSavedAt,
isProcessingAI,
aiOpen,
infoOpen,
@@ -894,7 +916,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
quotaExceededFeature,
}), [
title, content, checkItems, labels, images, links, newLabel, color, size,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, lastSavedAt, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
@@ -46,6 +46,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const [relativeTime, setRelativeTime] = useState<string | null>(null)
// Mise à jour du temps relatif depuis la dernière sauvegarde
useEffect(() => {
if (!state.lastSavedAt) { setRelativeTime(null); return }
const update = () => {
const secs = Math.floor((Date.now() - state.lastSavedAt!.getTime()) / 1000)
if (secs < 10) setRelativeTime(t('notes.savedJustNow') || 'Sauvegardé')
else if (secs < 60) setRelativeTime(`${secs}s`)
else setRelativeTime(`${Math.floor(secs / 60)}min`)
}
update()
const interval = setInterval(update, 10000)
return () => clearInterval(interval)
}, [state.lastSavedAt, t])
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
@@ -294,20 +309,34 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
)}
{!readOnly && (
<button
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={actions.handleSaveInPlace}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
<div className="flex items-center gap-1.5">
{/* Indicateur dernière sauvegarde */}
{!state.isDirty && relativeTime && !state.isSaving && (
<span className="hidden sm:flex items-center gap-1 text-[10px] text-concrete/70 font-medium select-none">
<Check size={10} className="text-emerald-500" />
{relativeTime}
</span>
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
</button>
{state.isDirty && !state.isSaving && (
<span className="hidden sm:block text-[10px] text-amber-500 font-medium select-none">
{t('notes.unsaved') || '●'}
</span>
)}
<button
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={actions.handleSaveInPlace}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
</button>
</div>
)}
{!readOnly && (

View File

@@ -19,6 +19,7 @@ export interface NoteEditorState {
removedImageUrls: string[]
isSaving: boolean
isDirty: boolean
lastSavedAt: Date | null
isProcessingAI: boolean
aiOpen: boolean