Files
Momento/memento-note/components/structured-views/notes-kanban-view.tsx
Antigravity 0784c94242
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped
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>
2026-05-24 23:03:16 +00:00

224 lines
8.0 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
useDraggable,
useDroppable,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import type { Note } from '@/lib/types'
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
type NotesKanbanViewProps = {
notes: Note[]
schema: NotebookSchemaPayload
noteValues: Record<string, NotePropertyValues>
onOpen: (note: Note) => void
onPropertyChange: (noteId: string, propertyId: string, value: unknown) => void
onCreateNote: (prefill: Record<string, unknown>) => void
onSetGroupProperty: (propertyId: string) => void
onQuickAddKanbanStatus?: () => void
}
function DraggableCard({ note, onOpen, dragDisabled }: { note: Note; onOpen: (note: Note) => void; dragDisabled?: boolean }) {
const { t } = useLanguage()
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: note.id, disabled: dragDisabled })
const style = transform
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
: undefined
return (
<div
ref={setNodeRef}
style={style}
{...(dragDisabled ? {} : listeners)}
{...(dragDisabled ? {} : attributes)}
className={cn(
'rounded-xl border border-border/50 bg-card p-3 shadow-sm',
!dragDisabled && 'cursor-grab active:cursor-grabbing touch-none',
isDragging && 'opacity-40',
)}
>
<button
type="button"
onClick={() => onOpen(note)}
className="font-memento-serif text-[13px] font-medium text-left w-full hover:text-brand-accent transition-colors pointer-events-auto"
>
{getNoteDisplayTitle(note, t('notes.untitled'))}
</button>
</div>
)
}
function DroppableColumn({
id,
children,
className,
}: {
id: string
children: React.ReactNode
className?: string
}) {
const { setNodeRef, isOver } = useDroppable({ id })
return (
<div
ref={setNodeRef}
className={cn(className, isOver && 'ring-2 ring-brand-accent/30 ring-inset')}
>
{children}
</div>
)
}
export function NotesKanbanView({
notes,
schema,
noteValues,
onOpen,
onPropertyChange,
onCreateNote,
onSetGroupProperty,
onQuickAddKanbanStatus,
}: NotesKanbanViewProps) {
const { t } = useLanguage()
const [activeId, setActiveId] = useState<string | null>(null)
const selectProps = schema.properties.filter((p) => p.type === 'select')
const groupPropId =
schema.viewSettings.kanbanGroupPropertyId &&
selectProps.some((p) => p.id === schema.viewSettings.kanbanGroupPropertyId)
? schema.viewSettings.kanbanGroupPropertyId
: selectProps[0]?.id ?? null
const groupProp = selectProps.find((p) => p.id === groupPropId) ?? null
const columns = useMemo(() => {
if (!groupProp) {
return [{ id: 'col:__none', label: t('structuredViews.kanbanAllNotes'), value: null as string | null }]
}
const cols = groupProp.options.map((opt) => ({ id: `col:${opt}`, label: opt, value: opt }))
return [...cols, { id: 'col:__none', label: t('structuredViews.kanbanUnassigned'), value: null }]
}, [groupProp, t])
const notesByColumn = useMemo(() => {
const map = new Map<string, Note[]>()
for (const col of columns) map.set(col.id, [])
for (const note of notes) {
if (!groupProp) {
map.get('col:__none')?.push(note)
continue
}
const raw = noteValues[note.id]?.[groupProp.id]
const val = typeof raw === 'string' ? raw : null
const colId = val && groupProp.options.includes(val) ? `col:${val}` : 'col:__none'
map.get(colId)?.push(note)
}
return map
}, [notes, noteValues, groupProp, columns])
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
const handleDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id))
const handleDragEnd = (e: DragEndEvent) => {
setActiveId(null)
const noteId = String(e.active.id)
const overId = e.over?.id ? String(e.over.id) : null
if (!overId || !groupProp || !overId.startsWith('col:')) return
const value = overId === 'col:__none' ? null : overId.slice(4)
onPropertyChange(noteId, groupProp.id, value)
}
const activeNote = activeId ? notes.find((n) => n.id === activeId) : null
return (
<div className="space-y-4">
{!groupProp && (
<div className="rounded-xl border border-border/40 bg-foreground/[0.02] px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-[13px] text-muted-foreground flex-1">{t('structuredViews.kanbanSingleColumnHint')}</p>
{onQuickAddKanbanStatus && (
<button
type="button"
onClick={onQuickAddKanbanStatus}
className="shrink-0 px-4 py-2 rounded-full bg-foreground text-background text-[11px] font-bold uppercase tracking-wider"
>
{t('structuredViews.kanbanAddStatusColumns')}
</button>
)}
</div>
)}
{groupProp && selectProps.length > 1 && (
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-muted-foreground uppercase tracking-widest font-bold">
{t('structuredViews.kanbanGroupBy')}
</span>
<select
value={groupProp.id}
onChange={(e) => onSetGroupProperty(e.target.value)}
className="rounded-lg border border-border bg-background px-2 py-1"
>
{selectProps.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
)}
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[420px]">
{columns.map((col) => {
const colNotes = notesByColumn.get(col.id) ?? []
return (
<div
key={col.id}
className="flex-shrink-0 w-[260px] rounded-2xl border border-border/40 bg-foreground/[0.02] flex flex-col"
>
<div className="px-3 py-3 border-b border-border/30 flex items-center justify-between">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
{col.label}
</span>
<span className="text-[10px] text-muted-foreground">{colNotes.length}</span>
</div>
<DroppableColumn id={col.id} className="flex-1 p-2 space-y-2 min-h-[120px]">
{colNotes.map((note) => (
<DraggableCard key={note.id} note={note} onOpen={onOpen} dragDisabled={!groupProp} />
))}
</DroppableColumn>
{groupProp && (
<button
type="button"
onClick={() => onCreateNote({ [groupProp.id]: col.value })}
className="m-2 flex items-center justify-center gap-1 py-2 rounded-lg border border-dashed border-border text-[11px] text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-colors"
>
<Plus size={14} />
{t('structuredViews.newNoteInColumn')}
</button>
)}
</div>
)
})}
</div>
<DragOverlay>
{activeNote ? (
<div className="rounded-xl border border-brand-accent/40 bg-card p-3 shadow-lg w-[240px]">
<span className="font-memento-serif text-[13px]">
{getNoteDisplayTitle(activeNote, t('notes.untitled'))}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
)
}