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>
224 lines
8.0 KiB
TypeScript
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>
|
|
)
|
|
}
|