feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesViewType } from '@/components/notes-list-views'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, type NotesViewType, isClassicLayoutMode } from '@/components/notes-list-views'
|
||||
import {
|
||||
NOTES_LAYOUT_STORAGE_KEY,
|
||||
NOTES_VIEW_TYPE_STORAGE_KEY,
|
||||
@@ -14,10 +14,16 @@ import {
|
||||
setNotesLayoutPreference,
|
||||
setNotesViewTypePreference,
|
||||
} from '@/lib/notes-view-preference'
|
||||
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
|
||||
import {
|
||||
bootstrapStructuredNotebook,
|
||||
ensureKanbanStatusField,
|
||||
type BootstrapStructuredTarget,
|
||||
} from '@/lib/structured-views/bootstrap-structured-notebook'
|
||||
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table } from 'lucide-react'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3 } from 'lucide-react'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
@@ -53,6 +59,22 @@ const OrganizeNotebookDialog = dynamic(
|
||||
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsIntro = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsHelpBanner = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-help-banner').then(m => ({ default: m.StructuredViewsHelpBanner })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsContainer = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-container').then(m => ({ default: m.StructuredViewsContainer })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const AddPropertyDialog = dynamic(
|
||||
() => import('@/components/structured-views/add-property-dialog').then(m => ({ default: m.AddPropertyDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type InitialSettings = {
|
||||
showRecentNotes: boolean
|
||||
@@ -116,6 +138,29 @@ export function HomeClient({
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
|
||||
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
||||
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const schemaHook = useNotebookSchema(notebookFilter)
|
||||
const structuredModeActive = Boolean(notebookFilter && schemaHook.schema)
|
||||
const wantsStructuredView = Boolean(
|
||||
notebookFilter && (layoutMode === 'table' || layoutMode === 'kanban'),
|
||||
)
|
||||
const structuredViewMode: BootstrapStructuredTarget =
|
||||
layoutMode === 'kanban' ? 'kanban' : 'table'
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutMode === 'gallery') {
|
||||
setLayoutMode('grid')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notebookFilter && (layoutMode === 'kanban' || layoutMode === 'gallery')) {
|
||||
setLayoutMode('list')
|
||||
}
|
||||
}, [notebookFilter, layoutMode])
|
||||
|
||||
useEffect(() => {
|
||||
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
|
||||
@@ -141,7 +186,7 @@ export function HomeClient({
|
||||
useEffect(() => {
|
||||
const onLayoutChange = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ layout?: NotesLayoutMode }>).detail?.layout
|
||||
if (detail === 'grid' || detail === 'list' || detail === 'table') {
|
||||
if (detail === 'grid' || detail === 'list' || detail === 'table' || detail === 'kanban') {
|
||||
setLayoutMode(detail)
|
||||
setViewType('notes')
|
||||
}
|
||||
@@ -161,8 +206,6 @@ export function HomeClient({
|
||||
}
|
||||
}, [searchParams, router])
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
|
||||
const fetchNotesForCurrentView = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
@@ -305,6 +348,95 @@ export function HomeClient({
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddNoteWithProperties = (prefill: Record<string, unknown>) => {
|
||||
startCreating(async () => {
|
||||
try {
|
||||
const newNote = await createNote({
|
||||
content: '',
|
||||
type: 'richtext',
|
||||
title: undefined,
|
||||
notebookId: notebookFilter || undefined,
|
||||
skipRevalidation: true,
|
||||
})
|
||||
if (!newNote) return
|
||||
if (Object.keys(prefill).length > 0) {
|
||||
await fetch(`/api/notes/${newNote.id}/properties`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ properties: prefill }),
|
||||
})
|
||||
schemaHook.patchNoteValuesLocal(newNote.id, prefill)
|
||||
}
|
||||
handleNoteCreated(newNote)
|
||||
setEditingNote({ note: newNote, readOnly: false })
|
||||
} catch {
|
||||
toast.error(t('notes.createFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const structuredFieldLabels = useMemo(
|
||||
() => ({
|
||||
statusName: t('structuredViews.wizard.fields.status.name'),
|
||||
statusOptions: t('structuredViews.wizard.fields.status.options')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
[t],
|
||||
)
|
||||
|
||||
const structuredBootstrapActions = useMemo(
|
||||
() => ({
|
||||
getSchema: () => schemaHook.schema,
|
||||
enableStructuredMode: schemaHook.enableStructuredMode,
|
||||
addProperty: schemaHook.addProperty,
|
||||
setKanbanGroupProperty: schemaHook.setKanbanGroupProperty,
|
||||
}),
|
||||
[schemaHook],
|
||||
)
|
||||
|
||||
const handleEnableStructured = useCallback(
|
||||
async (target: BootstrapStructuredTarget) => {
|
||||
if (!notebookFilter) return
|
||||
setIsEnablingStructured(true)
|
||||
try {
|
||||
await bootstrapStructuredNotebook(target, structuredFieldLabels, structuredBootstrapActions)
|
||||
await schemaHook.reload()
|
||||
setLayoutMode(target)
|
||||
toast.success(t('structuredViews.intro.enabledSuccess'))
|
||||
} catch {
|
||||
toast.error(t('structuredViews.enableFailed'))
|
||||
} finally {
|
||||
setIsEnablingStructured(false)
|
||||
}
|
||||
},
|
||||
[notebookFilter, structuredFieldLabels, structuredBootstrapActions, schemaHook, t],
|
||||
)
|
||||
|
||||
const handleQuickAddKanbanStatus = useCallback(async () => {
|
||||
try {
|
||||
await ensureKanbanStatusField(structuredFieldLabels, structuredBootstrapActions)
|
||||
await schemaHook.reload()
|
||||
} catch {
|
||||
toast.error(t('structuredViews.enableFailed'))
|
||||
}
|
||||
}, [structuredFieldLabels, structuredBootstrapActions, schemaHook, t])
|
||||
|
||||
const selectLayoutMode = useCallback((mode: NotesLayoutMode) => {
|
||||
if (mode === 'gallery') return
|
||||
setLayoutMode(mode)
|
||||
setViewType('notes')
|
||||
}, [])
|
||||
|
||||
const showStructuredIntro =
|
||||
wantsStructuredView && !structuredModeActive && !schemaHook.loading
|
||||
const showStructuredDataView = wantsStructuredView && structuredModeActive && schemaHook.schema
|
||||
const showStructuredLoading = wantsStructuredView && schemaHook.loading
|
||||
|
||||
const classicLayoutMode: NotesClassicLayoutMode =
|
||||
isClassicLayoutMode(layoutMode) ? layoutMode : 'list'
|
||||
|
||||
const handleOpenHistory = useCallback((note: Note) => {
|
||||
setHistoryNote(note)
|
||||
setHistoryOpen(true)
|
||||
@@ -840,7 +972,7 @@ export function HomeClient({
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('grid')}
|
||||
onClick={() => selectLayoutMode('grid')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'grid'
|
||||
@@ -853,7 +985,7 @@ export function HomeClient({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('list')}
|
||||
onClick={() => selectLayoutMode('list')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'list'
|
||||
@@ -864,22 +996,66 @@ export function HomeClient({
|
||||
>
|
||||
<List size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('table')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'table'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('notes.layoutTableTitle')}
|
||||
>
|
||||
<Table size={13} />
|
||||
</button>
|
||||
{!notebookFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectLayoutMode('table')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'table'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('notes.layoutTableTitle')}
|
||||
>
|
||||
<Table size={13} />
|
||||
</button>
|
||||
)}
|
||||
{notebookFilter && (
|
||||
<>
|
||||
<span className="w-px h-4 bg-border/50 mx-0.5" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectLayoutMode('table')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'table'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('structuredViews.viewTableHint')}
|
||||
>
|
||||
<Table size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectLayoutMode('kanban')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'kanban'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('structuredViews.viewKanbanHint')}
|
||||
>
|
||||
<Columns3 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewType === 'notes' && currentNotebook && structuredModeActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddPropertyOpen(true)}
|
||||
className="p-1.5 rounded-full text-muted-foreground hover:text-brand-accent transition-colors"
|
||||
title={t('structuredViews.addProperty')}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{searchParams.get('notebook') && (
|
||||
<button
|
||||
onClick={() => setSummaryDialogOpen(true)}
|
||||
@@ -986,6 +1162,34 @@ export function HomeClient({
|
||||
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||||
) : showStructuredLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||||
) : showStructuredIntro ? (
|
||||
<StructuredViewsIntro
|
||||
target={structuredViewMode}
|
||||
enabling={isEnablingStructured}
|
||||
onEnable={() => void handleEnableStructured(structuredViewMode)}
|
||||
/>
|
||||
) : showStructuredDataView && schemaHook.schema && notebookFilter ? (
|
||||
<>
|
||||
<StructuredViewsHelpBanner notebookId={notebookFilter} mode={structuredViewMode} />
|
||||
<StructuredViewsContainer
|
||||
mode={structuredViewMode}
|
||||
notes={sortedNotes}
|
||||
schema={schemaHook.schema}
|
||||
noteValues={schemaHook.noteValues}
|
||||
notebookColor={currentNotebook?.color}
|
||||
onOpen={(note) => handleOpenNoteFresh(note.id, false)}
|
||||
onNoteValuesPatch={schemaHook.patchNoteValuesLocal}
|
||||
onCreateNote={handleAddNoteWithProperties}
|
||||
onSetKanbanGroupProperty={(id) => void schemaHook.setKanbanGroupProperty(id)}
|
||||
onQuickAddKanbanStatus={() => void handleQuickAddKanbanStatus()}
|
||||
onDeleteProperty={async (propertyId) => {
|
||||
await schemaHook.deleteProperty(propertyId)
|
||||
toast.success(t('structuredViews.deletePropertySuccess'))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<p className="font-memento-serif text-xl italic text-muted-foreground">
|
||||
@@ -1004,7 +1208,7 @@ export function HomeClient({
|
||||
notes={sortedNotes}
|
||||
pinnedNotes={sortedPinnedNotes}
|
||||
viewType={viewType}
|
||||
layoutMode={layoutMode}
|
||||
layoutMode={classicLayoutMode}
|
||||
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
notebookName={currentNotebook?.name}
|
||||
@@ -1090,6 +1294,15 @@ export function HomeClient({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{notebookFilter && schemaHook.schema && (
|
||||
<AddPropertyDialog
|
||||
open={addPropertyOpen}
|
||||
onClose={() => setAddPropertyOpen(false)}
|
||||
onSubmit={async (name, type, options) => {
|
||||
await schemaHook.addProperty(name, type, options)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user