refactor: split NoteEditor into focused components + consolidate contexts

Phase 1: NoteEditor Split (64KB → 9 focused components)
- components/note-editor/: types.ts, context, toolbar, title-block,
  content-area, metadata-section, full-page, dialog compositions
- Maintains backwards compatibility via re-export from note-editor.tsx

Phase 2: Context Consolidation (5 → 3 contexts)
- NotebooksContext absorbs LabelContext (labels CRUD)
- EditorUIContext merges HomeViewContext + NotebookDragContext
- Removed: LabelContext, home-view-context, notebook-drag-context

Phase 3: React Query Infrastructure
- Added QueryProvider with @tanstack/react-query
- lib/query-keys.ts: centralized query key definitions
- lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery
- lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper
- NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-08 14:31:08 +00:00
parent a58610003d
commit 91b1201112
35 changed files with 2606 additions and 344 deletions

View File

@@ -1,145 +0,0 @@
'use client'
import { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react'
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
import { getHashColor } from '@/lib/utils'
export interface Label {
id: string
name: string
color: LabelColorName
userId?: string | null
createdAt: Date
updatedAt: Date
}
interface LabelContextType {
labels: Label[]
loading: boolean
notebookId?: string | null
setNotebookId: (notebookId: string | null) => void
addLabel: (name: string, color?: LabelColorName, notebookId?: string | null) => Promise<void>
updateLabel: (id: string, updates: Partial<Pick<Label, 'name' | 'color'>>) => Promise<void>
deleteLabel: (id: string) => Promise<void>
getLabelColor: (name: string) => LabelColorName
refreshLabels: () => Promise<void>
}
const LabelContext = createContext<LabelContextType | undefined>(undefined)
export function LabelProvider({ children }: { children: ReactNode }) {
const [labels, setLabels] = useState<Label[]>([])
const [loading, setLoading] = useState(true)
const [notebookId, setNotebookId] = useState<string | null>(null)
const fetchLabels = useCallback(async (nbId: string | null) => {
try {
setLoading(true)
const url = new URL('/api/labels', window.location.origin)
if (nbId) {
url.searchParams.set('notebookId', nbId)
}
const response = await fetch(url.toString(), {
cache: 'no-store',
credentials: 'include'
})
const data = await response.json()
if (data.success && data.data) {
setLabels(data.data)
}
} catch (error) {
console.error('Failed to fetch labels:', error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchLabels(notebookId)
}, [notebookId, fetchLabels])
const addLabel = useCallback(async (name: string, color?: LabelColorName, labelNotebookId?: string | null) => {
try {
const labelColor = color || getHashColor(name);
const finalNotebookId = labelNotebookId || notebookId
const response = await fetch('/api/labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color: labelColor, notebookId: finalNotebookId }),
})
const data = await response.json()
if (data.success && data.data) {
setLabels(prev => [...prev, data.data])
}
} catch (error) {
console.error('Failed to add label:', error)
throw error
}
}, [notebookId])
const updateLabel = useCallback(async (id: string, updates: Partial<Pick<Label, 'name' | 'color'>>) => {
try {
const response = await fetch(`/api/labels/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
const data = await response.json()
if (data.success && data.data) {
setLabels(prev => prev.map(label =>
label.id === id ? { ...label, ...data.data } : label
))
}
} catch (error) {
console.error('Failed to update label:', error)
throw error
}
}, [])
const deleteLabel = useCallback(async (id: string) => {
try {
const response = await fetch(`/api/labels/${id}`, {
method: 'DELETE',
})
if (response.ok) {
setLabels(prev => prev.filter(label => label.id !== id))
}
} catch (error) {
console.error('Failed to delete label:', error)
throw error
}
}, [])
const getLabelColor = useCallback((name: string): LabelColorName => {
const label = labels.find(l => l.name.toLowerCase() === name.toLowerCase())
return label?.color || 'gray'
}, [labels])
const refreshLabels = useCallback(async () => {
await fetchLabels(notebookId)
}, [fetchLabels, notebookId])
const value = useMemo<LabelContextType>(() => ({
labels,
loading,
notebookId,
setNotebookId,
addLabel,
updateLabel,
deleteLabel,
getLabelColor,
refreshLabels,
}), [labels, loading, notebookId, addLabel, updateLabel, deleteLabel, getLabelColor, refreshLabels])
return <LabelContext.Provider value={value}>{children}</LabelContext.Provider>
}
export function useLabels() {
const context = useContext(LabelContext)
if (context === undefined) {
throw new Error('useLabels must be used within a LabelProvider')
}
return context
}

View File

@@ -0,0 +1,74 @@
'use client'
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react'
export type HomeUiControls = {
isTabsMode: boolean
openNoteComposer: () => void
}
interface EditorUIContextValue {
// HomeView controls
controls: HomeUiControls | null
setControls: (c: HomeUiControls | null) => void
// NotebookDrag controls
draggedNoteId: string | null
dragOverNotebookId: string | null
startDrag: (noteId: string) => void
endDrag: () => void
dragOver: (notebookId: string | null) => void
isDragging: boolean
isDragOver: boolean
}
const EditorUIContext = createContext<EditorUIContextValue | null>(null)
export function EditorUIProvider({ children }: { children: ReactNode }) {
// HomeView state
const [controls, setControls] = useState<HomeUiControls | null>(null)
// NotebookDrag state
const [draggedNoteId, setDraggedNoteId] = useState<string | null>(null)
const [dragOverNotebookId, setDragOverNotebookId] = useState<string | null>(null)
const startDrag = useCallback((noteId: string) => {
setDraggedNoteId(noteId)
}, [])
const endDrag = useCallback(() => {
setDraggedNoteId(null)
setDragOverNotebookId(null)
}, [])
const dragOver = useCallback((notebookId: string | null) => {
setDragOverNotebookId(notebookId)
}, [])
const isDragging = draggedNoteId !== null
const isDragOver = dragOverNotebookId !== null
const value = useMemo<EditorUIContextValue>(() => ({
controls,
setControls,
draggedNoteId,
dragOverNotebookId,
startDrag,
endDrag,
dragOver,
isDragging,
isDragOver,
}), [controls, draggedNoteId, dragOverNotebookId, startDrag, endDrag, dragOver, isDragging, isDragOver])
return <EditorUIContext.Provider value={value}>{children}</EditorUIContext.Provider>
}
export function useEditorUI() {
const ctx = useContext(EditorUIContext)
if (!ctx) throw new Error('useEditorUI must be used within EditorUIProvider')
return ctx
}
export function useEditorUIOptional(): EditorUIContextValue | null {
return useContext(EditorUIContext)
}

View File

@@ -1,36 +0,0 @@
'use client'
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
export type HomeUiControls = {
isTabsMode: boolean
openNoteComposer: () => void
}
type Ctx = {
controls: HomeUiControls | null
setControls: (c: HomeUiControls | null) => void
}
const HomeViewContext = createContext<Ctx | null>(null)
export function HomeViewProvider({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<HomeUiControls | null>(null)
const value = useMemo(() => ({ controls, setControls }), [controls])
return <HomeViewContext.Provider value={value}>{children}</HomeViewContext.Provider>
}
/** Enregistré par la page daccueil ; la sidebar lit `controls` */
export function useHomeView() {
const ctx = useContext(HomeViewContext)
if (!ctx) {
throw new Error('useHomeView must be used within HomeViewProvider')
}
return ctx
}
/** Sidebar / shells : ne pas planter si hors provider */
export function useHomeViewOptional(): Ctx | null {
return useContext(HomeViewContext)
}

View File

@@ -1,64 +0,0 @@
'use client'
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react'
interface NotebookDragContextValue {
draggedNoteId: string | null
dragOverNotebookId: string | null
startDrag: (noteId: string) => void
endDrag: () => void
dragOver: (notebookId: string | null) => void
isDragging: boolean
isDragOver: boolean
}
const NotebookDragContext = createContext<NotebookDragContextValue | null>(null)
export function useNotebookDrag() {
const context = useContext(NotebookDragContext)
if (!context) {
throw new Error('useNotebookDrag must be used within NotebookDragProvider')
}
return context
}
interface NotebookDragProviderProps {
children: ReactNode
}
export function NotebookDragProvider({ children }: NotebookDragProviderProps) {
const [draggedNoteId, setDraggedNoteId] = useState<string | null>(null)
const [dragOverNotebookId, setDragOverNotebookId] = useState<string | null>(null)
const startDrag = useCallback((noteId: string) => {
setDraggedNoteId(noteId)
}, [])
const endDrag = useCallback(() => {
setDraggedNoteId(null)
setDragOverNotebookId(null)
}, [])
const dragOver = useCallback((notebookId: string | null) => {
setDragOverNotebookId(notebookId)
}, [])
const isDragging = draggedNoteId !== null
const isDragOver = dragOverNotebookId !== null
const value = useMemo(() => ({
draggedNoteId,
dragOverNotebookId,
startDrag,
endDrag,
dragOver,
isDragging,
isDragOver,
}), [draggedNoteId, dragOverNotebookId, startDrag, endDrag, dragOver, isDragging, isDragOver])
return (
<NotebookDragContext.Provider value={value}>
{children}
</NotebookDragContext.Provider>
)
}

View File

@@ -1,9 +1,13 @@
'use client'
import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { Notebook, Label, Note } from '@/lib/types'
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
import { getHashColor } from '@/lib/utils'
import { useNoteRefresh } from './NoteRefreshContext'
import { toast } from 'sonner'
import { queryKeys } from '@/lib/query-keys'
// ===== INPUT TYPES =====
export interface CreateNotebookInput {
@@ -39,6 +43,15 @@ export interface NotebooksContextValue {
isMovingNote: boolean
error: string | null
// Labels from /api/labels (merged from LabelContext)
labels: Label[]
loading: boolean
notebookId: string | null
setNotebookId: (notebookId: string | null) => void
addLabel: (name: string, color?: LabelColorName, labelNotebookId?: string | null) => Promise<void>
refreshLabels: () => Promise<void>
getLabelColor: (name: string) => LabelColorName
// Actions: Notebooks
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<void>
updateNotebook: (notebookId: string, data: UpdateNotebookInput) => Promise<void>
@@ -79,6 +92,7 @@ interface NotebooksProviderProps {
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
// ===== BASE STATE =====
const queryClient = useQueryClient()
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -86,6 +100,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
const [error, setError] = useState<string | null>(null)
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
// ===== LABEL STATE (merged from LabelContext) =====
const [labels, setLabels] = useState<Label[]>([])
const [loading, setLoading] = useState(true)
const [notebookId, setNotebookId] = useState<string | null>(null)
// ===== DERIVED STATE =====
const currentLabels = useMemo(() => {
if (!currentNotebook) return []
@@ -119,6 +138,34 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
if (notebooksRefreshKey > 0) loadNotebooks()
}, [notebooksRefreshKey, loadNotebooks])
// ===== LABEL FETCHING (merged from LabelContext) =====
const fetchLabels = useCallback(async (nbId: string | null) => {
try {
setLoading(true)
const url = new URL('/api/labels', window.location.origin)
if (nbId) {
url.searchParams.set('notebookId', nbId)
}
const response = await fetch(url.toString(), {
cache: 'no-store',
credentials: 'include'
})
const data = await response.json()
if (data.success && data.data) {
setLabels(data.data)
}
} catch (error) {
console.error('Failed to fetch labels:', error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchLabels(notebookId)
}, [notebookId, fetchLabels])
// ===== ACTIONS: NOTEBOOKS =====
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
const response = await fetch('/api/notebooks', {
@@ -131,11 +178,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
throw new Error('Failed to create notebook')
}
// Reload notebooks from server to update sidebar state
await loadNotebooks()
// Invalidate notebooks cache — React Query will re-fetch in bg
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -148,10 +195,10 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
throw new Error('Failed to update notebook')
}
await loadNotebooks()
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
const deleteNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -162,10 +209,10 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
throw new Error('Failed to delete notebook')
}
await loadNotebooks()
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
const response = await fetch('/api/notebooks/reorder', {
@@ -178,12 +225,47 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
throw new Error('Failed to update notebook order')
}
await loadNotebooks()
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
// ===== ACTIONS: LABELS =====
// ===== LABEL ACTIONS (merged from LabelContext) =====
const addLabel = useCallback(async (name: string, color?: LabelColorName, labelNotebookId?: string | null) => {
try {
const labelColor = color || getHashColor(name);
const finalNotebookId = labelNotebookId || notebookId
const response = await fetch('/api/labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color: labelColor, notebookId: finalNotebookId }),
})
const data = await response.json()
if (data.success && data.data) {
setLabels(prev => [...prev, data.data])
// Invalidate labels cache
queryClient.invalidateQueries({ queryKey: queryKeys.labels(finalNotebookId) })
triggerRefresh()
}
} catch (error) {
console.error('Failed to add label:', error)
throw error
}
}, [notebookId, queryClient, triggerRefresh])
const getLabelColor = useCallback((name: string): LabelColorName => {
const label = labels.find(l => l.name.toLowerCase() === name.toLowerCase())
return label?.color || 'gray'
}, [labels])
const refreshLabels = useCallback(async () => {
await fetchLabels(notebookId)
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
triggerRefresh()
}, [fetchLabels, notebookId, queryClient, triggerRefresh])
// ===== ACTIONS: LABELS (keep existing API-based ones for compatibility) =====
const createLabel = useCallback(async (data: CreateLabelInput) => {
const response = await fetch('/api/labels', {
method: 'POST',
@@ -196,8 +278,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
const result = await response.json()
// Invalidate labels cache for this notebook
queryClient.invalidateQueries({ queryKey: queryKeys.labels(data.notebookId) })
triggerRefresh()
return result
}, [])
}, [queryClient, triggerRefresh])
const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
const response = await fetch(`/api/labels/${labelId}`, {
@@ -209,7 +294,17 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
if (!response.ok) {
throw new Error('Failed to update label')
}
}, [])
const result = await response.json()
if (result.success && result.data) {
setLabels(prev => prev.map(label =>
label.id === labelId ? { ...label, ...result.data } : label
))
}
// Invalidate labels cache
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
triggerRefresh()
}, [notebookId, queryClient, triggerRefresh])
const deleteLabel = useCallback(async (labelId: string) => {
const response = await fetch(`/api/labels/${labelId}`, {
@@ -219,23 +314,30 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
if (!response.ok) {
throw new Error('Failed to delete label')
}
}, [])
setLabels(prev => prev.filter(label => label.id !== labelId))
// Invalidate labels cache
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
triggerRefresh()
}, [notebookId, queryClient, triggerRefresh])
// ===== ACTIONS: NOTES =====
const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, notebookId: string | null) => {
const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, targetNotebookId: string | null) => {
setIsMovingNote(true)
try {
const response = await fetch(`/api/notes/${noteId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId }),
body: JSON.stringify({ notebookId: targetNotebookId }),
})
if (!response.ok) {
throw new Error('Failed to move note')
}
await loadNotebooks()
// Invalidate notebooks and notes cache
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(null) }) // notes list
triggerNotebooksRefresh()
triggerRefresh()
} catch (error) {
@@ -244,7 +346,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
} finally {
setIsMovingNote(false)
}
}, [loadNotebooks, triggerRefresh])
}, [queryClient, triggerRefresh, triggerNotebooksRefresh])
// ===== ACTIONS: AI (STUBS) =====
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
@@ -265,6 +367,13 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
isLoading,
isMovingNote,
error,
labels,
loading,
notebookId,
setNotebookId,
addLabel,
refreshLabels,
getLabelColor,
createNotebookOptimistic,
updateNotebook,
deleteNotebook,
@@ -284,6 +393,12 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
isLoading,
isMovingNote,
error,
labels,
loading,
notebookId,
addLabel,
refreshLabels,
getLabelColor,
createNotebookOptimistic,
updateNotebook,
deleteNotebook,
@@ -302,4 +417,4 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
{children}
</NotebooksContext.Provider>
)
}
}