Files
Momento/memento-note/context/notebooks-context.tsx
Antigravity e2672cd2c2
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:27:29 +00:00

488 lines
15 KiB
TypeScript

'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 { emitNoteChange } from '@/lib/note-change-sync'
import { getNoteById } from '@/app/actions/notes'
import { useNoteRefresh } from './NoteRefreshContext'
import { toast } from 'sonner'
import { queryKeys } from '@/lib/query-keys'
// ===== INPUT TYPES =====
export interface CreateNotebookInput {
name: string
icon?: string
color?: string
parentId?: string | null
}
export interface UpdateNotebookInput {
name?: string
icon?: string
color?: string
parentId?: string | null
}
export interface CreateLabelInput {
name: string
color?: string
notebookId: string
}
export interface UpdateLabelInput {
name?: string
color?: string
}
// ===== CONTEXT VALUE =====
export interface NotebooksContextValue {
// État global
notebooks: Notebook[]
currentNotebook: Notebook | null // null = "Notes générales"
currentLabels: Label[] // Labels du notebook actuel
isLoading: boolean
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>
moveNotebookToParent: (notebookId: string, parentId: string | null) => Promise<void>
deleteNotebook: (notebookId: string) => Promise<void>
trashNotebook: (notebookId: string) => Promise<void>
restoreNotebook: (notebookId: string) => Promise<void>
permanentDeleteNotebook: (notebookId: string) => Promise<void>
updateNotebookOrderOptimistic: (notebookIds: string[]) => Promise<void>
setCurrentNotebook: (notebook: Notebook | null) => void
// Actions: Labels
createLabel: (data: CreateLabelInput) => Promise<Label>
updateLabel: (labelId: string, data: UpdateLabelInput) => Promise<void>
deleteLabel: (labelId: string) => Promise<void>
// Actions: Notes
moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void>
/** Recharger la liste des carnets (ex. après maintenance étiquettes) */
refreshNotebooks: () => Promise<void>
// Actions: AI (stubs pour l'instant)
suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null>
suggestLabelsForNote: (noteContent: string, notebookId: string) => Promise<string[]>
}
export const NotebooksContext = createContext<NotebooksContextValue | null>(null)
export function useNotebooks() {
const context = useContext(NotebooksContext)
if (!context) {
throw new Error('useNotebooks must be used within NotebooksProvider')
}
return context
}
interface NotebooksProviderProps {
children: React.ReactNode
initialNotebooks?: Notebook[]
}
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)
const [isMovingNote, setIsMovingNote] = useState(false)
const [error, setError] = useState<string | null>(null)
const { 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 []
return notebooks.find(nb => nb.id === currentNotebook.id)?.labels ?? []
}, [currentNotebook, notebooks])
// ===== DATA LOADING =====
const loadNotebooks = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/notebooks')
if (!response.ok) throw new Error('Failed to load notebooks')
const data = await response.json()
setNotebooks(data.notebooks || [])
} catch (err) {
console.error('Failed to load notebooks:', err)
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadNotebooks()
}, [loadNotebooks])
useEffect(() => {
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Failed to create notebook')
}
// Invalidate notebooks cache — React Query will re-fetch in bg
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
}, [queryClient, triggerNotebooksRefresh])
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Failed to update notebook')
}
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
}, [queryClient, triggerNotebooksRefresh])
const moveNotebookToParent = useCallback(async (notebookId: string, parentId: string | null) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parentId }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to move notebook')
}
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
}, [queryClient, triggerNotebooksRefresh])
const deleteNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete notebook')
}
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
}, [queryClient, triggerNotebooksRefresh])
const trashNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trashedAt: new Date().toISOString() }),
})
if (!response.ok) {
throw new Error('Failed to trash notebook')
}
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
}, [queryClient, triggerNotebooksRefresh])
const restoreNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trashedAt: null }),
})
if (!response.ok) {
throw new Error('Failed to restore notebook')
}
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
}, [queryClient, triggerNotebooksRefresh])
const permanentDeleteNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to permanently delete notebook')
}
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
}, [queryClient, triggerNotebooksRefresh])
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
const response = await fetch('/api/notebooks/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookIds }),
})
if (!response.ok) {
throw new Error('Failed to update notebook order')
}
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
}, [queryClient, triggerNotebooksRefresh])
// ===== 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) })
}
} catch (error) {
console.error('Failed to add label:', error)
throw error
}
}, [notebookId, queryClient])
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) })
}, [fetchLabels, notebookId, queryClient])
// ===== ACTIONS: LABELS (keep existing API-based ones for compatibility) =====
const createLabel = useCallback(async (data: CreateLabelInput) => {
const response = await fetch('/api/labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Failed to create label')
}
const result = await response.json()
// Invalidate labels cache for this notebook
queryClient.invalidateQueries({ queryKey: queryKeys.labels(data.notebookId) })
return result
}, [queryClient])
const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
const response = await fetch(`/api/labels/${labelId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
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) })
}, [notebookId, queryClient])
const deleteLabel = useCallback(async (labelId: string) => {
const response = await fetch(`/api/labels/${labelId}`, {
method: 'DELETE',
})
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) })
}, [notebookId, queryClient])
// ===== ACTIONS: NOTES =====
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: targetNotebookId }),
})
if (!response.ok) {
throw new Error('Failed to move note')
}
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(null) })
triggerNotebooksRefresh()
const movedNote = await getNoteById(noteId)
if (movedNote) {
emitNoteChange({ type: 'updated', note: movedNote })
}
} catch (error) {
toast.error('Failed to move note. Please try again.')
throw error
} finally {
setIsMovingNote(false)
}
}, [queryClient, triggerNotebooksRefresh])
// ===== ACTIONS: AI (STUBS) =====
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
// Stub pour l'instant - retourne null
return null
}, [])
const suggestLabelsForNote = useCallback(async (_noteContent: string, _notebookId: string) => {
// Stub pour l'instant - retourne tableau vide
return []
}, [])
// ===== CONTEXT VALUE =====
const value: NotebooksContextValue = useMemo(() => ({
notebooks,
currentNotebook,
currentLabels,
isLoading,
isMovingNote,
error,
labels,
loading,
notebookId,
setNotebookId,
addLabel,
refreshLabels,
getLabelColor,
createNotebookOptimistic,
updateNotebook,
moveNotebookToParent,
deleteNotebook,
trashNotebook,
restoreNotebook,
permanentDeleteNotebook,
updateNotebookOrderOptimistic,
setCurrentNotebook,
createLabel,
updateLabel,
deleteLabel,
moveNoteToNotebookOptimistic,
refreshNotebooks: loadNotebooks,
suggestNotebookForNote,
suggestLabelsForNote,
}), [
notebooks,
currentNotebook,
currentLabels,
isLoading,
isMovingNote,
error,
labels,
loading,
notebookId,
addLabel,
refreshLabels,
getLabelColor,
createNotebookOptimistic,
updateNotebook,
moveNotebookToParent,
deleteNotebook,
trashNotebook,
restoreNotebook,
permanentDeleteNotebook,
updateNotebookOrderOptimistic,
createLabel,
updateLabel,
deleteLabel,
moveNoteToNotebookOptimistic,
loadNotebooks,
suggestNotebookForNote,
suggestLabelsForNote,
])
return (
<NotebooksContext.Provider value={value}>
{children}
</NotebooksContext.Provider>
)
}