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:
@@ -37,6 +37,7 @@ const NOTE_LIST_SELECT = {
|
|||||||
checkItems: true,
|
checkItems: true,
|
||||||
labels: true,
|
labels: true,
|
||||||
images: true,
|
images: true,
|
||||||
|
illustrationSvg: true,
|
||||||
links: true,
|
links: true,
|
||||||
reminder: true,
|
reminder: true,
|
||||||
isReminderDone: true,
|
isReminderDone: true,
|
||||||
@@ -789,6 +790,7 @@ export async function updateNote(id: string, data: {
|
|||||||
checkItems?: CheckItem[] | null
|
checkItems?: CheckItem[] | null
|
||||||
labels?: string[] | null
|
labels?: string[] | null
|
||||||
images?: string[] | null
|
images?: string[] | null
|
||||||
|
illustrationSvg?: string | null
|
||||||
links?: any[] | null
|
links?: any[] | null
|
||||||
reminder?: Date | null
|
reminder?: Date | null
|
||||||
isMarkdown?: boolean
|
isMarkdown?: boolean
|
||||||
@@ -844,6 +846,7 @@ export async function updateNote(id: string, data: {
|
|||||||
// labels handled by syncNoteLabels below
|
// labels handled by syncNoteLabels below
|
||||||
delete updateData.labels
|
delete updateData.labels
|
||||||
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
||||||
|
if ('illustrationSvg' in data) updateData.illustrationSvg = data.illustrationSvg
|
||||||
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
||||||
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
||||||
// Explicitly handle size to ensure it propagates
|
// Explicitly handle size to ensure it propagates
|
||||||
@@ -852,7 +855,7 @@ export async function updateNote(id: string, data: {
|
|||||||
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
||||||
// (size, color, isPinned, isArchived are properties, not content)
|
// (size, color, isPinned, isArchived are properties, not content)
|
||||||
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
|
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
|
||||||
const contentFields = ['title', 'content', 'checkItems', 'images', 'links']
|
const contentFields = ['title', 'content', 'checkItems', 'images', 'links', 'illustrationSvg']
|
||||||
const isContentChange = contentFields.some(field => field in data)
|
const isContentChange = contentFields.some(field => field in data)
|
||||||
if (isContentChange && !options?.skipContentTimestamp) {
|
if (isContentChange && !options?.skipContentTimestamp) {
|
||||||
updateData.contentUpdatedAt = new Date()
|
updateData.contentUpdatedAt = new Date()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { Header } from './header'
|
import { Header } from './header'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
|
||||||
interface HeaderWrapperProps {
|
interface HeaderWrapperProps {
|
||||||
onColorFilterChange?: (color: string | null) => void
|
onColorFilterChange?: (color: string | null) => void
|
||||||
@@ -13,7 +13,7 @@ interface HeaderWrapperProps {
|
|||||||
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
|
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { labels } = useLabels()
|
const { labels } = useNotebooks()
|
||||||
|
|
||||||
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||||
const selectedColor = searchParams.get('color') || null
|
const selectedColor = searchParams.get('color') || null
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, S
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { LabelFilter } from './label-filter'
|
import { LabelFilter } from './label-filter'
|
||||||
import { NotificationPanel } from './notification-panel'
|
import { NotificationPanel } from './notification-panel'
|
||||||
import { updateTheme } from '@/app/actions/profile'
|
import { updateTheme } from '@/app/actions/profile'
|
||||||
@@ -53,7 +53,7 @@ export function Header({
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { labels, setNotebookId } = useLabels()
|
const { labels, setNotebookId } = useNotebooks()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
|||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, ArrowUpDown } from 'lucide-react'
|
import { Plus, ArrowUpDown } from 'lucide-react'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useHomeView } from '@/context/home-view-context'
|
import { useEditorUI } from '@/context/editor-ui-context'
|
||||||
import { NoteHistoryModal } from '@/components/note-history-modal'
|
import { NoteHistoryModal } from '@/components/note-history-modal'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
@@ -71,9 +70,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
const [isCreating, startCreating] = useTransition()
|
const [isCreating, startCreating] = useTransition()
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||||
|
const notesRef = useRef(notes)
|
||||||
|
notesRef.current = notes
|
||||||
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
||||||
const { labels } = useLabels()
|
const { labels, notebooks } = useNotebooks()
|
||||||
const { setControls } = useHomeView()
|
const { setControls } = useEditorUI()
|
||||||
|
|
||||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||||
@@ -84,18 +85,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
}
|
}
|
||||||
}, [shouldSuggestLabels, suggestNotebookId])
|
}, [shouldSuggestLabels, suggestNotebookId])
|
||||||
|
|
||||||
// BUG FIX: forceList param from sidebar carnet click → reset to editorial view
|
// Sidebar carnet / inbox: forceList → liste éditoriale + fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const forceList = searchParams.get('forceList')
|
const forceList = searchParams.get('forceList')
|
||||||
if (forceList === '1') {
|
if (forceList !== '1') return
|
||||||
setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev))
|
setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev))
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
setEditingNote(null)
|
||||||
params.delete('forceList')
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
|
params.delete('forceList')
|
||||||
router.replace(newUrl, { scroll: false })
|
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
|
||||||
}
|
router.replace(newUrl, { scroll: false })
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [searchParams, router])
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
const notebookFilter = searchParams.get('notebook')
|
const notebookFilter = searchParams.get('notebook')
|
||||||
const handleNoteCreated = useCallback((note: Note) => {
|
const handleNoteCreated = useCallback((note: Note) => {
|
||||||
@@ -196,27 +196,25 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
useReminderCheck(notes)
|
useReminderCheck(notes)
|
||||||
|
|
||||||
|
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openNoteId = searchParams.get('openNote')
|
const openNoteId = searchParams.get('openNote')
|
||||||
if (!openNoteId) return
|
if (!openNoteId) return
|
||||||
|
|
||||||
const openNote = async () => {
|
let cancelled = false
|
||||||
const existing = notes.find(n => n.id === openNoteId)
|
const run = async () => {
|
||||||
if (existing) {
|
const existing = notesRef.current.find(n => n.id === openNoteId)
|
||||||
setEditingNote({ note: existing, readOnly: false })
|
const note = existing ?? (await getNoteById(openNoteId))
|
||||||
} else {
|
if (cancelled || !note) return
|
||||||
const fetched = await getNoteById(openNoteId)
|
setEditingNote(prev => {
|
||||||
if (fetched) {
|
if (prev?.note.id === note.id && prev.readOnly === false) return prev
|
||||||
setEditingNote({ note: fetched, readOnly: false })
|
return { note, readOnly: false }
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
run()
|
||||||
params.delete('openNote')
|
return () => {
|
||||||
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
|
cancelled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
openNote()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -305,7 +303,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchParams, refreshKey])
|
}, [searchParams, refreshKey])
|
||||||
|
|
||||||
const { notebooks } = useNotebooks()
|
|
||||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -340,8 +337,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
const handleEditorClose = useCallback(() => {
|
const handleEditorClose = useCallback(() => {
|
||||||
setEditingNote(null)
|
setEditingNote(null)
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.delete('openNote')
|
||||||
|
const qs = params.toString()
|
||||||
|
router.replace(qs ? `/?${qs}` : '/', { scroll: false })
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [triggerRefresh])
|
}, [triggerRefresh, router, searchParams])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { LABEL_COLORS } from '@/lib/types'
|
import { LABEL_COLORS } from '@/lib/types'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
|
||||||
interface LabelBadgeProps {
|
interface LabelBadgeProps {
|
||||||
label: string
|
label: string
|
||||||
@@ -23,7 +23,7 @@ export function LabelBadge({
|
|||||||
isSelected = false,
|
isSelected = false,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
}: LabelBadgeProps) {
|
}: LabelBadgeProps) {
|
||||||
const { getLabelColor } = useLabels()
|
const { getLabelColor } = useNotebooks()
|
||||||
const colorName = getLabelColor(label)
|
const colorName = getLabelColor(label)
|
||||||
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
|
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Filter, Check } from 'lucide-react'
|
import { Filter, Check } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ interface LabelFilterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
|
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
|
||||||
const { labels, loading } = useLabels()
|
const { labels, isLoading: loading } = useNotebooks()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
|
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
|
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
|
||||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export interface LabelManagementDialogProps {
|
|||||||
|
|
||||||
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||||
const { open, onOpenChange } = props
|
const { open, onOpenChange } = props
|
||||||
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
|
const { labels, isLoading: loading, addLabel, updateLabel, deleteLabel } = useNotebooks()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
const { triggerRefresh } = useNoteRefresh()
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Badge } from './ui/badge'
|
|||||||
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
|
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
|
||||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels, Label } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface LabelManagerProps {
|
interface LabelManagerProps {
|
||||||
@@ -26,7 +26,7 @@ interface LabelManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelManagerProps) {
|
export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelManagerProps) {
|
||||||
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels()
|
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useNotebooks()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [newLabel, setNewLabel] = useState('')
|
const [newLabel, setNewLabel] = useState('')
|
||||||
@@ -45,18 +45,11 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
|
|
||||||
if (trimmed && !selectedLabels.includes(trimmed)) {
|
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||||
try {
|
try {
|
||||||
// NotebookId is REQUIRED for label creation (PRD R2)
|
|
||||||
if (!notebookId) {
|
|
||||||
setErrorMessage(t('labels.notebookRequired'))
|
|
||||||
console.error(t('labels.notebookRequired'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing label color or use random
|
// Get existing label color or use random
|
||||||
const existingLabel = labels.find(l => l.name === trimmed)
|
const existingLabel = labels.find(l => l.name === trimmed)
|
||||||
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
||||||
|
|
||||||
await addLabel(trimmed, color, notebookId)
|
await addLabel(trimmed, color)
|
||||||
const updated = [...selectedLabels, trimmed]
|
const updated = [...selectedLabels, trimmed]
|
||||||
setSelectedLabels(updated)
|
setSelectedLabels(updated)
|
||||||
setNewLabel('')
|
setNewLabel('')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Tag, Plus, Check } from 'lucide-react'
|
import { Tag, Plus, Check } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function LabelSelector({
|
|||||||
triggerLabel,
|
triggerLabel,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
}: LabelSelectorProps) {
|
}: LabelSelectorProps) {
|
||||||
const { labels, loading, addLabel } = useLabels()
|
const { labels, isLoading: loading, addLabel } = useNotebooks()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { Note } from '@/lib/types';
|
import { Note } from '@/lib/types';
|
||||||
import { NoteCard } from './note-card';
|
import { NoteCard } from './note-card';
|
||||||
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
||||||
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
import { useEditorUI } from '@/context/editor-ui-context';
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n';
|
||||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
|
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
@@ -175,7 +175,7 @@ export function MasonryGrid({
|
|||||||
}: MasonryGridProps) {
|
}: MasonryGridProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
||||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
const { startDrag, endDrag, draggedNoteId } = useEditorUI();
|
||||||
const cardSizeMode = useCardSizeMode();
|
const cardSizeMode = useCardSizeMode();
|
||||||
const isUniformMode = cardSizeMode === 'uniform';
|
const isUniformMode = cardSizeMode === 'uniform';
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,9 @@ const ConnectionsOverlay = dynamic(() => import('./connections-overlay').then(m
|
|||||||
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
|
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
|
||||||
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
|
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
|
||||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
// Mapping of supported languages to date-fns locales
|
// Mapping of supported languages to date-fns locales
|
||||||
@@ -172,11 +171,10 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
}: NoteCardProps) {
|
}: NoteCardProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { refreshLabels } = useLabels()
|
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
const { triggerRefresh } = useNoteRefresh()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
const { notebooks, moveNoteToNotebookOptimistic, refreshLabels } = useNotebooks()
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [isHidden, setIsHidden] = useState(false)
|
const [isHidden, setIsHidden] = useState(false)
|
||||||
|
|||||||
37
memento-note/components/note-editor/index.tsx
Normal file
37
memento-note/components/note-editor/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { NoteEditorFullPage } from './note-editor-full-page'
|
||||||
|
import { NoteEditorDialog } from './note-editor-dialog'
|
||||||
|
import { Note } from '@/lib/types'
|
||||||
|
|
||||||
|
interface NoteEditorProps {
|
||||||
|
note: Note
|
||||||
|
readOnly?: boolean
|
||||||
|
onClose: () => void
|
||||||
|
fullPage?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditor({ note, readOnly, onClose, fullPage = false }: NoteEditorProps) {
|
||||||
|
return (
|
||||||
|
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage}>
|
||||||
|
{fullPage ? (
|
||||||
|
<NoteEditorFullPage onClose={onClose} />
|
||||||
|
) : (
|
||||||
|
<NoteEditorDialog onClose={onClose} />
|
||||||
|
)}
|
||||||
|
</NoteEditorProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export context hook for backwards compatibility
|
||||||
|
export { useNoteEditorContext } from './note-editor-context'
|
||||||
|
|
||||||
|
// Re-export sub-components for advanced usage
|
||||||
|
export { NoteEditorFullPage } from './note-editor-full-page'
|
||||||
|
export { NoteEditorDialog } from './note-editor-dialog'
|
||||||
|
export { NoteEditorProvider } from './note-editor-context'
|
||||||
|
export { NoteTitleBlock } from './note-title-block'
|
||||||
|
export { NoteContentArea } from './note-content-area'
|
||||||
|
export { NoteMetadataSection } from './note-metadata-section'
|
||||||
|
export { NoteEditorToolbar } from './note-editor-toolbar'
|
||||||
179
memento-note/components/note-editor/note-content-area.tsx
Normal file
179
memento-note/components/note-editor/note-content-area.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { RichTextEditor } from '@/components/rich-text-editor'
|
||||||
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
|
import { MarkdownSlashCommands } from '@/components/markdown-slash-commands'
|
||||||
|
import { GhostTags } from '@/components/ghost-tags'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { X, Plus } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function NoteContentArea() {
|
||||||
|
const { state, actions, readOnly, fullPage, textareaRef } = useNoteEditorContext()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
const uploadImageFile = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
if (!response.ok) throw new Error('Upload failed')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.noteType === 'richtext') {
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<div className="fullpage-editor">
|
||||||
|
<RichTextEditor
|
||||||
|
content={state.content}
|
||||||
|
onChange={(v: string) => actions.setContent(v)}
|
||||||
|
className="min-h-[280px]"
|
||||||
|
onImageUpload={uploadImageFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RichTextEditor
|
||||||
|
content={state.content}
|
||||||
|
onChange={actions.setContent}
|
||||||
|
className="min-h-[200px]"
|
||||||
|
onImageUpload={uploadImageFile}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.noteType === 'markdown' && state.showMarkdownPreview) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-h-[280px] cursor-text prose prose-lg dark:prose-invert max-w-none leading-relaxed',
|
||||||
|
fullPage ? '' : 'p-3 rounded-md border border-border/40 bg-muted/20'
|
||||||
|
)}
|
||||||
|
onClick={() => !readOnly && actions.setShowMarkdownPreview(false)}
|
||||||
|
>
|
||||||
|
<MarkdownContent content={state.content} />
|
||||||
|
{!readOnly && (
|
||||||
|
<p className="text-[11px] text-foreground/30 mt-8 select-none not-prose italic">
|
||||||
|
Cliquez pour éditer
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.noteType === 'markdown' || state.noteType === 'text') {
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
dir="auto"
|
||||||
|
placeholder={t('notes.takeNote') || "Commencez à écrire… tapez '/' pour les commandes"}
|
||||||
|
value={state.content}
|
||||||
|
onFocus={() => actions.setShowMarkdownPreview(false)}
|
||||||
|
onChange={(e) => actions.setContent(e.target.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="w-full min-h-[280px] border-0 outline-none px-0 bg-transparent editor-body leading-relaxed resize-none overflow-hidden placeholder:text-foreground/30 text-foreground"
|
||||||
|
/>
|
||||||
|
{!readOnly && (
|
||||||
|
<MarkdownSlashCommands
|
||||||
|
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
value={state.content}
|
||||||
|
onChange={(v: string) => actions.setContent(v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog mode
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
dir="auto"
|
||||||
|
placeholder={state.isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
|
||||||
|
value={state.content}
|
||||||
|
onChange={(e) => actions.setContent(e.target.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none text-sm leading-relaxed",
|
||||||
|
readOnly && "cursor-default"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<GhostTags
|
||||||
|
suggestions={state.filteredSuggestions}
|
||||||
|
addedTags={state.labels}
|
||||||
|
isAnalyzing={state.isAnalyzingSuggestions}
|
||||||
|
onSelectTag={actions.handleSelectGhostTag}
|
||||||
|
onDismissTag={actions.handleDismissGhostTag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checklist mode
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{state.checkItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-2 group">
|
||||||
|
<Checkbox
|
||||||
|
checked={item.checked}
|
||||||
|
onCheckedChange={() => actions.handleCheckItem(item.id)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
|
||||||
|
placeholder={t('notes.listItem')}
|
||||||
|
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
||||||
|
onClick={() => actions.handleRemoveCheckItem(item.id)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('notes.addItem')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{state.checkItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-2 group">
|
||||||
|
<Checkbox
|
||||||
|
checked={item.checked}
|
||||||
|
onCheckedChange={() => actions.handleCheckItem(item.id)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
|
||||||
|
placeholder={t('notes.listItem')}
|
||||||
|
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
||||||
|
onClick={() => actions.handleRemoveCheckItem(item.id)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('notes.addItem')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
796
memento-note/components/note-editor/note-editor-context.tsx
Normal file
796
memento-note/components/note-editor/note-editor-context.tsx
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
|
||||||
|
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType, NoteSize } from '@/lib/types'
|
||||||
|
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
|
||||||
|
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||||
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
|
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||||
|
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
|
import { extractImagesFromHTML } from '@/lib/utils'
|
||||||
|
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||||
|
import type { TagSuggestion } from '@/lib/ai/types'
|
||||||
|
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
|
||||||
|
|
||||||
|
const NoteEditorContext = createContext<NoteEditorContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
interface NoteEditorProviderProps {
|
||||||
|
note: Note
|
||||||
|
readOnly?: boolean
|
||||||
|
fullPage?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, children }: NoteEditorProviderProps) {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
|
||||||
|
const { triggerRefresh } = useNoteRefresh()
|
||||||
|
|
||||||
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||||
|
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user?.id) {
|
||||||
|
getAISettings(session.user.id).then(settings => {
|
||||||
|
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
||||||
|
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
||||||
|
}).catch(err => console.error("Failed to fetch AI settings", err))
|
||||||
|
}
|
||||||
|
}, [session?.user?.id])
|
||||||
|
|
||||||
|
// Core content state
|
||||||
|
const [title, setTitle] = useState(note.title || '')
|
||||||
|
const [content, setContent] = useState(note.content)
|
||||||
|
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||||
|
const [labels, setLabels] = useState<string[]>(note.labels || [])
|
||||||
|
const [images, setImages] = useState<string[]>(note.images || [])
|
||||||
|
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
|
||||||
|
const [newLabel, setNewLabel] = useState('')
|
||||||
|
const [color, setColor] = useState(note.color)
|
||||||
|
const [size, setSize] = useState<NoteSize>(note.size || 'small')
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
|
||||||
|
const [noteType, setNoteType] = useState<NoteType>(note.type)
|
||||||
|
const isMarkdown = noteType === 'markdown'
|
||||||
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const prevNoteRef = useRef(note)
|
||||||
|
|
||||||
|
// CRITICAL: Sync state when note.id changes (lines 101-116 from original)
|
||||||
|
useEffect(() => {
|
||||||
|
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
|
||||||
|
setTitle(note.title || '')
|
||||||
|
setContent(note.content)
|
||||||
|
setCheckItems(note.checkItems || [])
|
||||||
|
setLabels(note.labels || [])
|
||||||
|
setImages(note.images || [])
|
||||||
|
setLinks(note.links || [])
|
||||||
|
setColor(note.color)
|
||||||
|
setSize(note.size || 'small')
|
||||||
|
setNoteType(note.type)
|
||||||
|
setShowMarkdownPreview(note.type === 'markdown')
|
||||||
|
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
||||||
|
}
|
||||||
|
prevNoteRef.current = note
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
// Update context notebookId when note changes
|
||||||
|
useEffect(() => {
|
||||||
|
setContextNotebookId(note.notebookId || null)
|
||||||
|
}, [note.notebookId, setContextNotebookId])
|
||||||
|
|
||||||
|
// Auto-tagging hook
|
||||||
|
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
|
||||||
|
content: noteType !== 'checklist' ? content : '',
|
||||||
|
notebookId: note.notebookId,
|
||||||
|
enabled: noteType !== 'checklist' && autoLabelingEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reminder state
|
||||||
|
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||||
|
const [currentReminder, setCurrentReminder] = useState<Date | null>(
|
||||||
|
note.reminder ? new Date(note.reminder as unknown as string) : null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Link state
|
||||||
|
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||||||
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
|
|
||||||
|
// Title suggestions state
|
||||||
|
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
|
||||||
|
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
|
||||||
|
|
||||||
|
// Reformulation state
|
||||||
|
const [isReformulating, setIsReformulating] = useState(false)
|
||||||
|
const [reformulationModal, setReformulationModal] = useState<{
|
||||||
|
originalText: string
|
||||||
|
reformulatedText: string
|
||||||
|
option: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// AI processing state
|
||||||
|
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
||||||
|
const [aiOpen, setAiOpen] = useState(false)
|
||||||
|
const [infoOpen, setInfoOpen] = useState(false)
|
||||||
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
|
||||||
|
// fullPage — auto title suggestions
|
||||||
|
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
||||||
|
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
|
||||||
|
content,
|
||||||
|
enabled: fullPage && !title && !dismissedTitleSuggestions,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track previous content for copilot action undo
|
||||||
|
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Memory Echo Connections state
|
||||||
|
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
||||||
|
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||||
|
|
||||||
|
// Tags dismissed by the user for this session
|
||||||
|
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Filter suggestions to exclude dismissed ones
|
||||||
|
// and those already present on the note
|
||||||
|
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
|
||||||
|
const filteredSuggestions = suggestions.filter(s => {
|
||||||
|
if (!s || !s.tag) return false
|
||||||
|
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
|
||||||
|
|
||||||
|
const uploadImageFile = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
if (!response.ok) throw new Error('Upload failed')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
const url = await uploadImageFile(file)
|
||||||
|
setImages(prev => [...prev, url])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error)
|
||||||
|
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste handler: upload clipboard images
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
try {
|
||||||
|
const url = await uploadImageFile(file)
|
||||||
|
setImages(prev => [...prev, url])
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('paste', handlePaste, { capture: true })
|
||||||
|
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||||
|
}, [t, noteType])
|
||||||
|
|
||||||
|
// Auto-grow textarea as content grows
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
// Also auto-grow when switching FROM preview TO edit mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (showMarkdownPreview) return // we're in preview, textarea not mounted
|
||||||
|
// Defer one frame so the textarea is in the DOM
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
|
||||||
|
el.focus()
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [showMarkdownPreview])
|
||||||
|
|
||||||
|
const handleRemoveImage = (index: number) => {
|
||||||
|
const removedUrl = images[index]
|
||||||
|
setImages(images.filter((_, i) => i !== index))
|
||||||
|
// Track removed images for cleanup on save
|
||||||
|
if (removedUrl) {
|
||||||
|
setRemovedImageUrls(prev => [...prev, removedUrl])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddLink = async () => {
|
||||||
|
if (!linkUrl) return
|
||||||
|
|
||||||
|
setShowLinkDialog(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await fetchLinkMetadata(linkUrl)
|
||||||
|
if (metadata) {
|
||||||
|
setLinks(prev => [...prev, metadata])
|
||||||
|
toast.success(t('notes.linkAdded'))
|
||||||
|
} else {
|
||||||
|
toast.warning(t('notes.linkMetadataFailed'))
|
||||||
|
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add link:', error)
|
||||||
|
toast.error(t('notes.linkAddFailed'))
|
||||||
|
} finally {
|
||||||
|
setLinkUrl('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveLink = (index: number) => {
|
||||||
|
setLinks(links.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const allImages = useMemo(() => {
|
||||||
|
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
||||||
|
return Array.from(new Set([...images, ...extracted]));
|
||||||
|
}, [images, content, noteType]);
|
||||||
|
|
||||||
|
const handleGenerateTitles = async () => {
|
||||||
|
const fullContentForAI = [
|
||||||
|
content,
|
||||||
|
...links.map(l => `${l.title || ''} ${l.description || ''}`)
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const wordCount = fullContentForAI.split(/\s+/).filter(word => word.length > 0).length
|
||||||
|
|
||||||
|
if (wordCount < 10) {
|
||||||
|
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingTitles(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/title-suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: fullContentForAI }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || t('ai.titleGenerationError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setTitleSuggestions(data.suggestions || [])
|
||||||
|
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error generating titles:', error)
|
||||||
|
toast.error(error.message || t('ai.titleGenerationFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingTitles(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectTitle = (title: string) => {
|
||||||
|
setTitle(title)
|
||||||
|
setTitleSuggestions([])
|
||||||
|
toast.success(t('ai.titleApplied'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReformulate = async (option: 'clarify' | 'shorten' | 'improve') => {
|
||||||
|
const selectedText = window.getSelection()?.toString()
|
||||||
|
|
||||||
|
if (!selectedText && (!content || content.trim().length === 0)) {
|
||||||
|
toast.error(t('ai.reformulationNoText'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let textToReformulate: string
|
||||||
|
if (selectedText && selectedText.trim().split(/\s+/).filter(word => word.length > 0).length >= 10) {
|
||||||
|
textToReformulate = selectedText
|
||||||
|
} else {
|
||||||
|
textToReformulate = content
|
||||||
|
if (selectedText) {
|
||||||
|
toast.info(t('ai.reformulationSelectionTooShort'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCount = textToReformulate.trim().split(/\s+/).filter(word => word.length > 0).length
|
||||||
|
|
||||||
|
if (wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordCount > 500) {
|
||||||
|
toast.error(t('ai.reformulationMaxWords'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsReformulating(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/reformulate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: textToReformulate,
|
||||||
|
option: option
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || t('ai.reformulationError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
setReformulationModal({
|
||||||
|
originalText: data.originalText,
|
||||||
|
reformulatedText: data.reformulatedText,
|
||||||
|
option: data.option
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error reformulating:', error)
|
||||||
|
toast.error(error.message || t('ai.reformulationFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsReformulating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClarifyDirect = async () => {
|
||||||
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||||
|
if (!content || wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/reformulate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content, option: 'clarify' })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
|
||||||
|
setContent(data.reformulatedText || data.text)
|
||||||
|
toast.success(t('ai.reformulationApplied'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clarify error:', error)
|
||||||
|
toast.error(t('notes.clarifyFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingAI(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShortenDirect = async () => {
|
||||||
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||||
|
if (!content || wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/reformulate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content, option: 'shorten' })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
|
||||||
|
setContent(data.reformulatedText || data.text)
|
||||||
|
toast.success(t('ai.reformulationApplied'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Shorten error:', error)
|
||||||
|
toast.error(t('notes.shortenFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingAI(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImproveDirect = async () => {
|
||||||
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||||
|
if (!content || wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/reformulate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content, option: 'improve' })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
|
||||||
|
setContent(data.reformulatedText || data.text)
|
||||||
|
toast.success(t('ai.reformulationApplied'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Improve error:', error)
|
||||||
|
toast.error(t('notes.improveFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingAI(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTransformMarkdown = async () => {
|
||||||
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||||
|
if (!content || wordCount < 10) {
|
||||||
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordCount > 500) {
|
||||||
|
toast.error(t('ai.reformulationMaxWords'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/transform-markdown', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
||||||
|
|
||||||
|
setContent(data.transformedText)
|
||||||
|
setNoteType('markdown')
|
||||||
|
setShowMarkdownPreview(false)
|
||||||
|
|
||||||
|
toast.success(t('ai.transformSuccess'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Transform to markdown error:', error)
|
||||||
|
toast.error(t('ai.transformError'))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingAI(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyRefactor = () => {
|
||||||
|
if (!reformulationModal) return
|
||||||
|
|
||||||
|
const selectedText = window.getSelection()?.toString()
|
||||||
|
if (selectedText) {
|
||||||
|
setContent(reformulationModal.reformulatedText)
|
||||||
|
} else {
|
||||||
|
setContent(reformulationModal.reformulatedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
setReformulationModal(null)
|
||||||
|
toast.success(t('ai.reformulationApplied'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReminderSave = async (date: Date) => {
|
||||||
|
if (date < new Date()) {
|
||||||
|
toast.error(t('notes.reminderPastError'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCurrentReminder(date)
|
||||||
|
try {
|
||||||
|
await updateNote(note.id, { reminder: date })
|
||||||
|
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notebook.savingReminder'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveReminder = async () => {
|
||||||
|
setCurrentReminder(null)
|
||||||
|
try {
|
||||||
|
await updateNote(note.id, { reminder: null })
|
||||||
|
toast.success(t('notes.reminderRemoved'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notebook.removingReminder'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await updateNote(note.id, {
|
||||||
|
title: title.trim() || null,
|
||||||
|
content: noteType !== 'checklist' ? content : '',
|
||||||
|
checkItems: noteType === 'checklist' ? checkItems : null,
|
||||||
|
labels,
|
||||||
|
images,
|
||||||
|
links,
|
||||||
|
color,
|
||||||
|
reminder: currentReminder,
|
||||||
|
isMarkdown: noteType === 'markdown',
|
||||||
|
type: noteType,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (removedImageUrls.length > 0) {
|
||||||
|
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshLabels()
|
||||||
|
triggerRefresh()
|
||||||
|
|
||||||
|
// Note: onClose is handled by the composition component
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save note:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckItem = (id: string) => {
|
||||||
|
setCheckItems(items =>
|
||||||
|
items.map(item =>
|
||||||
|
item.id === id ? { ...item, checked: !item.checked } : item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateCheckItem = (id: string, text: string) => {
|
||||||
|
setCheckItems(items =>
|
||||||
|
items.map(item => (item.id === id ? { ...item, text } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddCheckItem = () => {
|
||||||
|
setCheckItems([
|
||||||
|
...checkItems,
|
||||||
|
{ id: Date.now().toString(), text: '', checked: false },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCheckItem = (id: string) => {
|
||||||
|
setCheckItems(items => items.filter(item => item.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectGhostTag = async (tag: string) => {
|
||||||
|
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
|
||||||
|
|
||||||
|
if (!tagExists) {
|
||||||
|
setLabels(prev => [...prev, tag])
|
||||||
|
|
||||||
|
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
|
||||||
|
if (!globalExists) {
|
||||||
|
try {
|
||||||
|
await addLabel(tag)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating auto-label:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(t('ai.tagAdded', { tag }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismissGhostTag = (tag: string) => {
|
||||||
|
setDismissedTags(prev => [...prev, tag])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveLabel = (label: string) => {
|
||||||
|
setLabels(labels.filter(l => l !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMakeCopy = async () => {
|
||||||
|
try {
|
||||||
|
const newNote = await createNote({
|
||||||
|
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
||||||
|
content: content,
|
||||||
|
color: color,
|
||||||
|
checkItems: checkItems,
|
||||||
|
labels: labels,
|
||||||
|
images: images,
|
||||||
|
links: links,
|
||||||
|
isMarkdown: noteType === 'markdown',
|
||||||
|
type: noteType,
|
||||||
|
size: size,
|
||||||
|
})
|
||||||
|
toast.success(t('notes.copySuccess'))
|
||||||
|
triggerRefresh()
|
||||||
|
// Note: onClose is handled by the composition component
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy note:', error)
|
||||||
|
toast.error(t('notes.copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save in place (fullPage) — without closing
|
||||||
|
const handleSaveInPlace = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await updateNote(note.id, {
|
||||||
|
title: title.trim() || null,
|
||||||
|
content: noteType !== 'checklist' ? content : '',
|
||||||
|
checkItems: noteType === 'checklist' ? checkItems : null,
|
||||||
|
labels,
|
||||||
|
images,
|
||||||
|
links,
|
||||||
|
color,
|
||||||
|
reminder: currentReminder,
|
||||||
|
isMarkdown: noteType === 'markdown',
|
||||||
|
type: noteType,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
if (removedImageUrls.length > 0) {
|
||||||
|
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||||
|
}
|
||||||
|
await refreshLabels()
|
||||||
|
triggerRefresh()
|
||||||
|
setIsDirty(false)
|
||||||
|
toast.success('Note sauvegardée !')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save note:', error)
|
||||||
|
toast.error('Erreur lors de la sauvegarde.')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullPage) return
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSaveInPlace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [fullPage, isSaving])
|
||||||
|
|
||||||
|
// Build state object
|
||||||
|
const state: NoteEditorState = useMemo(() => ({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
checkItems,
|
||||||
|
labels,
|
||||||
|
images,
|
||||||
|
links,
|
||||||
|
newLabel,
|
||||||
|
color: color as NoteColor,
|
||||||
|
size,
|
||||||
|
noteType,
|
||||||
|
showMarkdownPreview,
|
||||||
|
removedImageUrls,
|
||||||
|
isSaving,
|
||||||
|
isDirty,
|
||||||
|
isProcessingAI,
|
||||||
|
aiOpen,
|
||||||
|
infoOpen,
|
||||||
|
isGeneratingTitles,
|
||||||
|
titleSuggestions,
|
||||||
|
dismissedTitleSuggestions,
|
||||||
|
isReformulating,
|
||||||
|
reformulationModal,
|
||||||
|
previousContentForCopilot,
|
||||||
|
showReminderDialog,
|
||||||
|
currentReminder,
|
||||||
|
showLinkDialog,
|
||||||
|
linkUrl,
|
||||||
|
comparisonNotes,
|
||||||
|
fusionNotes,
|
||||||
|
dismissedTags,
|
||||||
|
filteredSuggestions,
|
||||||
|
isAnalyzingSuggestions,
|
||||||
|
isMarkdown,
|
||||||
|
allImages,
|
||||||
|
colorClasses,
|
||||||
|
}), [
|
||||||
|
title, content, checkItems, labels, images, links, newLabel, color, size, noteType,
|
||||||
|
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
|
||||||
|
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||||
|
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||||
|
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
|
||||||
|
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
|
||||||
|
])
|
||||||
|
|
||||||
|
// Build actions object
|
||||||
|
const actions: NoteEditorActions = useMemo(() => ({
|
||||||
|
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
|
||||||
|
setDismissedTitleSuggestions,
|
||||||
|
setContent: (c) => { setContent(c); setIsDirty(true) },
|
||||||
|
setCheckItems,
|
||||||
|
handleCheckItem,
|
||||||
|
handleUpdateCheckItem,
|
||||||
|
handleAddCheckItem,
|
||||||
|
handleRemoveCheckItem,
|
||||||
|
setLabels: (l) => { setLabels(l); setIsDirty(true) },
|
||||||
|
handleSelectGhostTag,
|
||||||
|
handleDismissGhostTag,
|
||||||
|
handleRemoveLabel,
|
||||||
|
setImages,
|
||||||
|
handleImageUpload,
|
||||||
|
handleRemoveImage,
|
||||||
|
uploadImageFile,
|
||||||
|
setLinks,
|
||||||
|
handleAddLink,
|
||||||
|
handleRemoveLink,
|
||||||
|
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
|
||||||
|
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
|
||||||
|
setColor: (c) => { setColor(c); setIsDirty(true) },
|
||||||
|
setSize: (s) => { setSize(s); setIsDirty(true) },
|
||||||
|
setShowReminderDialog,
|
||||||
|
setCurrentReminder,
|
||||||
|
handleReminderSave,
|
||||||
|
handleRemoveReminder,
|
||||||
|
setShowLinkDialog,
|
||||||
|
setLinkUrl,
|
||||||
|
handleGenerateTitles,
|
||||||
|
handleSelectTitle,
|
||||||
|
handleReformulate,
|
||||||
|
handleApplyRefactor,
|
||||||
|
handleClarifyDirect,
|
||||||
|
handleShortenDirect,
|
||||||
|
handleImproveDirect,
|
||||||
|
handleTransformMarkdown,
|
||||||
|
handleSave,
|
||||||
|
handleSaveInPlace,
|
||||||
|
handleMakeCopy,
|
||||||
|
setComparisonNotes,
|
||||||
|
setFusionNotes,
|
||||||
|
setReformulationModal,
|
||||||
|
setIsDirty,
|
||||||
|
setAiOpen,
|
||||||
|
setInfoOpen,
|
||||||
|
setIsProcessingAI,
|
||||||
|
setIsGeneratingTitles,
|
||||||
|
setIsAnalyzingSuggestions: (a) => { /* handled by useAutoTagging */ },
|
||||||
|
setPreviousContentForCopilot,
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const value: NoteEditorContextValue = useMemo(() => ({
|
||||||
|
note,
|
||||||
|
readOnly,
|
||||||
|
fullPage,
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
notebooks: notebooks.map(nb => ({ id: nb.id, name: nb.name })),
|
||||||
|
globalLabels,
|
||||||
|
fileInputRef,
|
||||||
|
textareaRef,
|
||||||
|
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoteEditorContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</NoteEditorContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNoteEditorContext() {
|
||||||
|
const context = useContext(NoteEditorContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
345
memento-note/components/note-editor/note-editor-dialog.tsx
Normal file
345
memento-note/components/note-editor/note-editor-dialog.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { NoteTitleBlock } from './note-title-block'
|
||||||
|
import { NoteContentArea } from './note-content-area'
|
||||||
|
import { NoteMetadataSection } from './note-metadata-section'
|
||||||
|
import { EditorImages } from '@/components/editor-images'
|
||||||
|
import { ComparisonModal } from '@/components/comparison-modal'
|
||||||
|
import { FusionModal } from '@/components/fusion-modal'
|
||||||
|
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||||
|
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||||
|
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Note } from '@/lib/types'
|
||||||
|
|
||||||
|
interface NoteEditorDialogProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||||
|
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
const handleSaveAndClose = async () => {
|
||||||
|
await actions.handleSave()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
|
||||||
|
state.colorClasses
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto space-y-4 px-6 py-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="sr-only">{t('notes.edit')}</DialogTitle>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
|
||||||
|
</div>
|
||||||
|
{readOnly && (
|
||||||
|
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
|
||||||
|
{t('notes.readOnly')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<NoteTitleBlock />
|
||||||
|
|
||||||
|
{/* Title Suggestions */}
|
||||||
|
{!readOnly && state.titleSuggestions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{/* TitleSuggestions component */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
<EditorImages images={state.images} onRemove={actions.handleRemoveImage} />
|
||||||
|
|
||||||
|
{/* Link Previews */}
|
||||||
|
{state.links.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{state.links.map((link, idx) => (
|
||||||
|
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
|
||||||
|
{link.imageUrl && (
|
||||||
|
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
||||||
|
)}
|
||||||
|
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
|
||||||
|
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
|
||||||
|
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
|
||||||
|
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary truncate hover:underline block mt-1">
|
||||||
|
{new URL(link.url).hostname}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
|
||||||
|
onClick={() => actions.handleRemoveLink(idx)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<NoteContentArea />
|
||||||
|
|
||||||
|
{/* Metadata Section */}
|
||||||
|
<NoteMetadataSection />
|
||||||
|
|
||||||
|
{/* Memory Echo Connections Section */}
|
||||||
|
{!readOnly && (
|
||||||
|
<EditorConnectionsSection
|
||||||
|
noteId={note.id}
|
||||||
|
onOpenNote={(noteId: string) => {
|
||||||
|
onClose()
|
||||||
|
window.location.href = `/?note=${noteId}`
|
||||||
|
}}
|
||||||
|
onCompareNotes={(noteIds: string[]) => {
|
||||||
|
Promise.all(noteIds.map(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${id}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`Failed to fetch note ${id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching note ${id}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.then(notes => notes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||||
|
.then(fetchedNotes => {
|
||||||
|
actions.setComparisonNotes(fetchedNotes)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onMergeNotes={async (noteIds: string[]) => {
|
||||||
|
const fetchedNotes = await Promise.all(noteIds.map(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${id}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`Failed to fetch note ${id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching note ${id}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
actions.setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog Toolbar - inline for now */}
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('general.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveAndClose} disabled={state.isSaving}>
|
||||||
|
{state.isSaving ? t('notes.saving') : t('general.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={actions.handleImageUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── AI Copilot Side Panel ── */}
|
||||||
|
{state.aiOpen && (
|
||||||
|
<ContextualAIChat
|
||||||
|
onClose={() => actions.setAiOpen(false)}
|
||||||
|
noteTitle={state.title}
|
||||||
|
noteContent={state.content}
|
||||||
|
noteImages={state.allImages}
|
||||||
|
noteId={note.id}
|
||||||
|
onApplyToNote={(newContent: string) => {
|
||||||
|
actions.setPreviousContentForCopilot(state.content)
|
||||||
|
actions.setContent(newContent)
|
||||||
|
}}
|
||||||
|
onUndoLastAction={state.previousContentForCopilot !== null ? () => {
|
||||||
|
if (state.previousContentForCopilot !== null) {
|
||||||
|
actions.setContent(state.previousContentForCopilot)
|
||||||
|
}
|
||||||
|
actions.setPreviousContentForCopilot(null)
|
||||||
|
} : undefined}
|
||||||
|
lastActionApplied={state.previousContentForCopilot !== null}
|
||||||
|
notebooks={notebooks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Reminder Dialog */}
|
||||||
|
<ReminderDialog
|
||||||
|
open={state.showReminderDialog}
|
||||||
|
onOpenChange={actions.setShowReminderDialog}
|
||||||
|
currentReminder={state.currentReminder}
|
||||||
|
onSave={actions.handleReminderSave}
|
||||||
|
onRemove={actions.handleRemoveReminder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Link Dialog */}
|
||||||
|
<Dialog open={state.showLinkDialog} onOpenChange={actions.setShowLinkDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('notes.addLink')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com"
|
||||||
|
value={state.linkUrl}
|
||||||
|
onChange={(e) => actions.setLinkUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
actions.handleAddLink()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => actions.setShowLinkDialog(false)}>
|
||||||
|
{t('general.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={actions.handleAddLink}>
|
||||||
|
{t('general.add')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Reformulation Modal */}
|
||||||
|
{state.reformulationModal && (
|
||||||
|
<Dialog open={!!state.reformulationModal} onOpenChange={() => actions.setReformulationModal(null)}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('ai.reformulationComparison')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 text-sm text-gray-600 dark:text-gray-400">{t('ai.original')}</h3>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-sm">
|
||||||
|
{state.reformulationModal.originalText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 text-sm text-purple-600 dark:text-purple-400">
|
||||||
|
{t('ai.reformulated')} ({state.reformulationModal.option})
|
||||||
|
</h3>
|
||||||
|
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-sm">
|
||||||
|
{state.reformulationModal.reformulatedText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => actions.setReformulationModal(null)}>
|
||||||
|
{t('general.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={actions.handleApplyRefactor}>
|
||||||
|
{t('general.apply')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparison Modal */}
|
||||||
|
{state.comparisonNotes && state.comparisonNotes.length > 0 && (
|
||||||
|
<ComparisonModal
|
||||||
|
isOpen={!!state.comparisonNotes}
|
||||||
|
onClose={() => actions.setComparisonNotes([])}
|
||||||
|
notes={state.comparisonNotes}
|
||||||
|
onOpenNote={(noteId: string) => {
|
||||||
|
onClose()
|
||||||
|
window.location.href = `/?note=${noteId}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fusion Modal */}
|
||||||
|
{state.fusionNotes && state.fusionNotes.length > 0 && (
|
||||||
|
<FusionModal
|
||||||
|
isOpen={!!state.fusionNotes}
|
||||||
|
onClose={() => actions.setFusionNotes([])}
|
||||||
|
notes={state.fusionNotes}
|
||||||
|
onConfirmFusion={async ({ title, content }: { title: string; content: string }, options: { keepAllTags: boolean; archiveOriginals: boolean }) => {
|
||||||
|
// Save current first
|
||||||
|
await actions.handleSave()
|
||||||
|
|
||||||
|
// Use createNote directly since handleMakeCopy doesn't handle fusion
|
||||||
|
const { createNote } = await import('@/app/actions/notes')
|
||||||
|
await createNote({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
labels: options.keepAllTags
|
||||||
|
? [...new Set(state.fusionNotes.flatMap(n => n.labels || []))]
|
||||||
|
: state.fusionNotes[0].labels || [],
|
||||||
|
color: state.fusionNotes[0].color,
|
||||||
|
type: 'text',
|
||||||
|
isMarkdown: true,
|
||||||
|
autoGenerated: true,
|
||||||
|
aiProvider: 'fusion',
|
||||||
|
notebookId: state.fusionNotes[0].notebookId ?? undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// Archive original notes if option is selected
|
||||||
|
if (options.archiveOriginals) {
|
||||||
|
const { updateNote } = await import('@/app/actions/notes')
|
||||||
|
for (const fusionNote of state.fusionNotes) {
|
||||||
|
if (fusionNote.id) {
|
||||||
|
await updateNote(fusionNote.id, { isArchived: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(t('toast.notesFusionSuccess'))
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
memento-note/components/note-editor/note-editor-full-page.tsx
Normal file
146
memento-note/components/note-editor/note-editor-full-page.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { NoteEditorToolbar } from './note-editor-toolbar'
|
||||||
|
import { NoteTitleBlock } from './note-title-block'
|
||||||
|
import { NoteContentArea } from './note-content-area'
|
||||||
|
import { EditorImages } from '@/components/editor-images'
|
||||||
|
import { ComparisonModal } from '@/components/comparison-modal'
|
||||||
|
import { FusionModal } from '@/components/fusion-modal'
|
||||||
|
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||||
|
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||||
|
import { NoteDocumentInfoPanel } from '@/components/note-document-info-panel'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Note } from '@/lib/types'
|
||||||
|
|
||||||
|
interface NoteEditorFullPageProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||||
|
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
|
||||||
|
|
||||||
|
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── outer container ── */}
|
||||||
|
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
|
||||||
|
|
||||||
|
{/* ── main scrollable column ── */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-zinc-950">
|
||||||
|
|
||||||
|
{/* TOOLBAR */}
|
||||||
|
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
|
||||||
|
|
||||||
|
{/* BODY — max-w-4xl, px-12, py-16 */}
|
||||||
|
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
|
||||||
|
|
||||||
|
{/* Breadcrumb + Title block */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Breadcrumb: Notebook › Date */}
|
||||||
|
<div className="flex items-center gap-3 text-[12px] text-foreground/50 uppercase tracking-[.25em] font-bold">
|
||||||
|
{notebookName && <span>{notebookName}</span>}
|
||||||
|
{notebookName && <span>›</span>}
|
||||||
|
<span suppressHydrationWarning>
|
||||||
|
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<NoteTitleBlock />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero image — show first note image if present */}
|
||||||
|
{state.allImages.length > 0 && (
|
||||||
|
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src={state.allImages[0]}
|
||||||
|
alt={state.title}
|
||||||
|
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content area — max-w-3xl for wider reading column */}
|
||||||
|
<div className="max-w-3xl mx-auto w-full pb-32">
|
||||||
|
<NoteContentArea />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Side panel: AI Chat ── */}
|
||||||
|
{state.aiOpen && (
|
||||||
|
<div className="h-full self-stretch bg-background flex flex-col z-50 shrink-0">
|
||||||
|
<ContextualAIChat
|
||||||
|
onClose={() => actions.setAiOpen(false)}
|
||||||
|
noteTitle={state.title}
|
||||||
|
noteContent={state.content}
|
||||||
|
noteImages={state.allImages}
|
||||||
|
noteId={note.id}
|
||||||
|
onApplyToNote={(nc: string) => {
|
||||||
|
actions.setPreviousContentForCopilot(state.content)
|
||||||
|
actions.setContent(nc)
|
||||||
|
if (state.noteType === 'markdown') actions.setShowMarkdownPreview(true)
|
||||||
|
}}
|
||||||
|
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
|
||||||
|
lastActionApplied={state.previousContentForCopilot !== null}
|
||||||
|
notebooks={notebooks}
|
||||||
|
diagramInsertFormat={state.noteType === 'richtext' ? 'html' : 'markdown'}
|
||||||
|
onGenerateTitle={async () => {
|
||||||
|
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||||
|
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||||
|
if (wordCount < 10) {
|
||||||
|
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/title-suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: plain }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const s = data.suggestions?.[0]?.title ?? ''
|
||||||
|
if (s) {
|
||||||
|
actions.setTitle(s)
|
||||||
|
toast.success('Titre généré !')
|
||||||
|
} else {
|
||||||
|
toast.error('Impossible de générer un titre.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Erreur lors de la génération du titre.')
|
||||||
|
}
|
||||||
|
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Side panel: Document Info ── */}
|
||||||
|
{state.infoOpen && (
|
||||||
|
<div className="w-[400px] h-full self-stretch border-l border-black/10 dark:border-white/10 bg-background flex flex-col z-50 shrink-0">
|
||||||
|
<NoteDocumentInfoPanel
|
||||||
|
note={note}
|
||||||
|
content={state.content}
|
||||||
|
onClose={() => actions.setInfoOpen(false)}
|
||||||
|
onNoteRestored={(r: Note) => { actions.setContent(r.content || ''); actions.setTitle(r.title || ''); actions.setIsDirty(false) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={actions.handleImageUpload} />
|
||||||
|
<ReminderDialog
|
||||||
|
open={state.showReminderDialog}
|
||||||
|
onOpenChange={actions.setShowReminderDialog}
|
||||||
|
currentReminder={state.currentReminder}
|
||||||
|
onSave={actions.handleReminderSave}
|
||||||
|
onRemove={actions.handleRemoveReminder}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
307
memento-note/components/note-editor/note-editor-toolbar.tsx
Normal file
307
memento-note/components/note-editor/note-editor-toolbar.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { LabelManager } from '@/components/label-manager'
|
||||||
|
import { LabelBadge } from '@/components/label-badge'
|
||||||
|
import { GhostTags } from '@/components/ghost-tags'
|
||||||
|
import { EditorImages } from '@/components/editor-images'
|
||||||
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||||
|
import { NoteTypeSelector } from '@/components/note-type-selector'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||||
|
Maximize2, Copy, ArrowLeft, ChevronRight, Info, Check, Loader2, Save, MoreHorizontal,
|
||||||
|
Trash2, LogOut
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||||
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
interface NoteEditorToolbarProps {
|
||||||
|
mode: 'fullPage' | 'dialog'
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||||
|
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const { triggerRefresh } = useNoteRefresh()
|
||||||
|
|
||||||
|
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||||
|
|
||||||
|
if (mode === 'fullPage') {
|
||||||
|
return (
|
||||||
|
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
||||||
|
{/* Left: back */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
<span className="text-sm font-medium">Back to collection</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Right: status + type + AI + Info */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Save status */}
|
||||||
|
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
||||||
|
{state.isSaving
|
||||||
|
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving…</span></>
|
||||||
|
: state.isDirty
|
||||||
|
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
|
||||||
|
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Note type */}
|
||||||
|
<NoteTypeSelector
|
||||||
|
value={state.noteType}
|
||||||
|
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
|
||||||
|
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
|
||||||
|
<button
|
||||||
|
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
|
||||||
|
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
|
||||||
|
state.showMarkdownPreview
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
<span>{state.showMarkdownPreview ? 'Éditer' : 'Aperçu'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI — rounded-full, exact prototype style */}
|
||||||
|
<button
|
||||||
|
aria-label="Ouvrir l'assistant IA"
|
||||||
|
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
|
||||||
|
state.aiOpen
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
<span>AI Assistant</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info — rounded-full */}
|
||||||
|
<button
|
||||||
|
aria-label="Informations du document"
|
||||||
|
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
|
||||||
|
state.infoOpen
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Info size={16} />
|
||||||
|
<span>Document Info</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
aria-label={state.isDirty ? 'Enregistrer la note' : 'Aucune modification à enregistrer'}
|
||||||
|
onClick={actions.handleSaveInPlace}
|
||||||
|
disabled={state.isSaving || !state.isDirty}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
|
||||||
|
state.isDirty
|
||||||
|
? 'bg-foreground text-background border-foreground hover:opacity-80'
|
||||||
|
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
<span>{state.isSaving ? 'Saving…' : 'Save'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Three-dot options menu */}
|
||||||
|
{!readOnly && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button aria-label="Menu des options" className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteNote(note.id)
|
||||||
|
triggerRefresh()
|
||||||
|
toast.success('Note supprimée.')
|
||||||
|
onClose()
|
||||||
|
} catch { toast.error('Impossible de supprimer.') }
|
||||||
|
}}
|
||||||
|
className="text-red-600 dark:text-red-400 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Supprimer la note
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog toolbar
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
{/* Reminder */}
|
||||||
|
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
|
||||||
|
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{/* Add Image */}
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
|
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Add Link */}
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
|
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} />
|
||||||
|
|
||||||
|
{state.noteType === 'markdown' && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
|
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||||
|
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Copilot */}
|
||||||
|
{state.noteType !== 'checklist' && (
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
|
||||||
|
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">IA Note</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Size Selector */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<div className="flex flex-col gap-1 p-1">
|
||||||
|
{(['small', 'medium', 'large'] as const).map((s) => (
|
||||||
|
<Button key={s} variant="ghost" size="sm"
|
||||||
|
onClick={() => actions.setSize(s)}
|
||||||
|
className={cn('justify-start capitalize', state.size === s && 'bg-accent')}>
|
||||||
|
{s}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<div className="grid grid-cols-5 gap-2 p-2">
|
||||||
|
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
||||||
|
<button key={colorName}
|
||||||
|
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
|
||||||
|
state.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
|
||||||
|
onClick={() => actions.setColor(colorName as NoteColor)} title={colorName} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Label Manager */}
|
||||||
|
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{readOnly && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="text-xs">{t('notes.sharedReadOnly')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{readOnly ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={actions.handleMakeCopy}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
{t('notes.makeCopy')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await leaveSharedNote(note.id)
|
||||||
|
toast.success(t('notes.leftShare') || 'Share removed')
|
||||||
|
triggerRefresh()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error(t('general.error'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
{t('notes.leaveShare')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('general.close')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('general.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={actions.handleSave} disabled={state.isSaving}>
|
||||||
|
{state.isSaving ? t('notes.saving') : t('general.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { LabelBadge } from '../label-badge'
|
||||||
|
import { GhostTags } from '../ghost-tags'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function NoteMetadataSection() {
|
||||||
|
const { state, actions, readOnly } = useNoteEditorContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Labels */}
|
||||||
|
{state.labels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{state.labels.map((label) => (
|
||||||
|
<LabelBadge
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
onRemove={() => actions.handleRemoveLabel(label)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ghost Tags - only show in dialog mode */}
|
||||||
|
{!readOnly && state.noteType !== 'richtext' && (
|
||||||
|
<GhostTags
|
||||||
|
suggestions={state.filteredSuggestions}
|
||||||
|
addedTags={state.labels}
|
||||||
|
isAnalyzing={state.isAnalyzingSuggestions}
|
||||||
|
onSelectTag={actions.handleSelectGhostTag}
|
||||||
|
onDismissTag={actions.handleDismissGhostTag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Color indicator */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-foreground/50">Color:</span>
|
||||||
|
<div className={cn('w-4 h-4 rounded-full', state.colorClasses?.bg || 'bg-gray-100')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size indicator */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-foreground/50">Size:</span>
|
||||||
|
<span className="text-xs capitalize">{state.size}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
memento-note/components/note-editor/note-title-block.tsx
Normal file
118
memento-note/components/note-editor/note-title-block.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||||
|
import { Loader2, Sparkles } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export function NoteTitleBlock() {
|
||||||
|
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title — auto-resizing textarea to prevent overflow */}
|
||||||
|
<div className="group relative">
|
||||||
|
<textarea
|
||||||
|
dir="auto"
|
||||||
|
rows={1}
|
||||||
|
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
|
||||||
|
value={state.title}
|
||||||
|
onChange={(e) => { actions.setTitle(e.target.value) }}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = el.scrollHeight + 'px'
|
||||||
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight',
|
||||||
|
'placeholder:text-foreground/20 resize-none overflow-hidden',
|
||||||
|
!readOnly && 'pr-12'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* AI title generation — always visible on hover */}
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||||
|
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||||
|
if (wordCount < 10) {
|
||||||
|
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setIsProcessingAI(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/title-suggestions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: plain }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const s = data.suggestions?.[0]?.title ?? ''
|
||||||
|
if (s) {
|
||||||
|
actions.setTitle(s)
|
||||||
|
toast.success('Titre généré !')
|
||||||
|
} else {
|
||||||
|
toast.error('Impossible de générer un titre.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Erreur lors de la génération du titre.')
|
||||||
|
}
|
||||||
|
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
|
||||||
|
}}
|
||||||
|
disabled={state.isProcessingAI}
|
||||||
|
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
|
||||||
|
title="Générer un titre automatique avec l'IA"
|
||||||
|
>
|
||||||
|
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto title suggestions */}
|
||||||
|
{!state.title && !state.dismissedTitleSuggestions && state.titleSuggestions.length > 0 && (
|
||||||
|
<TitleSuggestions
|
||||||
|
suggestions={state.titleSuggestions}
|
||||||
|
onSelect={(s: string) => { actions.setTitle(s); actions.setDismissedTitleSuggestions(true) }}
|
||||||
|
onDismiss={() => actions.setDismissedTitleSuggestions(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog mode title block
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
dir="auto"
|
||||||
|
placeholder={t('notes.titlePlaceholder')}
|
||||||
|
value={state.title}
|
||||||
|
onChange={(e) => actions.setTitle(e.target.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
|
||||||
|
readOnly && "cursor-default"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={actions.handleGenerateTitles}
|
||||||
|
disabled={state.isGeneratingTitles || readOnly}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 hover:bg-purple-100 dark:hover:bg-purple-900 rounded transition-colors"
|
||||||
|
title={state.isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')}
|
||||||
|
>
|
||||||
|
{state.isGeneratingTitles ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-4 h-4 text-purple-600 hover:text-purple-700 dark:text-purple-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
memento-note/components/note-editor/types.ts
Normal file
174
memento-note/components/note-editor/types.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType, LinkMetadata, NoteSize } from '@/lib/types'
|
||||||
|
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||||
|
import type { TagSuggestion } from '@/lib/ai/types'
|
||||||
|
|
||||||
|
// State interface - all local state from NoteEditor
|
||||||
|
export interface NoteEditorState {
|
||||||
|
// Core content state
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
checkItems: CheckItem[]
|
||||||
|
labels: string[]
|
||||||
|
images: string[]
|
||||||
|
links: LinkMetadata[]
|
||||||
|
newLabel: string
|
||||||
|
color: NoteColor
|
||||||
|
size: NoteSize
|
||||||
|
noteType: NoteType
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
showMarkdownPreview: boolean
|
||||||
|
removedImageUrls: string[]
|
||||||
|
isSaving: boolean
|
||||||
|
isDirty: boolean
|
||||||
|
|
||||||
|
// AI state
|
||||||
|
isProcessingAI: boolean
|
||||||
|
aiOpen: boolean
|
||||||
|
infoOpen: boolean
|
||||||
|
isGeneratingTitles: boolean
|
||||||
|
titleSuggestions: TitleSuggestion[]
|
||||||
|
dismissedTitleSuggestions: boolean
|
||||||
|
isReformulating: boolean
|
||||||
|
reformulationModal: {
|
||||||
|
originalText: string
|
||||||
|
reformulatedText: string
|
||||||
|
option: string
|
||||||
|
} | null
|
||||||
|
previousContentForCopilot: string | null
|
||||||
|
|
||||||
|
// Reminder state
|
||||||
|
showReminderDialog: boolean
|
||||||
|
currentReminder: Date | null
|
||||||
|
|
||||||
|
// Link dialog state
|
||||||
|
showLinkDialog: boolean
|
||||||
|
linkUrl: string
|
||||||
|
|
||||||
|
// Memory Echo Connections
|
||||||
|
comparisonNotes: Array<Partial<Note>>
|
||||||
|
fusionNotes: Array<Partial<Note>>
|
||||||
|
|
||||||
|
// Ghost tags
|
||||||
|
dismissedTags: string[]
|
||||||
|
|
||||||
|
// Tag suggestions (from auto-tagging)
|
||||||
|
filteredSuggestions: TagSuggestion[]
|
||||||
|
isAnalyzingSuggestions: boolean
|
||||||
|
|
||||||
|
// Context-derived values
|
||||||
|
isMarkdown: boolean
|
||||||
|
allImages: string[]
|
||||||
|
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions interface - all handlers from NoteEditor
|
||||||
|
export interface NoteEditorActions {
|
||||||
|
// Title actions
|
||||||
|
setTitle: (title: string) => void
|
||||||
|
setDismissedTitleSuggestions: (dismissed: boolean) => void
|
||||||
|
|
||||||
|
// Content actions
|
||||||
|
setContent: (content: string) => void
|
||||||
|
|
||||||
|
// CheckItems actions
|
||||||
|
setCheckItems: (items: CheckItem[]) => void
|
||||||
|
handleCheckItem: (id: string) => void
|
||||||
|
handleUpdateCheckItem: (id: string, text: string) => void
|
||||||
|
handleAddCheckItem: () => void
|
||||||
|
handleRemoveCheckItem: (id: string) => void
|
||||||
|
|
||||||
|
// Labels actions
|
||||||
|
setLabels: (labels: string[]) => void
|
||||||
|
handleSelectGhostTag: (tag: string) => void
|
||||||
|
handleDismissGhostTag: (tag: string) => void
|
||||||
|
handleRemoveLabel: (label: string) => void
|
||||||
|
|
||||||
|
// Images actions
|
||||||
|
setImages: (images: string[]) => void
|
||||||
|
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
handleRemoveImage: (index: number) => void
|
||||||
|
uploadImageFile: (file: File) => Promise<string>
|
||||||
|
|
||||||
|
// Links actions
|
||||||
|
setLinks: (links: LinkMetadata[]) => void
|
||||||
|
handleAddLink: () => Promise<void>
|
||||||
|
handleRemoveLink: (index: number) => void
|
||||||
|
|
||||||
|
// Note properties
|
||||||
|
setNoteType: (type: NoteType) => void
|
||||||
|
setShowMarkdownPreview: (show: boolean) => void
|
||||||
|
setColor: (color: NoteColor) => void
|
||||||
|
setSize: (size: NoteSize) => void
|
||||||
|
|
||||||
|
// Reminder actions
|
||||||
|
setShowReminderDialog: (show: boolean) => void
|
||||||
|
setCurrentReminder: (date: Date | null) => void
|
||||||
|
handleReminderSave: (date: Date) => Promise<void>
|
||||||
|
handleRemoveReminder: () => Promise<void>
|
||||||
|
|
||||||
|
// Link dialog
|
||||||
|
setShowLinkDialog: (show: boolean) => void
|
||||||
|
setLinkUrl: (url: string) => void
|
||||||
|
|
||||||
|
// Title suggestions
|
||||||
|
handleGenerateTitles: () => Promise<void>
|
||||||
|
handleSelectTitle: (title: string) => void
|
||||||
|
|
||||||
|
// Reformulation
|
||||||
|
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
|
||||||
|
handleApplyRefactor: () => void
|
||||||
|
|
||||||
|
// AI Direct handlers
|
||||||
|
handleClarifyDirect: () => Promise<void>
|
||||||
|
handleShortenDirect: () => Promise<void>
|
||||||
|
handleImproveDirect: () => Promise<void>
|
||||||
|
handleTransformMarkdown: () => Promise<void>
|
||||||
|
|
||||||
|
// Save actions
|
||||||
|
handleSave: () => Promise<void>
|
||||||
|
handleSaveInPlace: () => Promise<void>
|
||||||
|
handleMakeCopy: () => Promise<void>
|
||||||
|
|
||||||
|
// Memory Echo
|
||||||
|
setComparisonNotes: (notes: Array<Partial<Note>>) => void
|
||||||
|
setFusionNotes: (notes: Array<Partial<Note>>) => void
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
|
||||||
|
|
||||||
|
// State setters
|
||||||
|
setIsDirty: (dirty: boolean) => void
|
||||||
|
setAiOpen: (open: boolean) => void
|
||||||
|
setInfoOpen: (open: boolean) => void
|
||||||
|
setIsProcessingAI: (processing: boolean) => void
|
||||||
|
setIsGeneratingTitles: (generating: boolean) => void
|
||||||
|
setIsAnalyzingSuggestions: (analyzing: boolean) => void
|
||||||
|
setPreviousContentForCopilot: (content: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context value - combines state + actions + note reference
|
||||||
|
export interface NoteEditorContextValue {
|
||||||
|
// The current note (external source of truth)
|
||||||
|
note: Note
|
||||||
|
|
||||||
|
// Read-only flag
|
||||||
|
readOnly: boolean
|
||||||
|
|
||||||
|
// FullPage flag
|
||||||
|
fullPage: boolean
|
||||||
|
|
||||||
|
// All state
|
||||||
|
state: NoteEditorState
|
||||||
|
|
||||||
|
// All actions
|
||||||
|
actions: NoteEditorActions
|
||||||
|
|
||||||
|
// Computed values from contexts
|
||||||
|
notebooks: Array<{ id: string; name: string }>
|
||||||
|
globalLabels: Array<{ name: string }>
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||||
|
}
|
||||||
@@ -52,9 +52,8 @@ import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
|||||||
import { GhostTags } from '@/components/ghost-tags'
|
import { GhostTags } from '@/components/ghost-tags'
|
||||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { fr } from 'date-fns/locale/fr'
|
import { fr } from 'date-fns/locale/fr'
|
||||||
@@ -120,7 +119,7 @@ export function NoteInlineEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [session?.user?.id])
|
}, [session?.user?.id])
|
||||||
const { labels: globalLabels, addLabel } = useLabels()
|
const { labels: globalLabels, addLabel } = useNotebooks()
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
const { triggerRefresh } = useNoteRefresh()
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import { GhostTags } from './ghost-tags'
|
|||||||
import { TitleSuggestions } from './title-suggestions'
|
import { TitleSuggestions } from './title-suggestions'
|
||||||
import { CollaboratorDialog } from './collaborator-dialog'
|
import { CollaboratorDialog } from './collaborator-dialog'
|
||||||
import { AIAssistantActionBar } from './ai-assistant-action-bar'
|
import { AIAssistantActionBar } from './ai-assistant-action-bar'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
@@ -106,7 +105,7 @@ export function NoteInput({
|
|||||||
forceExpanded = false,
|
forceExpanded = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
}: NoteInputProps) {
|
}: NoteInputProps) {
|
||||||
const { labels: globalLabels, addLabel } = useLabels()
|
const { labels: globalLabels, addLabel } = useNotebooks()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
|
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useNotebookDrag } from '@/context/notebook-drag-context'
|
import { useEditorUI } from '@/context/editor-ui-context'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { CreateNotebookDialog } from './create-notebook-dialog'
|
import { CreateNotebookDialog } from './create-notebook-dialog'
|
||||||
import { NotebookActions } from './notebook-actions'
|
import { NotebookActions } from './notebook-actions'
|
||||||
@@ -15,7 +15,6 @@ import { DeleteNotebookDialog } from './delete-notebook-dialog'
|
|||||||
import { EditNotebookDialog } from './edit-notebook-dialog'
|
import { EditNotebookDialog } from './edit-notebook-dialog'
|
||||||
import { NotebookSummaryDialog } from './notebook-summary-dialog'
|
import { NotebookSummaryDialog } from './notebook-summary-dialog'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
||||||
import { Notebook } from '@/lib/types'
|
import { Notebook } from '@/lib/types'
|
||||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||||
@@ -35,9 +34,8 @@ export function NotebooksList() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading } = useNotebooks()
|
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading, labels } = useNotebooks()
|
||||||
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
|
const { draggedNoteId, dragOverNotebookId, dragOver } = useEditorUI()
|
||||||
const { labels } = useLabels()
|
|
||||||
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||||
const [editingNotebook, setEditingNotebook] = useState<Notebook | null>(null)
|
const [editingNotebook, setEditingNotebook] = useState<Notebook | null>(null)
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider'
|
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||||
import { LabelProvider } from '@/context/LabelContext'
|
|
||||||
import { NotebooksProvider } from '@/context/notebooks-context'
|
import { NotebooksProvider } from '@/context/notebooks-context'
|
||||||
import { NotebookDragProvider } from '@/context/notebook-drag-context'
|
import { EditorUIProvider } from '@/context/editor-ui-context'
|
||||||
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
||||||
import { HomeViewProvider } from '@/context/home-view-context'
|
import { QueryProvider } from '@/components/query-provider'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { Translations } from '@/lib/i18n/load-translations'
|
import type { Translations } from '@/lib/i18n/load-translations'
|
||||||
|
|
||||||
@@ -26,18 +25,18 @@ interface ProvidersWrapperProps {
|
|||||||
|
|
||||||
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
|
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
|
||||||
return (
|
return (
|
||||||
<NoteRefreshProvider>
|
<QueryProvider>
|
||||||
<LabelProvider>
|
<NoteRefreshProvider>
|
||||||
<NotebooksProvider>
|
<NotebooksProvider>
|
||||||
<NotebookDragProvider>
|
<EditorUIProvider>
|
||||||
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
||||||
<DirWrapper>
|
<DirWrapper>
|
||||||
<HomeViewProvider>{children}</HomeViewProvider>
|
{children}
|
||||||
</DirWrapper>
|
</DirWrapper>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</NotebookDragProvider>
|
</EditorUIProvider>
|
||||||
</NotebooksProvider>
|
</NotebooksProvider>
|
||||||
</LabelProvider>
|
</NoteRefreshProvider>
|
||||||
</NoteRefreshProvider>
|
</QueryProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
26
memento-note/components/query-provider.tsx
Normal file
26
memento-note/components/query-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes (was cacheTime)
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
74
memento-note/context/editor-ui-context.tsx
Normal file
74
memento-note/context/editor-ui-context.tsx
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 d’accueil ; 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)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'
|
import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import type { Notebook, Label, Note } from '@/lib/types'
|
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 { useNoteRefresh } from './NoteRefreshContext'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { queryKeys } from '@/lib/query-keys'
|
||||||
|
|
||||||
// ===== INPUT TYPES =====
|
// ===== INPUT TYPES =====
|
||||||
export interface CreateNotebookInput {
|
export interface CreateNotebookInput {
|
||||||
@@ -39,6 +43,15 @@ export interface NotebooksContextValue {
|
|||||||
isMovingNote: boolean
|
isMovingNote: boolean
|
||||||
error: string | null
|
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
|
// Actions: Notebooks
|
||||||
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<void>
|
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<void>
|
||||||
updateNotebook: (notebookId: string, data: UpdateNotebookInput) => Promise<void>
|
updateNotebook: (notebookId: string, data: UpdateNotebookInput) => Promise<void>
|
||||||
@@ -79,6 +92,7 @@ interface NotebooksProviderProps {
|
|||||||
|
|
||||||
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
|
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
|
||||||
// ===== BASE STATE =====
|
// ===== BASE STATE =====
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
|
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
|
||||||
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
|
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -86,6 +100,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
|
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 =====
|
// ===== DERIVED STATE =====
|
||||||
const currentLabels = useMemo(() => {
|
const currentLabels = useMemo(() => {
|
||||||
if (!currentNotebook) return []
|
if (!currentNotebook) return []
|
||||||
@@ -119,6 +138,34 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
if (notebooksRefreshKey > 0) loadNotebooks()
|
if (notebooksRefreshKey > 0) loadNotebooks()
|
||||||
}, [notebooksRefreshKey, 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 =====
|
// ===== ACTIONS: NOTEBOOKS =====
|
||||||
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
||||||
const response = await fetch('/api/notebooks', {
|
const response = await fetch('/api/notebooks', {
|
||||||
@@ -131,11 +178,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
throw new Error('Failed to create notebook')
|
throw new Error('Failed to create notebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload notebooks from server to update sidebar state
|
// Invalidate notebooks cache — React Query will re-fetch in bg
|
||||||
await loadNotebooks()
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
||||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||||
@@ -148,10 +195,10 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
throw new Error('Failed to update notebook')
|
throw new Error('Failed to update notebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const deleteNotebook = useCallback(async (notebookId: string) => {
|
const deleteNotebook = useCallback(async (notebookId: string) => {
|
||||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||||
@@ -162,10 +209,10 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
throw new Error('Failed to delete notebook')
|
throw new Error('Failed to delete notebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
||||||
const response = await fetch('/api/notebooks/reorder', {
|
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')
|
throw new Error('Failed to update notebook order')
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
triggerNotebooksRefresh()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
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 createLabel = useCallback(async (data: CreateLabelInput) => {
|
||||||
const response = await fetch('/api/labels', {
|
const response = await fetch('/api/labels', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -196,8 +278,11 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
// Invalidate labels cache for this notebook
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.labels(data.notebookId) })
|
||||||
|
triggerRefresh()
|
||||||
return result
|
return result
|
||||||
}, [])
|
}, [queryClient, triggerRefresh])
|
||||||
|
|
||||||
const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
|
const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
|
||||||
const response = await fetch(`/api/labels/${labelId}`, {
|
const response = await fetch(`/api/labels/${labelId}`, {
|
||||||
@@ -209,7 +294,17 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to update label')
|
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 deleteLabel = useCallback(async (labelId: string) => {
|
||||||
const response = await fetch(`/api/labels/${labelId}`, {
|
const response = await fetch(`/api/labels/${labelId}`, {
|
||||||
@@ -219,23 +314,30 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete label')
|
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 =====
|
// ===== ACTIONS: NOTES =====
|
||||||
const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, notebookId: string | null) => {
|
const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, targetNotebookId: string | null) => {
|
||||||
setIsMovingNote(true)
|
setIsMovingNote(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/notes/${noteId}/move`, {
|
const response = await fetch(`/api/notes/${noteId}/move`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ notebookId }),
|
body: JSON.stringify({ notebookId: targetNotebookId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to move note')
|
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()
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -244,7 +346,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
} finally {
|
} finally {
|
||||||
setIsMovingNote(false)
|
setIsMovingNote(false)
|
||||||
}
|
}
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [queryClient, triggerRefresh, triggerNotebooksRefresh])
|
||||||
|
|
||||||
// ===== ACTIONS: AI (STUBS) =====
|
// ===== ACTIONS: AI (STUBS) =====
|
||||||
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
|
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
|
||||||
@@ -265,6 +367,13 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
isLoading,
|
isLoading,
|
||||||
isMovingNote,
|
isMovingNote,
|
||||||
error,
|
error,
|
||||||
|
labels,
|
||||||
|
loading,
|
||||||
|
notebookId,
|
||||||
|
setNotebookId,
|
||||||
|
addLabel,
|
||||||
|
refreshLabels,
|
||||||
|
getLabelColor,
|
||||||
createNotebookOptimistic,
|
createNotebookOptimistic,
|
||||||
updateNotebook,
|
updateNotebook,
|
||||||
deleteNotebook,
|
deleteNotebook,
|
||||||
@@ -284,6 +393,12 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
isLoading,
|
isLoading,
|
||||||
isMovingNote,
|
isMovingNote,
|
||||||
error,
|
error,
|
||||||
|
labels,
|
||||||
|
loading,
|
||||||
|
notebookId,
|
||||||
|
addLabel,
|
||||||
|
refreshLabels,
|
||||||
|
getLabelColor,
|
||||||
createNotebookOptimistic,
|
createNotebookOptimistic,
|
||||||
updateNotebook,
|
updateNotebook,
|
||||||
deleteNotebook,
|
deleteNotebook,
|
||||||
@@ -302,4 +417,4 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
{children}
|
{children}
|
||||||
</NotebooksContext.Provider>
|
</NotebooksContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
77
memento-note/lib/query-hooks.ts
Normal file
77
memento-note/lib/query-hooks.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
|
||||||
|
import { queryKeys } from './query-keys'
|
||||||
|
import type { Note, Notebook, Label } from '@/lib/types'
|
||||||
|
|
||||||
|
// Re-export query keys
|
||||||
|
export { queryKeys }
|
||||||
|
|
||||||
|
// ===== useNotes =====
|
||||||
|
export function useNotes(notebookId?: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.notes(notebookId),
|
||||||
|
queryFn: async (): Promise<Note[]> => {
|
||||||
|
const url = notebookId ? `/api/notes?notebookId=${notebookId}` : '/api/notes'
|
||||||
|
const res = await fetch(url, { cache: 'no-store', credentials: 'include' })
|
||||||
|
const data = await res.json()
|
||||||
|
return data.notes || []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== useNote =====
|
||||||
|
export function useNote(noteId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.note(noteId),
|
||||||
|
queryFn: async (): Promise<Note> => {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}`, { cache: 'no-store', credentials: 'include' })
|
||||||
|
const data = await res.json()
|
||||||
|
return data.note || data
|
||||||
|
},
|
||||||
|
enabled: !!noteId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== useNotebooks =====
|
||||||
|
export function useNotebooksQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.notebooks(),
|
||||||
|
queryFn: async (): Promise<Notebook[]> => {
|
||||||
|
const res = await fetch('/api/notebooks', { cache: 'no-store', credentials: 'include' })
|
||||||
|
const data = await res.json()
|
||||||
|
return data.notebooks || []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== useLabels =====
|
||||||
|
export function useLabelsQuery(notebookId?: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.labels(notebookId),
|
||||||
|
queryFn: async (): Promise<Label[]> => {
|
||||||
|
const url = new URL('/api/labels', window.location.origin)
|
||||||
|
if (notebookId) url.searchParams.set('notebookId', notebookId)
|
||||||
|
const res = await fetch(url.toString(), { cache: 'no-store', credentials: 'include' })
|
||||||
|
const data = await res.json()
|
||||||
|
return data.data || []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== invalidateHelpers =====
|
||||||
|
export function invalidateNotes(queryClient: ReturnType<typeof useQueryClient>, notebookId?: string | null) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.notes(notebookId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateNote(queryClient: ReturnType<typeof useQueryClient>, noteId: string) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.note(noteId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateNotebooks(queryClient: ReturnType<typeof useQueryClient>) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateLabels(queryClient: ReturnType<typeof useQueryClient>, notebookId?: string | null) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
|
||||||
|
}
|
||||||
23
memento-note/lib/query-keys.ts
Normal file
23
memento-note/lib/query-keys.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// React Query query keys
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
// Notes
|
||||||
|
notes: (notebookId?: string | null) => ['notes', notebookId] as const,
|
||||||
|
note: (noteId: string) => ['note', noteId] as const,
|
||||||
|
notesWithReminders: () => ['notes', 'reminders'] as const,
|
||||||
|
noteHistory: (noteId: string) => ['note', noteId, 'history'] as const,
|
||||||
|
|
||||||
|
// Notebooks
|
||||||
|
notebooks: () => ['notebooks'] as const,
|
||||||
|
notebook: (notebookId: string) => ['notebooks', notebookId] as const,
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
labels: (notebookId?: string | null) => ['labels', notebookId] as const,
|
||||||
|
|
||||||
|
// AI
|
||||||
|
aiSettings: (userId: string) => ['ai', 'settings', userId] as const,
|
||||||
|
titleSuggestions: (content: string) => ['ai', 'title-suggestions', content] as const,
|
||||||
|
autoTags: (content: string, notebookId?: string | null) => ['ai', 'auto-tags', content, notebookId] as const,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type QueryKeys = typeof queryKeys
|
||||||
46
memento-note/lib/use-refresh.ts
Normal file
46
memento-note/lib/use-refresh.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
|
import { queryKeys, invalidateNotes, invalidateNotebooks, invalidateLabels } from './query-hooks'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined refresh hook that:
|
||||||
|
* 1. Calls triggerRefresh() for backward compatibility (NoteRefreshContext)
|
||||||
|
* 2. Invalidates React Query cache for the affected keys
|
||||||
|
*
|
||||||
|
* This allows gradual migration from triggerRefresh to pure React Query.
|
||||||
|
*/
|
||||||
|
export function useRefresh() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { triggerRefresh, triggerNotebooksRefresh } = useNoteRefresh()
|
||||||
|
|
||||||
|
const refreshNotes = useCallback((notebookId?: string | null) => {
|
||||||
|
// Invalidate React Query cache
|
||||||
|
invalidateNotes(queryClient, notebookId)
|
||||||
|
// Trigger old refresh mechanism
|
||||||
|
triggerRefresh()
|
||||||
|
}, [queryClient, triggerRefresh])
|
||||||
|
|
||||||
|
const refreshNotebooks = useCallback(() => {
|
||||||
|
// Invalidate React Query cache
|
||||||
|
invalidateNotebooks(queryClient)
|
||||||
|
// Trigger old refresh mechanism
|
||||||
|
triggerNotebooksRefresh()
|
||||||
|
}, [queryClient, triggerNotebooksRefresh])
|
||||||
|
|
||||||
|
const refreshLabels = useCallback((notebookId?: string | null) => {
|
||||||
|
invalidateLabels(queryClient, notebookId)
|
||||||
|
triggerRefresh() // Labels affect note display too
|
||||||
|
}, [queryClient, triggerRefresh])
|
||||||
|
|
||||||
|
return {
|
||||||
|
refreshNotes,
|
||||||
|
refreshNotebooks,
|
||||||
|
refreshLabels,
|
||||||
|
// For direct query invalidation without triggering refresh
|
||||||
|
queryClient,
|
||||||
|
queryKeys,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"test:migration:watch": "vitest watch tests/migration"
|
"test:migration:watch": "vitest watch tests/migration"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^3.0.76",
|
||||||
"@ai-sdk/openai": "^3.0.7",
|
"@ai-sdk/openai": "^3.0.7",
|
||||||
"@ai-sdk/react": "^3.0.170",
|
"@ai-sdk/react": "^3.0.170",
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-query": "^5.100.9",
|
||||||
"@tiptap/extension-color": "^3.22.5",
|
"@tiptap/extension-color": "^3.22.5",
|
||||||
"@tiptap/extension-highlight": "^3.22.5",
|
"@tiptap/extension-highlight": "^3.22.5",
|
||||||
"@tiptap/extension-image": "^3.22.5",
|
"@tiptap/extension-image": "^3.22.5",
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ model Note {
|
|||||||
checkItems String?
|
checkItems String?
|
||||||
labels String?
|
labels String?
|
||||||
images String?
|
images String?
|
||||||
|
/// Illustration SVG (sanitized) for editorial feed thumbnail — optional, peut être généré par IA
|
||||||
|
illustrationSvg String?
|
||||||
links String?
|
links String?
|
||||||
reminder DateTime?
|
reminder DateTime?
|
||||||
isReminderDone Boolean @default(false)
|
isReminderDone Boolean @default(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user