feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair

Harmonise la liste des notes et l’éditeur (titres, dates, retour),
rend la saisie rapide et les raccourcis de l’accueil utilisables
sur téléphone, et conserve le panneau latéral repliable.
This commit is contained in:
Antigravity
2026-09-05 20:53:48 +00:00
parent a7b16870a4
commit 3bdbec575a
37 changed files with 1138 additions and 482 deletions

View File

@@ -1,7 +1,8 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { usePathname, useSearchParams } from 'next/navigation'
import { editorBackLabelKey, resolveEditorBackKind } from '@/lib/editor/editor-back-context'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
@@ -34,7 +35,6 @@ import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useVoiceTranscription } from '@/hooks/use-voice-transcription'
import { toast } from 'sonner'
import { format } from 'date-fns'
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
import { getCalloutColors } from '@/lib/editor/callout-colors'
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
@@ -52,7 +52,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
const { t, language } = useLanguage()
const searchParams = useSearchParams()
const fromDashboard = searchParams.get('from') === 'dashboard' || Boolean(searchParams.get('peekNote'))
const pathname = usePathname()
const backKind = resolveEditorBackKind(pathname, searchParams)
const backLabel = t(editorBackLabelKey(backKind))
const { requestAiConsent } = useAiConsent()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
@@ -562,32 +564,40 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
if (mode === 'fullPage') {
const handleCloseWithSave = async () => {
if (state.isDirty && !state.isSaving) {
await actions.handleSaveInPlace()
if (state.isSaving) return
if (state.isDirty) {
const saved = await actions.handleSaveInPlace()
if (!saved) return
}
onClose()
}
return (
<div className="max-w-5xl mx-auto w-full px-4 sm:px-8 md:px-12 py-4 sm:py-6 md:py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
<div className="max-w-5xl mx-auto w-full px-3 sm:px-8 md:px-12 py-3 sm:py-6 md:py-8 flex items-center justify-between gap-2 sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
<button
type="button"
onClick={handleCloseWithSave}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
title={backLabel}
aria-label={backLabel}
className="flex h-11 min-w-11 shrink-0 items-center gap-2 px-1.5 md:px-0 text-foreground hover:opacity-60 transition-opacity rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">
{fromDashboard ? t('notes.backToDashboard') : t('notes.backToCollection')}
</span>
<ArrowLeft size={18} className="rtl:rotate-180 shrink-0" />
<span className="hidden md:inline text-sm font-medium whitespace-nowrap">{backLabel}</span>
<span className="md:hidden text-sm font-medium whitespace-nowrap">{t('general.back')}</span>
</button>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="flex items-center gap-1 sm:gap-1.5 md:gap-2 min-w-0">
{/* 1. Status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none me-1">
<span
className="flex items-center gap-1.5 text-[11px] text-foreground/40 select-none me-1 min-h-11 md:min-h-0"
title={state.isSaving ? t('notes.saving') : state.isDirty ? t('notes.dirtyStatus') : t('notes.savedStatus')}
aria-label={state.isSaving ? t('notes.saving') : state.isDirty ? t('notes.dirtyStatus') : t('notes.savedStatus')}
>
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
? <><Loader2 className="h-3 w-3 animate-spin" /><span className="hidden sm:inline">{t('notes.saving')}</span></>
: state.isDirty
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus')}</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus')}</span></>}
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span className="hidden sm:inline">{t('notes.dirtyStatus')}</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span className="hidden sm:inline">{t('notes.savedStatus')}</span></>}
</span>
{note.historyEnabled && (
@@ -607,7 +617,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
@@ -626,7 +636,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
title={t('richTextEditor.publishTitle')}
aria-label={t('richTextEditor.publishTitle')}
className={cn(
'p-1.5 rounded-full border transition-all',
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all md:h-auto md:w-auto md:p-1.5',
publishMeta.isPublic
? 'border-green-400/40 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/20'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
@@ -834,7 +844,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
title={t('notes.shareNoteTitle')}
aria-label={t('notes.shareNoteAria')}
onClick={() => setShareOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
className="hidden min-[360px]:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all md:h-auto md:w-auto md:p-1.5"
>
<Share2 size={16} />
</button>
@@ -846,7 +856,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
aria-label={t('ai.openAssistant')}
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
@@ -866,19 +876,19 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
if (!seed.trim()) return
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
}}
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
className="hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all md:h-auto md:w-auto md:p-1.5"
>
<Wind size={16} />
</button>
{/* 7. Education (flashcards / exercises) */}
{!readOnly && (
<div className="relative">
<div className="relative hidden md:block">
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setShowEduMenu(v => !v)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all md:h-auto md:w-auto md:p-1.5"
>
<GraduationCap size={16} />
</button>
@@ -926,7 +936,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
onClick={toggleVoice}
className={cn(
'p-1.5 rounded-full border transition-all',
'hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all md:h-auto md:w-auto md:p-1.5',
voiceState === 'listening'
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
@@ -941,7 +951,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
title={t('notes.attachments') || 'Attachments'}
aria-label={t('notes.attachments') || 'Attachments'}
onClick={onToggleAttachments}
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
className="relative hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all md:h-auto md:w-auto md:p-1.5"
>
<Paperclip size={16} />
{(attachmentsCount ?? 0) > 0 && (
@@ -958,7 +968,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
@@ -975,7 +985,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
onClick={handleConvertToRichtext}
disabled={isConverting}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
isConverting && 'opacity-50 cursor-not-allowed'
)}
@@ -984,53 +994,123 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</button>
)}
{/* 9. Overflow menu */}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label={t('notes.optionsMenuAria')} className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileDown className="h-4 w-4 me-2" />
{t('richTextEditor.exportMarkdown')}
{/* 9. Overflow menu — always on mobile (secondary actions), desktop when editable */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('notes.optionsMenuAria')}
className={cn(
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all md:h-auto md:w-auto md:p-1.5',
readOnly && 'md:hidden',
)}
>
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{!readOnly && (
<DropdownMenuItem className="min-[360px]:hidden" onClick={() => setShareOpen(true)}>
<Share2 className="h-4 w-4 me-2" />
{t('notes.shareNoteTitle')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportPdf}>
<Printer className="h-4 w-4 me-2" />
{t('richTextEditor.exportPdf') || 'Exporter en PDF'}
</DropdownMenuItem>
<DropdownMenuItem onClick={openMarkdownImport}>
<FileUp className="h-4 w-4 me-2" />
{t('richTextEditor.importMarkdown')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={actions.toggleAutoSave} className="flex items-center justify-between cursor-pointer">
<span className="flex items-center">
<Save className="h-4 w-4 me-2 text-muted-foreground" />
{t('settings.autoSave') || 'Auto-enregistrement'}
</span>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider',
state.autoSaveEnabled
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
)}>
{state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setConfirmDeleteOpen(true)}
className="text-red-600 dark:text-red-400 focus:text-red-600"
>
<Trash2 className="h-4 w-4 me-2" />
{t('notes.deleteNoteConfirmItem')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
)}
<DropdownMenuItem
className="md:hidden"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
>
<PanelRight className="h-4 w-4 me-2" />
{t('notes.documentInfoAria')}
</DropdownMenuItem>
<DropdownMenuItem
className="md:hidden"
onClick={() => {
const title = note.title || ''
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
const seed = title ? `${title}. ${summary}` : summary
if (!seed.trim()) return
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
}}
>
<Wind className="h-4 w-4 me-2" />
{t('notes.brainstormThisIdea')}
</DropdownMenuItem>
{!readOnly && (
<>
<DropdownMenuItem className="md:hidden" onClick={() => setFlashcardsOpen(true)}>
<GraduationCap className="h-4 w-4 me-2" />
{t('flashcards.toolbarGenerate')}
</DropdownMenuItem>
<DropdownMenuItem className="md:hidden" onClick={() => void handleGenerateExercises()}>
<PenTool className="h-4 w-4 me-2" />
{t('richTextEditor.generateExercises') || 'Générer des exercices'}
</DropdownMenuItem>
{voiceSupported && (
<DropdownMenuItem className="md:hidden" onClick={toggleVoice}>
{voiceState === 'listening' ? <MicOff className="h-4 w-4 me-2" /> : <Mic className="h-4 w-4 me-2" />}
{voiceState === 'listening'
? (t('editor.voiceStop') || 'Arrêter la dictée')
: (t('editor.voiceStart') || 'Dicter du texte')}
</DropdownMenuItem>
)}
{onToggleAttachments && (
<DropdownMenuItem className="md:hidden" onClick={onToggleAttachments}>
<Paperclip className="h-4 w-4 me-2" />
{t('notes.attachments') || 'Attachments'}
</DropdownMenuItem>
)}
{state.isMarkdown && (
<>
<DropdownMenuItem className="md:hidden" onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}>
<Eye className="h-4 w-4 me-2" />
{state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
</DropdownMenuItem>
<DropdownMenuItem className="md:hidden" onClick={() => void handleConvertToRichtext()}>
<Wand2 className="h-4 w-4 me-2" />
{t('ai.convertToRichtext') || 'Convert to Rich Text'}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator className="md:hidden" />
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileDown className="h-4 w-4 me-2" />
{t('richTextEditor.exportMarkdown')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportPdf}>
<Printer className="h-4 w-4 me-2" />
{t('richTextEditor.exportPdf') || 'Exporter en PDF'}
</DropdownMenuItem>
<DropdownMenuItem onClick={openMarkdownImport}>
<FileUp className="h-4 w-4 me-2" />
{t('richTextEditor.importMarkdown')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={actions.toggleAutoSave} className="flex items-center justify-between cursor-pointer">
<span className="flex items-center">
<Save className="h-4 w-4 me-2 text-muted-foreground" />
{t('settings.autoSave') || 'Auto-enregistrement'}
</span>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider',
state.autoSaveEnabled
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
)}>
{state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setConfirmDeleteOpen(true)}
className="text-red-600 dark:text-red-400 focus:text-red-600"
>
<Trash2 className="h-4 w-4 me-2" />
{t('notes.deleteNoteConfirmItem')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<ConfirmDeleteNoteDialog
open={confirmDeleteOpen}
@@ -1091,7 +1171,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
state.infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'