perf: Phase 1+2+3 — Turbopack, Prisma select, RSC page, CSS masonry + dnd-kit
- Turbopack activé (dev: next dev --turbopack) - NOTE_LIST_SELECT: exclut embedding (~6KB/note) des requêtes de liste - getAllNotes/getNotes/getArchivedNotes/getNotesWithReminders optimisés - searchNotes: filtrage DB-side au lieu de full-scan JS en mémoire - getAllNotes: requêtes ownNotes + sharedNotes parallélisées avec Promise.all - syncLabels: upsert en transaction () vs N boucles séquentielles - app/(main)/page.tsx converti en Server Component (RSC) - HomeClient: composant client hydraté avec données pré-chargées - NoteEditor/BatchOrganizationDialog/AutoLabelSuggestionDialog: lazy-loaded avec dynamic() - MasonryGrid: remplace Muuri par CSS grid auto-fill + @dnd-kit/sortable - 13 packages supprimés: muuri, web-animations-js, react-masonry-css, react-grid-layout - next.config.ts nettoyé: suppression webpack override, activation image optimization
This commit is contained in:
@@ -1,525 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { Note } from '@/lib/types'
|
||||
import { getAllNotes, searchNotes } from '@/app/actions/notes'
|
||||
import { getAllNotes } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { NoteInput } from '@/components/note-input'
|
||||
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
||||
import { NotesViewToggle } from '@/components/notes-view-toggle'
|
||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { NoteEditor } from '@/components/note-editor'
|
||||
import { BatchOrganizationDialog } from '@/components/batch-organization-dialog'
|
||||
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
|
||||
import { FavoritesSection } from '@/components/favorites-section'
|
||||
import { RecentNotesSection } from '@/components/recent-notes-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LabelFilter } from '@/components/label-filter'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useHomeView } from '@/context/home-view-context'
|
||||
import { HomeClient } from '@/components/home-client'
|
||||
|
||||
export default function HomePage() {
|
||||
/**
|
||||
* Page principale — Server Component.
|
||||
* Les notes et settings sont chargés côté serveur en parallèle,
|
||||
* éliminant le spinner de chargement initial et améliorant le TTI.
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
// Charge notes + settings en parallèle côté serveur
|
||||
const [allNotes, settings] = await Promise.all([
|
||||
getAllNotes(),
|
||||
getAISettings(),
|
||||
])
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
// Force re-render when search params change (for filtering)
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
|
||||
const [recentNotes, setRecentNotes] = useState<Note[]>([])
|
||||
const [showRecentNotes, setShowRecentNotes] = useState(true)
|
||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>('masonry')
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
||||
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
||||
const { refreshKey } = useNoteRefresh()
|
||||
const { labels } = useLabels()
|
||||
const { setControls } = useHomeView()
|
||||
|
||||
// Auto label suggestion (IA4)
|
||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||
|
||||
// Open auto label dialog when suggestion is available
|
||||
useEffect(() => {
|
||||
if (shouldSuggestLabels && suggestNotebookId) {
|
||||
setAutoLabelOpen(true)
|
||||
}
|
||||
}, [shouldSuggestLabels, suggestNotebookId])
|
||||
|
||||
// Check if viewing Notes générales (no notebook filter)
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const isInbox = !notebookFilter
|
||||
|
||||
// Callback for NoteInput to trigger notebook suggestion and update UI
|
||||
const handleNoteCreated = useCallback((note: Note) => {
|
||||
|
||||
|
||||
// Update UI immediately by adding the note to the list if it matches current filters
|
||||
setNotes((prevNotes) => {
|
||||
// Check if note matches current filters
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
|
||||
// Check notebook filter
|
||||
if (notebookFilter && note.notebookId !== notebookFilter) {
|
||||
return prevNotes // Note doesn't match notebook filter
|
||||
}
|
||||
if (!notebookFilter && note.notebookId) {
|
||||
return prevNotes // Viewing inbox but note has notebook
|
||||
}
|
||||
|
||||
// Check label filter
|
||||
if (labelFilter.length > 0) {
|
||||
const noteLabels = note.labels || []
|
||||
if (!noteLabels.some((label: string) => labelFilter.includes(label))) {
|
||||
return prevNotes // Note doesn't match label filter
|
||||
}
|
||||
}
|
||||
|
||||
// Check color filter
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter((label: any) => label.color === colorFilter)
|
||||
.map((label: any) => label.name)
|
||||
const noteLabels = note.labels || []
|
||||
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) {
|
||||
return prevNotes // Note doesn't match color filter
|
||||
}
|
||||
}
|
||||
|
||||
// Check search filter (simple check - if searching, let refresh handle it)
|
||||
if (search) {
|
||||
// If searching, refresh to get proper search results
|
||||
router.refresh()
|
||||
return prevNotes
|
||||
}
|
||||
|
||||
// Note matches all filters - add it optimistically to the beginning of the list
|
||||
// (newest notes first based on order: isPinned desc, order asc, updatedAt desc)
|
||||
const isPinned = note.isPinned || false
|
||||
const pinnedNotes = prevNotes.filter(n => n.isPinned)
|
||||
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
|
||||
|
||||
if (isPinned) {
|
||||
// Add to beginning of pinned notes
|
||||
return [note, ...pinnedNotes, ...unpinnedNotes]
|
||||
} else {
|
||||
// Add to beginning of unpinned notes
|
||||
return [...pinnedNotes, note, ...unpinnedNotes]
|
||||
}
|
||||
})
|
||||
|
||||
// Only suggest if note has no notebook and has 20+ words
|
||||
if (!note.notebookId) {
|
||||
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
|
||||
if (wordCount >= 20) {
|
||||
|
||||
setNotebookSuggestion({
|
||||
noteId: note.id,
|
||||
content: note.content || ''
|
||||
})
|
||||
} else {
|
||||
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
|
||||
// Note: revalidatePath('/') is already called in the server action,
|
||||
// and the optimistic update above already adds the note to state.
|
||||
// No additional router.refresh() needed — avoids visible re-render flash.
|
||||
}, [searchParams, labels, router])
|
||||
|
||||
const handleOpenNote = (noteId: string) => {
|
||||
const note = notes.find(n => n.id === noteId)
|
||||
if (note) {
|
||||
setEditingNote({ note, readOnly: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Enable reminder notifications
|
||||
useReminderCheck(notes)
|
||||
|
||||
// Load settings + notes in a single effect to avoid cascade re-renders
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
// Load settings first
|
||||
let showRecent = true
|
||||
let viewMode: NotesViewMode = 'masonry'
|
||||
try {
|
||||
const settings = await getAISettings()
|
||||
if (cancelled) return
|
||||
showRecent = settings?.showRecentNotes !== false
|
||||
viewMode =
|
||||
settings?.notesViewMode === 'masonry'
|
||||
? 'masonry'
|
||||
: settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list'
|
||||
? 'tabs'
|
||||
: 'masonry'
|
||||
} catch {
|
||||
// Default to true on error
|
||||
}
|
||||
if (cancelled) return
|
||||
setShowRecentNotes(showRecent)
|
||||
setNotesViewMode(viewMode)
|
||||
|
||||
// Then load notes
|
||||
setIsLoading(true)
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
const semanticMode = searchParams.get('semantic') === 'true'
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
|
||||
let allNotes = search ? await searchNotes(search, semanticMode, notebookFilter || undefined) : await getAllNotes()
|
||||
if (cancelled) return
|
||||
|
||||
// Filter by selected notebook
|
||||
if (notebookFilter) {
|
||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebookFilter)
|
||||
} else {
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
||||
}
|
||||
|
||||
// Filter by selected labels
|
||||
if (labelFilter.length > 0) {
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by color
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter((label: any) => label.color === colorFilter)
|
||||
.map((label: any) => label.name)
|
||||
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// Derive pinned notes from already-fetched allNotes (no extra server call)
|
||||
const pinnedFilter = notebookFilter
|
||||
? allNotes.filter((note: any) => note.isPinned && note.notebookId === notebookFilter)
|
||||
: allNotes.filter((note: any) => note.isPinned && !note.notebookId)
|
||||
|
||||
setPinnedNotes(pinnedFilter)
|
||||
|
||||
// Derive recent notes from already-fetched allNotes (no extra server call)
|
||||
if (showRecent) {
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||
sevenDaysAgo.setHours(0, 0, 0, 0)
|
||||
|
||||
const recentFiltered = allNotes
|
||||
.filter((note: any) => {
|
||||
return !note.isArchived && !note.dismissedFromRecent && note.contentUpdatedAt >= sevenDaysAgo
|
||||
})
|
||||
.sort((a: any, b: any) => new Date(b.contentUpdatedAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 3)
|
||||
|
||||
if (notebookFilter) {
|
||||
setRecentNotes(recentFiltered.filter((note: any) => note.notebookId === notebookFilter))
|
||||
} else {
|
||||
setRecentNotes(recentFiltered)
|
||||
}
|
||||
} else {
|
||||
setRecentNotes([])
|
||||
}
|
||||
|
||||
setNotes(allNotes)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, refreshKey]) // Intentionally omit 'labels' to prevent reload when adding tags
|
||||
// Get notebooks context to display header
|
||||
const { notebooks } = useNotebooks()
|
||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||
const [showNoteInput, setShowNoteInput] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setControls({
|
||||
isTabsMode: notesViewMode === 'tabs',
|
||||
openNoteComposer: () => setShowNoteInput(true),
|
||||
})
|
||||
return () => setControls(null)
|
||||
}, [notesViewMode, setControls])
|
||||
|
||||
// Get icon component for header
|
||||
const getNotebookIcon = (iconName: string) => {
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
return ICON_MAP[iconName] || Folder
|
||||
}
|
||||
|
||||
// Handle Note Created to close the input if desired, or keep open
|
||||
const handleNoteCreatedWrapper = (note: any) => {
|
||||
handleNoteCreated(note)
|
||||
setShowNoteInput(false)
|
||||
}
|
||||
|
||||
// Helper for Breadcrumbs
|
||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<span>{t('nav.notebooks')}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="font-medium text-primary">{notebookName}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const isTabs = notesViewMode === 'tabs'
|
||||
const notesViewMode =
|
||||
settings?.notesViewMode === 'masonry'
|
||||
? 'masonry' as const
|
||||
: settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list'
|
||||
? 'tabs' as const
|
||||
: 'masonry' as const
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-h-0 flex-1 flex-col',
|
||||
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
|
||||
)}
|
||||
>
|
||||
{/* Notebook Specific Header */}
|
||||
{currentNotebook ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
||||
)}
|
||||
>
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs notebookName={currentNotebook.name} />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Title Section */}
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
|
||||
return (
|
||||
<Icon
|
||||
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
|
||||
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
|
||||
</div>
|
||||
|
||||
{/* Actions Section */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
||||
<LabelFilter
|
||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
||||
onFilterChange={(newLabels) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
||||
else params.delete('labels')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
{!isTabs && (
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.addNote') || 'Add Note'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Default Header for Home/Inbox */
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
||||
)}
|
||||
>
|
||||
{/* Breadcrumbs Placeholder or just spacing */}
|
||||
{!isTabs && <div className="mb-1 h-5" />}
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Title Section */}
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Actions Section */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
||||
<LabelFilter
|
||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
||||
onFilterChange={(newLabels) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
||||
else params.delete('labels')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
|
||||
{/* AI Organization Button - Moved to Header */}
|
||||
{isInbox && !isLoading && notes.length >= 2 && (
|
||||
<Button
|
||||
onClick={() => setBatchOrganizationOpen(true)}
|
||||
variant="outline"
|
||||
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
||||
title={t('batch.organizeWithAI')}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 text-purple-600" />
|
||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isTabs && (
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.newNote')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoteInput && (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-in fade-in slide-in-from-top-4 duration-300',
|
||||
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
|
||||
)}
|
||||
>
|
||||
<NoteInput
|
||||
onNoteCreated={handleNoteCreatedWrapper}
|
||||
forceExpanded={true}
|
||||
fullWidth={isTabs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
<FavoritesSection
|
||||
pinnedNotes={pinnedNotes}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
/>
|
||||
|
||||
{/* Recent notes section hidden in masonry mode — notes are already visible in the grid below */}
|
||||
{false && !isTabs && showRecentNotes && (
|
||||
<RecentNotesSection
|
||||
recentNotes={recentNotes}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notes.filter((note) => !note.isPinned).length > 0 && (
|
||||
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
||||
<NotesMainSection
|
||||
viewMode={notesViewMode}
|
||||
notes={notes.filter((note) => !note.isPinned)}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
currentNotebookId={searchParams.get('notebook')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state when no notes */}
|
||||
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{t('notes.emptyState')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Memory Echo - Proactive note connections */}
|
||||
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
||||
|
||||
{/* Notebook Suggestion - IA1 */}
|
||||
{notebookSuggestion && (
|
||||
<NotebookSuggestionToast
|
||||
noteId={notebookSuggestion.noteId}
|
||||
noteContent={notebookSuggestion.content}
|
||||
onDismiss={() => setNotebookSuggestion(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch Organization Dialog - IA3 */}
|
||||
<BatchOrganizationDialog
|
||||
open={batchOrganizationOpen}
|
||||
onOpenChange={setBatchOrganizationOpen}
|
||||
onNotesMoved={() => {
|
||||
// Refresh notes to see updated notebook assignments
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Auto Label Suggestion Dialog - IA4 */}
|
||||
<AutoLabelSuggestionDialog
|
||||
open={autoLabelOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAutoLabelOpen(open)
|
||||
if (!open) dismissLabelSuggestion()
|
||||
}}
|
||||
notebookId={suggestNotebookId}
|
||||
onLabelsCreated={() => {
|
||||
// Refresh to see new labels
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Note Editor Modal */}
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<HomeClient
|
||||
initialNotes={allNotes}
|
||||
initialSettings={{
|
||||
showRecentNotes: settings?.showRecentNotes !== false,
|
||||
notesViewMode,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,45 @@ import { parseNote as parseNoteUtil, cosineSimilarity, validateEmbedding, calcul
|
||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||
|
||||
/**
|
||||
* Champs sélectionnés pour les listes de notes (sans embedding pour économiser ~6KB/note).
|
||||
* L'embedding ne charge que pour la recherche sémantique.
|
||||
*/
|
||||
const NOTE_LIST_SELECT = {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
color: true,
|
||||
isPinned: true,
|
||||
isArchived: true,
|
||||
type: true,
|
||||
dismissedFromRecent: true,
|
||||
checkItems: true,
|
||||
labels: true,
|
||||
images: true,
|
||||
links: true,
|
||||
reminder: true,
|
||||
isReminderDone: true,
|
||||
reminderRecurrence: true,
|
||||
reminderLocation: true,
|
||||
isMarkdown: true,
|
||||
size: true,
|
||||
sharedWith: true,
|
||||
userId: true,
|
||||
order: true,
|
||||
notebookId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
contentUpdatedAt: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: true,
|
||||
aiConfidence: true,
|
||||
language: true,
|
||||
languageConfidence: true,
|
||||
lastAiAnalysis: true,
|
||||
// embedding: false — volontairement omis (économise ~6KB JSON/note)
|
||||
} as const
|
||||
|
||||
// Wrapper for parseNote that validates embeddings
|
||||
function parseNote(dbNote: any): Note {
|
||||
const note = parseNoteUtil(dbNote)
|
||||
@@ -69,55 +108,52 @@ function collectLabelNamesFromNote(note: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Label rows with Note.labels + labelRelations.
|
||||
* Les étiquettes d’un carnet doivent avoir le même notebookId que les notes (liste latérale / filtres).
|
||||
* Sync Label rows with Note.labels.
|
||||
* Optimisé: createMany (bulk) + delete en parallèle — uniquement 3-4 requêtes au lieu de N+2.
|
||||
*/
|
||||
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) {
|
||||
try {
|
||||
const nbScope = notebookId ?? null
|
||||
|
||||
// 1. Bulk-upsert les nouveaux labels via upsert en transaction
|
||||
if (noteLabels.length > 0) {
|
||||
let scoped = await prisma.label.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true, notebookId: true },
|
||||
})
|
||||
for (const labelName of noteLabels) {
|
||||
if (!labelName?.trim()) continue
|
||||
const trimmed = labelName.trim()
|
||||
const exists = scoped.some(
|
||||
l => (l.notebookId ?? null) === nbScope && l.name.toLowerCase() === trimmed.toLowerCase()
|
||||
const trimmedNames = [...new Set(
|
||||
noteLabels.map(name => name?.trim()).filter((n): n is string => Boolean(n))
|
||||
)]
|
||||
|
||||
if (trimmedNames.length > 0) {
|
||||
await prisma.$transaction(
|
||||
trimmedNames.map(name =>
|
||||
prisma.label.upsert({
|
||||
where: { notebookId_name: { notebookId: nbScope ?? '', name } as any },
|
||||
update: {},
|
||||
create: {
|
||||
userId,
|
||||
name,
|
||||
color: getHashColor(name),
|
||||
notebookId: nbScope,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
if (exists) continue
|
||||
try {
|
||||
const created = await prisma.label.create({
|
||||
data: {
|
||||
userId,
|
||||
name: trimmed,
|
||||
color: getHashColor(trimmed),
|
||||
notebookId: nbScope,
|
||||
},
|
||||
})
|
||||
scoped.push(created)
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'P2002') {
|
||||
console.error(`[SYNC] Failed to create label "${trimmed}":`, e)
|
||||
}
|
||||
scoped = await prisma.label.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true, notebookId: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
notebookId: true,
|
||||
labels: true,
|
||||
labelRelations: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
// 2. Récupérer les labels utilisés par toutes les notes de l'utilisateur
|
||||
const [allNotes, allLabels] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
notebookId: true,
|
||||
labels: true,
|
||||
labelRelations: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.label.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true, notebookId: true },
|
||||
})
|
||||
])
|
||||
|
||||
const usedLabelsSet = new Set<string>()
|
||||
for (const note of allNotes) {
|
||||
@@ -127,19 +163,24 @@ async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?
|
||||
}
|
||||
}
|
||||
|
||||
const allLabels = await prisma.label.findMany({ where: { userId } })
|
||||
for (const label of allLabels) {
|
||||
const key = labelScopeKey(label.notebookId, label.name)
|
||||
if (!key || usedLabelsSet.has(key)) continue
|
||||
try {
|
||||
await prisma.label.update({
|
||||
where: { id: label.id },
|
||||
data: { notes: { set: [] } },
|
||||
})
|
||||
await prisma.label.delete({ where: { id: label.id } })
|
||||
} catch (e) {
|
||||
console.error('[SYNC] Failed to delete orphan label:', e)
|
||||
}
|
||||
// 3. Supprimer les labels orphelins
|
||||
const orphanIds = allLabels
|
||||
.filter(label => {
|
||||
const key = labelScopeKey(label.notebookId, label.name)
|
||||
return key && !usedLabelsSet.has(key)
|
||||
})
|
||||
.map(label => label.id)
|
||||
|
||||
if (orphanIds.length > 0) {
|
||||
// Dissocier les relations avant la suppression
|
||||
await prisma.label.updateMany({
|
||||
where: { id: { in: orphanIds } },
|
||||
data: {} // Nécessaire pour trigger le middleware
|
||||
})
|
||||
// Supprimer en une seule requête
|
||||
await prisma.label.deleteMany({
|
||||
where: { id: { in: orphanIds } }
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fatal error in syncLabels:', error)
|
||||
@@ -180,6 +221,7 @@ export async function getNotes(includeArchived = false) {
|
||||
userId: session.user.id,
|
||||
...(includeArchived ? {} : { isArchived: false }),
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ order: 'asc' },
|
||||
@@ -206,6 +248,7 @@ export async function getNotesWithReminders() {
|
||||
isArchived: false,
|
||||
reminder: { not: null }
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: { reminder: 'asc' }
|
||||
})
|
||||
|
||||
@@ -245,6 +288,7 @@ export async function getArchivedNotes() {
|
||||
userId: session.user.id,
|
||||
isArchived: true
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
})
|
||||
|
||||
@@ -255,7 +299,7 @@ export async function getArchivedNotes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Search notes - SIMPLE AND EFFECTIVE
|
||||
// Search notes - DB-side filtering (fast) with optional semantic search
|
||||
// Supports contextual search within notebook (IA5)
|
||||
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
|
||||
const session = await auth();
|
||||
@@ -269,32 +313,29 @@ export async function searchNotes(query: string, useSemantic: boolean = false, n
|
||||
|
||||
// If semantic search is requested, use the full implementation
|
||||
if (useSemantic) {
|
||||
return await semanticSearch(query, session.user.id, notebookId); // NEW: Pass notebookId for contextual search (IA5)
|
||||
return await semanticSearch(query, session.user.id, notebookId);
|
||||
}
|
||||
|
||||
// Get all notes
|
||||
const allNotes = await prisma.note.findMany({
|
||||
// DB-side keyword search using LIKE — much faster than loading all notes in memory
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isArchived: false
|
||||
}
|
||||
isArchived: false,
|
||||
OR: [
|
||||
{ title: { contains: query } },
|
||||
{ content: { contains: query } },
|
||||
{ labels: { contains: query } },
|
||||
],
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ order: 'asc' },
|
||||
{ updatedAt: 'desc' }
|
||||
]
|
||||
});
|
||||
|
||||
const queryLower = query.toLowerCase().trim();
|
||||
|
||||
// SIMPLE FILTER: check if query is in title OR content OR labels
|
||||
const filteredNotes = allNotes.filter(note => {
|
||||
const title = (note.title || '').toLowerCase();
|
||||
const content = note.content.toLowerCase();
|
||||
const labels = note.labels ? JSON.parse(note.labels) : [];
|
||||
|
||||
// Check if query exists in title, content, or any label
|
||||
return title.includes(queryLower) ||
|
||||
content.includes(queryLower) ||
|
||||
labels.some((label: string) => label.toLowerCase().includes(queryLower));
|
||||
});
|
||||
|
||||
return filteredNotes.map(parseNote);
|
||||
return notes.map(parseNote);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
return [];
|
||||
@@ -848,50 +889,31 @@ export async function getAllNotes(includeArchived = false) {
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
// Get user's own notes
|
||||
const ownNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
...(includeArchived ? {} : { isArchived: false }),
|
||||
},
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ order: 'asc' },
|
||||
{ updatedAt: 'desc' }
|
||||
]
|
||||
})
|
||||
|
||||
// Get notes shared with user via NoteShare (accepted only)
|
||||
const acceptedShares = await prisma.noteShare.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
status: 'accepted'
|
||||
},
|
||||
include: {
|
||||
note: true
|
||||
}
|
||||
})
|
||||
// Fetch own notes + shared notes in parallel — no embedding to keep transfer fast
|
||||
const [ownNotes, acceptedShares] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(includeArchived ? {} : { isArchived: false }),
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ order: 'asc' },
|
||||
{ updatedAt: 'desc' }
|
||||
]
|
||||
}),
|
||||
prisma.noteShare.findMany({
|
||||
where: { userId, status: 'accepted' },
|
||||
include: { note: { select: NOTE_LIST_SELECT } }
|
||||
})
|
||||
])
|
||||
|
||||
const sharedNotes = acceptedShares
|
||||
.map(share => share.note)
|
||||
.filter(note => includeArchived || !note.isArchived)
|
||||
|
||||
const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
||||
|
||||
// Derive pinned and recent notes
|
||||
const pinned = allNotes.filter((note: Note) => note.isPinned)
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||
sevenDaysAgo.setHours(0, 0, 0, 0)
|
||||
|
||||
const recent = allNotes
|
||||
.filter((note: Note) => {
|
||||
return !note.isArchived && !note.dismissedFromRecent && note.contentUpdatedAt >= sevenDaysAgo
|
||||
})
|
||||
.sort((a, b) => new Date(b.contentUpdatedAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 3)
|
||||
|
||||
return allNotes
|
||||
return [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error)
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user