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