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:
Antigravity
2026-05-27 19:45:15 +00:00
parent 2de66a863d
commit f46654f574
99 changed files with 29948 additions and 919 deletions

View File

@@ -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">