feat: editor improvements and architectural grid prototype
Multiple feature additions and improvements across the application: - NextGen Editor: drag handles, smart paste, block actions - Structured views: Kanban and table layouts for notes - Architectural Grid: new brainstorming/agent interface prototype - Flashcards: SM-2 revision algorithm with AI generation - MCP server: robustness improvements - Graph/PDF chat: fix click propagation and copy behavior - Various UI/UX enhancements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useTransition, useEffect, useCallback } from 'react'
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
@@ -28,7 +28,6 @@ import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNote
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useLabelsQuery } from '@/lib/query-hooks'
|
||||
import { updateNote } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
@@ -39,8 +38,6 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
Pin,
|
||||
FileText,
|
||||
Link2,
|
||||
CheckSquare,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Wind,
|
||||
@@ -50,6 +47,7 @@ import {
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHydrated } from '@/lib/use-hydrated'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -60,52 +58,6 @@ export type NotesClassicLayoutMode = 'grid' | 'list' | 'table'
|
||||
export function isClassicLayoutMode(mode: NotesLayoutMode): mode is NotesClassicLayoutMode {
|
||||
return mode === 'grid' || mode === 'list' || mode === 'table'
|
||||
}
|
||||
export type NotesViewType = 'notes' | 'tasks'
|
||||
|
||||
type TaskItem = {
|
||||
id: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
text: string
|
||||
completed: boolean
|
||||
lineIndex: number
|
||||
}
|
||||
|
||||
function getNoteTasksStats(content: string) {
|
||||
const lines = (content || '').split('\n')
|
||||
let total = 0
|
||||
let completed = 0
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
|
||||
if (match) {
|
||||
total++
|
||||
if (match[1].toLowerCase() === 'x') completed++
|
||||
}
|
||||
}
|
||||
return { completed, total }
|
||||
}
|
||||
|
||||
function extractTasksFromNotes(notes: Note[]): TaskItem[] {
|
||||
const tasks: TaskItem[] = []
|
||||
for (const note of notes) {
|
||||
const title = note.title?.trim() || 'Sans titre'
|
||||
const lines = (note.content || '').split('\n')
|
||||
lines.forEach((line, idx) => {
|
||||
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
|
||||
if (match) {
|
||||
tasks.push({
|
||||
id: `${note.id}-${idx}`,
|
||||
noteId: note.id,
|
||||
noteTitle: title,
|
||||
text: match[2].trim(),
|
||||
completed: match[1].toLowerCase() === 'x',
|
||||
lineIndex: idx,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
function getNotebookColor(notebookId: string | null | undefined, name?: string) {
|
||||
const colors = [
|
||||
@@ -247,7 +199,6 @@ export type { NoteCollectionActions } from '@/lib/note-change-sync'
|
||||
type NotesListViewsProps = {
|
||||
notes: Note[]
|
||||
pinnedNotes?: Note[]
|
||||
viewType: NotesViewType
|
||||
layoutMode: NotesLayoutMode
|
||||
onOpen: (note: Note, readOnly?: boolean) => void
|
||||
onOpenHistory?: (note: Note) => void
|
||||
@@ -258,7 +209,6 @@ type NotesListViewsProps = {
|
||||
export function NotesListViews({
|
||||
notes,
|
||||
pinnedNotes = [],
|
||||
viewType,
|
||||
layoutMode,
|
||||
onOpen,
|
||||
onOpenHistory,
|
||||
@@ -275,8 +225,7 @@ export function NotesListViews({
|
||||
const { data: session } = useSession()
|
||||
const { notebooks } = useNotebooks()
|
||||
const { data: allLabels = [] } = useLabelsQuery()
|
||||
const [, startTransition] = useTransition()
|
||||
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'tasks' | 'modified' | null>(null)
|
||||
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'modified' | null>(null)
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null)
|
||||
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||
|
||||
@@ -298,24 +247,7 @@ export function NotesListViews({
|
||||
return [...pinnedNotes, ...unpinned]
|
||||
}, [notes, pinnedNotes])
|
||||
|
||||
const extractTasks = useMemo(() => extractTasksFromNotes(allDisplayNotes), [allDisplayNotes])
|
||||
const completedTasksCount = extractTasks.filter((task) => task.completed).length
|
||||
|
||||
const handleToggleTask = (task: TaskItem) => {
|
||||
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
||||
if (!note) return
|
||||
const lines = (note.content || '').split('\n')
|
||||
const line = lines[task.lineIndex]
|
||||
if (!line) return
|
||||
const nextChar = task.completed ? ' ' : 'x'
|
||||
lines[task.lineIndex] = line.replace(/\[([ xX])\]/, `[${nextChar}]`)
|
||||
startTransition(async () => {
|
||||
await updateNote(note.id, { content: lines.join('\n') }, { skipRevalidation: true })
|
||||
onNotePatch?.(note.id, { content: lines.join('\n') })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSort = (field: 'title' | 'notebook' | 'tasks' | 'modified') => {
|
||||
const handleSort = (field: 'title' | 'notebook' | 'modified') => {
|
||||
if (sortColumn !== field) {
|
||||
setSortColumn(field)
|
||||
setSortDirection('asc')
|
||||
@@ -339,9 +271,6 @@ export function NotesListViews({
|
||||
} else if (sortColumn === 'notebook') {
|
||||
valA = notebooks.find((nb) => nb.id === a.notebookId)?.name?.toLowerCase() || ''
|
||||
valB = notebooks.find((nb) => nb.id === b.notebookId)?.name?.toLowerCase() || ''
|
||||
} else if (sortColumn === 'tasks') {
|
||||
valA = getNoteTasksStats(a.content || '').completed
|
||||
valB = getNoteTasksStats(b.content || '').completed
|
||||
} else {
|
||||
valA = new Date(a.updatedAt).getTime()
|
||||
valB = new Date(b.updatedAt).getTime()
|
||||
@@ -357,86 +286,6 @@ export function NotesListViews({
|
||||
sortDirection === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
|
||||
) : null
|
||||
|
||||
if (viewType === 'tasks') {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-foreground/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-muted-foreground">
|
||||
{t('notes.tasksHeader')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono font-bold text-foreground bg-foreground/[0.03] dark:bg-white/5 py-1 px-3 rounded-full">
|
||||
{t('notes.tasksSummary')
|
||||
.replace('{count}', String(extractTasks.length))
|
||||
.replace('{completed}', String(completedTasksCount))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extractTasks.length > 0 ? (
|
||||
<div className="overflow-hidden border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
||||
<div className="divide-y divide-foreground/[0.04]">
|
||||
{extractTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-4 flex items-center justify-between gap-4 hover:bg-foreground/[0.01] transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3.5 flex-grow min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleTask(task)}
|
||||
className={cn(
|
||||
'w-5 h-5 rounded-md border flex items-center justify-center transition-all shrink-0',
|
||||
task.completed
|
||||
? 'bg-brand-accent border-brand-accent text-white'
|
||||
: 'border-border hover:border-brand-accent/60 bg-transparent',
|
||||
)}
|
||||
>
|
||||
{task.completed && <span className="text-xs font-bold">✓</span>}
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[13px] font-light leading-relaxed truncate',
|
||||
task.completed && 'line-through text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[9.5px] uppercase font-mono tracking-wider text-muted-foreground max-w-[140px] truncate">
|
||||
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
||||
if (note) onOpen(note)
|
||||
}}
|
||||
className="p-1.5 rounded-full hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-all"
|
||||
title={t('notes.openSourceNote')}
|
||||
>
|
||||
<Link2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center border border-border/40">
|
||||
<CheckSquare size={18} className="text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="font-memento-serif text-lg italic text-muted-foreground">{t('notes.tasksEmptyTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-sm leading-relaxed">{t('notes.tasksEmptyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layoutMode === 'grid') {
|
||||
return (
|
||||
<NotesMasonryGrid
|
||||
@@ -484,15 +333,7 @@ export function NotesListViews({
|
||||
{t('notes.tableLabels')}
|
||||
</th>
|
||||
<th
|
||||
className="w-[12%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('tasks')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableTasks')} <SortIcon field="tasks" />
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="w-[13%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
className="w-[18%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('modified')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -506,7 +347,6 @@ export function NotesListViews({
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
const nb = notebooks.find((n) => n.id === note.notebookId)
|
||||
const nbColor = getNotebookColor(note.notebookId, nb?.name)
|
||||
const stats = getNoteTasksStats(note.content || '')
|
||||
return (
|
||||
<tr
|
||||
key={note.id}
|
||||
@@ -538,15 +378,6 @@ export function NotesListViews({
|
||||
<td className="px-4 py-2">
|
||||
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={3} />
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-[10.5px] font-bold text-foreground/80">
|
||||
{stats.total > 0 ? (
|
||||
<span className={stats.completed === stats.total ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}>
|
||||
{stats.completed}/{stats.total} <span className="text-[9px] font-sans">✓</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[10.5px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||
</td>
|
||||
@@ -834,9 +665,9 @@ function GridCard({
|
||||
}: GridCardSharedProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const hydrated = useHydrated()
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
const excerpt = getNotePlainExcerpt(note, 110)
|
||||
const stats = getNoteTasksStats(note.content || '')
|
||||
const formattedDate = formatGridCardDate(note.updatedAt, language)
|
||||
|
||||
const handlePinClick = (e: React.MouseEvent) => {
|
||||
@@ -857,9 +688,9 @@ function GridCard({
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={isOverlay ? false : { opacity: 0, y: 15 }}
|
||||
initial={isOverlay || !hydrated ? false : { opacity: 0, y: 15 }}
|
||||
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
||||
transition={isOverlay || !hydrated ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
||||
onClick={() => onOpen(note)}
|
||||
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative h-full"
|
||||
>
|
||||
@@ -874,11 +705,6 @@ function GridCard({
|
||||
<Pin size={11} className="fill-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
{stats.total > 0 && (
|
||||
<div className="absolute top-3 end-3 bg-background/90 backdrop-blur-sm py-1 px-2.5 rounded-full shadow-sm border border-border/40 text-[9.5px] font-mono font-bold text-muted-foreground">
|
||||
{stats.completed}/{stats.total} ✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
|
||||
<div className="space-y-2.5 flex-1">
|
||||
|
||||
Reference in New Issue
Block a user