diff --git a/keep-notes/app/(main)/page.tsx b/keep-notes/app/(main)/page.tsx
index e37f1f9..a714aca 100644
--- a/keep-notes/app/(main)/page.tsx
+++ b/keep-notes/app/(main)/page.tsx
@@ -154,15 +154,19 @@ export default function HomePage() {
// 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
try {
const settings = await getAISettings()
+ if (cancelled) return
showRecent = settings?.showRecentNotes !== false
- } catch (error) {
+ } catch {
// Default to true on error
}
+ if (cancelled) return
setShowRecentNotes(showRecent)
// Then load notes
@@ -174,12 +178,12 @@ export default function HomePage() {
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 {
- // If no notebook selected, only show notes without notebook (Notes générales)
allNotes = allNotes.filter((note: any) => !note.notebookId)
}
@@ -190,9 +194,7 @@ export default function HomePage() {
)
}
- // Filter by color (filter notes that have labels with this color)
- // Note: We use a ref-like pattern to avoid including labels in dependencies
- // This prevents dialog closing when adding new labels
+ // Filter by color
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
@@ -226,7 +228,6 @@ export default function HomePage() {
if (notebookFilter) {
setRecentNotes(recentFiltered.filter((note: any) => note.notebookId === notebookFilter))
} else {
- // Show ALL recent notes when in inbox (not just notes without notebooks)
setRecentNotes(recentFiltered)
}
} else {
@@ -238,6 +239,7 @@ export default function HomePage() {
}
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
diff --git a/keep-notes/app/(main)/settings/page.tsx b/keep-notes/app/(main)/settings/page.tsx
index c628060..6dfac31 100644
--- a/keep-notes/app/(main)/settings/page.tsx
+++ b/keep-notes/app/(main)/settings/page.tsx
@@ -1,16 +1,24 @@
'use client'
import React, { useState } from 'react'
-import { SettingsNav, SettingsSection } from '@/components/settings'
+import { useRouter } from 'next/navigation'
+import { SettingsSection } from '@/components/settings'
import { Button } from '@/components/ui/button'
import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react'
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import Link from 'next/link'
+import { useLabels } from '@/context/LabelContext'
+import { useNotebooks } from '@/context/notebooks-context'
+import { useNoteRefresh } from '@/context/NoteRefreshContext'
export default function SettingsPage() {
const { t } = useLanguage()
+ const router = useRouter()
+ const { refreshLabels } = useLabels()
+ const { refreshNotebooks } = useNotebooks()
+ const { triggerRefresh } = useNoteRefresh()
const [loading, setLoading] = useState(false)
const [cleanupLoading, setCleanupLoading] = useState(false)
const [syncLoading, setSyncLoading] = useState(false)
@@ -52,11 +60,13 @@ export default function SettingsPage() {
try {
const result = await syncAllEmbeddings()
if (result.success) {
- toast.success(`Indexing complete: ${result.count} notes processed`)
+ toast.success(t('settings.indexingComplete', { count: result.count ?? 0 }))
+ triggerRefresh()
+ router.refresh()
}
} catch (error) {
console.error(error)
- toast.error("Error during indexing")
+ toast.error(t('settings.indexingError'))
} finally {
setSyncLoading(false)
}
@@ -67,11 +77,26 @@ export default function SettingsPage() {
try {
const result = await cleanupAllOrphans()
if (result.success) {
- toast.success(result.message || `Cleanup complete: ${result.created} created, ${result.deleted} removed`)
+ const errCount = Array.isArray(result.errors) ? result.errors.length : 0
+ if (result.created === 0 && result.deleted === 0 && errCount === 0) {
+ toast.info(t('settings.cleanupNothing'))
+ } else {
+ const base = t('settings.cleanupDone', {
+ created: result.created ?? 0,
+ deleted: result.deleted ?? 0,
+ })
+ toast.success(errCount > 0 ? `${base} (${t('settings.cleanupWithErrors')})` : base)
+ }
+ await refreshLabels()
+ await refreshNotebooks()
+ triggerRefresh()
+ router.refresh()
+ } else {
+ toast.error(t('settings.cleanupError'))
}
} catch (error) {
console.error(error)
- toast.error("Error during cleanup")
+ toast.error(t('settings.cleanupError'))
} finally {
setCleanupLoading(false)
}
@@ -82,7 +107,7 @@ export default function SettingsPage() {
{t('settings.title')}
-
+
{t('settings.description')}
@@ -176,31 +201,31 @@ export default function SettingsPage() {
description={t('settings.maintenanceDescription')}
>
-
-
+
+
{t('settings.cleanTags')}
-
+
{t('settings.cleanTagsDescription')}
-
-
-
+
+
{t('settings.semanticIndexing')}
-
+
{t('settings.semanticIndexingDescription')}
-
+
{syncLoading ? : }
{t('general.indexAll')}
diff --git a/keep-notes/app/actions/notes.ts b/keep-notes/app/actions/notes.ts
index e10ae5c..bc1fd01 100644
--- a/keep-notes/app/actions/notes.ts
+++ b/keep-notes/app/actions/notes.ts
@@ -37,90 +37,108 @@ function getHashColor(name: string): string {
return colors[Math.abs(hash) % colors.length]
}
-// Comprehensive sync function for labels - ensures consistency between Note.labels and Label table
-async function syncLabels(userId: string, noteLabels: string[] = []) {
+/** Clé stable (carnet + nom) : les étiquettes sont uniques par (notebookId, name) côté Prisma */
+function labelScopeKey(notebookId: string | null | undefined, rawName: string): string {
+ const name = rawName.trim().toLowerCase()
+ if (!name) return ''
+ const nb = notebookId ?? ''
+ return `${nb}\u0000${name}`
+}
+
+function collectLabelNamesFromNote(note: {
+ labels: string | null
+ labelRelations?: { name: string }[]
+}): string[] {
+ const names: string[] = []
+ if (note.labels) {
+ try {
+ const parsed: unknown = JSON.parse(note.labels)
+ if (Array.isArray(parsed)) {
+ for (const l of parsed) {
+ if (typeof l === 'string' && l.trim()) names.push(l.trim())
+ }
+ }
+ } catch (e) {
+ console.error('[SYNC] Failed to parse labels:', e)
+ }
+ }
+ for (const rel of note.labelRelations ?? []) {
+ if (rel.name?.trim()) names.push(rel.name.trim())
+ }
+ return names
+}
+
+/**
+ * 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).
+ */
+async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) {
try {
- // Step 1: Create Label records for any labels in notes that don't exist in Label table
- // Get all existing labels for this user to do case-insensitive check in JS
- const existingLabels = await prisma.label.findMany({
- where: { userId },
- select: { id: true, name: true }
- })
+ const nbScope = notebookId ?? null
- // Create a map for case-insensitive lookup
- const existingLabelMap = new Map()
- existingLabels.forEach(label => {
- existingLabelMap.set(label.name.toLowerCase(), label.name)
- })
-
- for (const labelName of noteLabels) {
- if (!labelName || labelName.trim() === '') continue
-
- const trimmedLabel = labelName.trim()
- const lowerLabel = trimmedLabel.toLowerCase()
-
- // Check if label already exists (case-insensitive)
- const existingName = existingLabelMap.get(lowerLabel)
-
- // If label doesn't exist, create it
- if (!existingName) {
+ 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()
+ )
+ if (exists) continue
try {
- await prisma.label.create({
+ const created = await prisma.label.create({
data: {
userId,
- name: trimmedLabel,
- color: getHashColor(trimmedLabel)
- }
+ name: trimmed,
+ color: getHashColor(trimmed),
+ notebookId: nbScope,
+ },
})
- // Add to map to prevent duplicates in same batch
- existingLabelMap.set(lowerLabel, trimmedLabel)
+ scoped.push(created)
} catch (e: any) {
- // Ignore unique constraint violations (race condition)
if (e.code !== 'P2002') {
- console.error(`[SYNC] Failed to create label "${trimmedLabel}":`, e)
+ console.error(`[SYNC] Failed to create label "${trimmed}":`, e)
}
+ scoped = await prisma.label.findMany({
+ where: { userId },
+ select: { id: true, name: true, notebookId: true },
+ })
}
}
}
- // Step 2: Get ALL labels currently used in ALL user's notes
const allNotes = await prisma.note.findMany({
where: { userId },
- select: { labels: true }
+ select: {
+ notebookId: true,
+ labels: true,
+ labelRelations: { select: { name: true } },
+ },
})
const usedLabelsSet = new Set()
- allNotes.forEach(note => {
- if (note.labels) {
- try {
- const parsedLabels: string[] = JSON.parse(note.labels)
- if (Array.isArray(parsedLabels)) {
- parsedLabels.forEach(l => {
- if (l && l.trim()) {
- usedLabelsSet.add(l.trim().toLowerCase())
- }
- })
- }
- } catch (e) {
- console.error('[SYNC] Failed to parse labels:', e)
- }
+ for (const note of allNotes) {
+ for (const name of collectLabelNamesFromNote(note)) {
+ const key = labelScopeKey(note.notebookId, name)
+ if (key) usedLabelsSet.add(key)
}
- })
-
- // Step 3: Delete orphan Label records (labels not in any note)
- const allLabels = await prisma.label.findMany({
- where: { userId }
- })
+ }
+ const allLabels = await prisma.label.findMany({ where: { userId } })
for (const label of allLabels) {
- if (!usedLabelsSet.has(label.name.toLowerCase())) {
- try {
- await prisma.label.delete({
- where: { id: label.id }
- })
- } catch (e) {
- console.error(`Failed to delete orphan label:`, e)
- }
+ 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)
}
}
} catch (error) {
@@ -128,6 +146,29 @@ async function syncLabels(userId: string, noteLabels: string[] = []) {
}
}
+/** Après déplacement via API : rattacher les étiquettes de la note au bon carnet */
+export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId: string | null) {
+ const session = await auth()
+ if (!session?.user?.id) return
+ const note = await prisma.note.findFirst({
+ where: { id: noteId, userId: session.user.id },
+ select: { labels: true },
+ })
+ if (!note) return
+ let labels: string[] = []
+ if (note.labels) {
+ try {
+ const raw = JSON.parse(note.labels) as unknown
+ if (Array.isArray(raw)) {
+ labels = raw.filter((x): x is string => typeof x === 'string')
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ await syncLabels(session.user.id, labels, newNotebookId)
+}
+
// Get all notes (non-archived by default)
export async function getNotes(includeArchived = false) {
const session = await auth();
@@ -375,9 +416,9 @@ export async function createNote(data: {
}
})
- // Sync user-provided labels immediately
+ // Sync user-provided labels immediately (étiquettes rattachées au carnet de la note)
if (data.labels && data.labels.length > 0) {
- await syncLabels(session.user.id, data.labels)
+ await syncLabels(session.user.id, data.labels, data.notebookId ?? null)
}
// Revalidate main page (handles both inbox and notebook views via query params)
@@ -428,7 +469,7 @@ export async function createNote(data: {
where: { id: noteId },
data: { labels: JSON.stringify(appliedLabels) }
})
- await syncLabels(userId, appliedLabels)
+ await syncLabels(userId, appliedLabels, notebookId ?? null)
revalidatePath('/')
}
}
@@ -526,10 +567,14 @@ export async function updateNote(id: string, data: {
data: updateData
})
- // Sync labels to ensure consistency between Note.labels and Label table
- // This handles both creating new Label records and cleaning up orphans
- if (data.labels !== undefined) {
- await syncLabels(session.user.id, data.labels || [])
+ // Sync Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet
+ const notebookMoved =
+ data.notebookId !== undefined && data.notebookId !== oldNotebookId
+ if (data.labels !== undefined || notebookMoved) {
+ const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels
+ const effectiveNotebookId =
+ data.notebookId !== undefined ? data.notebookId : oldNotebookId
+ await syncLabels(session.user.id, labelsToSync, effectiveNotebookId ?? null)
}
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
@@ -667,7 +712,7 @@ export async function updateFullOrderWithoutRevalidation(ids: string[]) {
}
}
-// Maintenance - Sync all labels and clean up orphans
+// Maintenance - Sync all labels and clean up orphans (par carnet, aligné sur syncLabels)
export async function cleanupAllOrphans() {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
@@ -676,82 +721,84 @@ export async function cleanupAllOrphans() {
let deletedCount = 0;
let errors: any[] = [];
try {
- // Step 1: Get all labels from notes
- const allNotes = await prisma.note.findMany({ where: { userId }, select: { labels: true } })
- const allNoteLabels = new Set();
- allNotes.forEach(note => {
- if (note.labels) {
- try {
- const parsedLabels: string[] = JSON.parse(note.labels);
- if (Array.isArray(parsedLabels)) {
- parsedLabels.forEach(l => {
- if (l && l.trim()) allNoteLabels.add(l.trim());
- });
- }
- } catch (e) {
- console.error('[CLEANUP] Failed to parse labels:', e);
- }
- }
- });
-
- // Step 2: Get existing labels for case-insensitive comparison
- const existingLabels = await prisma.label.findMany({
+ const allNotes = await prisma.note.findMany({
where: { userId },
- select: { id: true, name: true }
- })
- const existingLabelMap = new Map()
- existingLabels.forEach(label => {
- existingLabelMap.set(label.name.toLowerCase(), label.name)
+ select: {
+ notebookId: true,
+ labels: true,
+ labelRelations: { select: { name: true } },
+ },
})
- // Step 3: Create missing Label records
- for (const labelName of allNoteLabels) {
- const lowerLabel = labelName.toLowerCase();
- const existingName = existingLabelMap.get(lowerLabel);
+ const usedSet = new Set()
+ for (const note of allNotes) {
+ for (const name of collectLabelNamesFromNote(note)) {
+ const key = labelScopeKey(note.notebookId, name)
+ if (key) usedSet.add(key)
+ }
+ }
- if (!existingName) {
+ let allScoped = await prisma.label.findMany({
+ where: { userId },
+ select: { id: true, name: true, notebookId: true },
+ })
+
+ const ensuredPairs = new Set()
+ for (const note of allNotes) {
+ for (const name of collectLabelNamesFromNote(note)) {
+ const key = labelScopeKey(note.notebookId, name)
+ if (!key || ensuredPairs.has(key)) continue
+ ensuredPairs.add(key)
+ const trimmed = name.trim()
+ const nb = note.notebookId ?? null
+ const exists = allScoped.some(
+ l => (l.notebookId ?? null) === nb && l.name.toLowerCase() === trimmed.toLowerCase()
+ )
+ if (exists) continue
try {
- await prisma.label.create({
+ const created = await prisma.label.create({
data: {
userId,
- name: labelName,
- color: getHashColor(labelName)
- }
- });
- createdCount++;
- existingLabelMap.set(lowerLabel, labelName);
+ name: trimmed,
+ color: getHashColor(trimmed),
+ notebookId: nb,
+ },
+ })
+ allScoped.push(created)
+ createdCount++
} catch (e: any) {
- console.error(`Failed to create label:`, e);
- errors.push({ label: labelName, error: e.message, code: e.code });
- // Continue with next label
+ console.error(`Failed to create label:`, e)
+ errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code })
+ allScoped = await prisma.label.findMany({
+ where: { userId },
+ select: { id: true, name: true, notebookId: true },
+ })
}
}
}
- // Step 4: Delete orphan Label records
- const allDefinedLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } })
- const usedLabelsSet = new Set();
- allNotes.forEach(note => {
- if (note.labels) {
- try {
- const parsedLabels: string[] = JSON.parse(note.labels);
- if (Array.isArray(parsedLabels)) parsedLabels.forEach(l => usedLabelsSet.add(l.toLowerCase()));
- } catch (e) {
- console.error('Failed to parse labels for orphan check:', e);
- }
- }
- });
- const orphans = allDefinedLabels.filter(label => !usedLabelsSet.has(label.name.toLowerCase()));
- for (const orphan of orphans) {
+ allScoped = await prisma.label.findMany({
+ where: { userId },
+ select: { id: true, name: true, notebookId: true },
+ })
+ for (const label of allScoped) {
+ const key = labelScopeKey(label.notebookId, label.name)
+ if (!key || usedSet.has(key)) continue
try {
- await prisma.label.delete({ where: { id: orphan.id } });
- deletedCount++;
- } catch (e) {
- console.error(`Failed to delete orphan:`, e);
+ await prisma.label.update({
+ where: { id: label.id },
+ data: { notes: { set: [] } },
+ })
+ await prisma.label.delete({ where: { id: label.id } })
+ deletedCount++
+ } catch (e: any) {
+ console.error(`Failed to delete orphan ${label.id}:`, e)
+ errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code })
}
}
revalidatePath('/')
+ revalidatePath('/settings')
return {
success: true,
created: createdCount,
diff --git a/keep-notes/app/api/ai/batch-organize/route.ts b/keep-notes/app/api/ai/batch-organize/route.ts
index 0b8118a..30f476a 100644
--- a/keep-notes/app/api/ai/batch-organize/route.ts
+++ b/keep-notes/app/api/ai/batch-organize/route.ts
@@ -42,6 +42,7 @@ export async function POST(request: NextRequest) {
data: plan,
})
} catch (error) {
+ console.error('[batch-organize POST] Error:', error)
return NextResponse.json(
{
success: false,
diff --git a/keep-notes/app/api/notes/[id]/move/route.ts b/keep-notes/app/api/notes/[id]/move/route.ts
index fd627b5..fcae75f 100644
--- a/keep-notes/app/api/notes/[id]/move/route.ts
+++ b/keep-notes/app/api/notes/[id]/move/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
+import { reconcileLabelsAfterNoteMove } from '@/app/actions/notes'
// POST /api/notes/[id]/move - Move a note to a notebook (or to Inbox)
export async function POST(
@@ -60,10 +61,12 @@ export async function POST(
// Update the note's notebook
// notebookId = null or "" means move to Inbox (Notes générales)
+ const targetNotebookId = notebookId && notebookId !== '' ? notebookId : null
+
const updatedNote = await prisma.note.update({
where: { id },
data: {
- notebookId: notebookId && notebookId !== '' ? notebookId : null
+ notebookId: targetNotebookId
},
include: {
notebook: {
@@ -72,6 +75,8 @@ export async function POST(
}
})
+ await reconcileLabelsAfterNoteMove(id, targetNotebookId)
+
revalidatePath('/')
return NextResponse.json({
diff --git a/keep-notes/components/batch-organization-dialog.tsx b/keep-notes/components/batch-organization-dialog.tsx
index 5fad0f5..9e10c67 100644
--- a/keep-notes/components/batch-organization-dialog.tsx
+++ b/keep-notes/components/batch-organization-dialog.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
import { Button } from './ui/button'
import {
Dialog,
@@ -27,21 +27,24 @@ export function BatchOrganizationDialog({
onOpenChange,
onNotesMoved,
}: BatchOrganizationDialogProps) {
- const { t } = useLanguage()
+ const { t, language } = useLanguage()
const [plan, setPlan] = useState(null)
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
const [selectedNotes, setSelectedNotes] = useState>(new Set())
+ const [fetchError, setFetchError] = useState(null)
+
const fetchOrganizationPlan = async () => {
setLoading(true)
+ setFetchError(null)
try {
const response = await fetch('/api/ai/batch-organize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
- language: document.documentElement.lang || 'en'
+ language: language || 'en'
}),
})
@@ -49,32 +52,38 @@ export function BatchOrganizationDialog({
if (data.success && data.data) {
setPlan(data.data)
- // Select all notes by default
const allNoteIds = new Set()
data.data.notebooks.forEach((nb: NotebookOrganization) => {
nb.notes.forEach(note => allNoteIds.add(note.noteId))
})
setSelectedNotes(allNoteIds)
} else {
- toast.error(data.error || t('ai.batchOrganization.error'))
+ const msg = data.error || t('ai.batchOrganization.error')
+ setFetchError(msg)
+ toast.error(msg)
}
} catch (error) {
console.error('Failed to create organization plan:', error)
- toast.error(t('ai.batchOrganization.error'))
+ const msg = t('ai.batchOrganization.error')
+ setFetchError(msg)
+ toast.error(msg)
} finally {
setLoading(false)
}
}
- const handleOpenChange = (isOpen: boolean) => {
- if (!isOpen) {
- // Reset state when closing
+ useEffect(() => {
+ if (open) {
+ fetchOrganizationPlan()
+ } else {
setPlan(null)
setSelectedNotes(new Set())
- } else {
- // Fetch plan when opening
- fetchOrganizationPlan()
+ setFetchError(null)
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open])
+
+ const handleOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen)
}
@@ -173,6 +182,13 @@ export function BatchOrganizationDialog({
{t('ai.batchOrganization.analyzing')}
+ ) : fetchError ? (
+
+
{fetchError}
+
+ {t('general.tryAgain')}
+
+
) : plan ? (
{/* Summary */}
diff --git a/keep-notes/components/connections-badge.tsx b/keep-notes/components/connections-badge.tsx
index 16ca2a5..8da9f51 100644
--- a/keep-notes/components/connections-badge.tsx
+++ b/keep-notes/components/connections-badge.tsx
@@ -1,6 +1,6 @@
'use client'
-import { memo, useState, useEffect } from 'react'
+import { memo, useState, useEffect, useRef, useCallback } from 'react'
import { Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
@@ -15,45 +15,40 @@ interface ConnectionsBadgeProps {
export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
const { t } = useLanguage()
const [connectionCount, setConnectionCount] = useState
(0)
- const [isLoading, setIsLoading] = useState(false)
const [isHovered, setIsHovered] = useState(false)
- const [fetchAttempted, setFetchAttempted] = useState(false)
+ const containerRef = useRef(null)
+ const fetchedRef = useRef(false)
+
+ const fetchConnections = useCallback(async () => {
+ if (fetchedRef.current) return
+ fetchedRef.current = true
+ try {
+ const count = await getConnectionsCount(noteId)
+ setConnectionCount(count)
+ } catch {
+ setConnectionCount(0)
+ }
+ }, [noteId])
useEffect(() => {
- if (fetchAttempted) return
- setFetchAttempted(true)
+ const el = containerRef.current
+ if (!el) return
- let isMounted = true
-
- const fetchConnections = async () => {
- setIsLoading(true)
- try {
- const count = await getConnectionsCount(noteId)
- if (isMounted) {
- setConnectionCount(count)
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ fetchConnections()
+ observer.disconnect()
}
- } catch (error) {
- console.error('[ConnectionsBadge] Failed to fetch connections:', error)
- if (isMounted) {
- setConnectionCount(0)
- }
- } finally {
- if (isMounted) {
- setIsLoading(false)
- }
- }
- }
+ },
+ { rootMargin: '200px' }
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [fetchConnections])
- fetchConnections()
-
- return () => {
- isMounted = false
- }
- }, [noteId]) // eslint-disable-line react-hooks/exhaustive-deps
-
- // Don't render if no connections or still loading
- if (connectionCount === 0 || isLoading) {
- return null
+ if (connectionCount === 0) {
+ return
}
const plural = connectionCount > 1 ? 's' : ''
diff --git a/keep-notes/components/label-management-dialog.tsx b/keep-notes/components/label-management-dialog.tsx
index 9078223..e77847c 100644
--- a/keep-notes/components/label-management-dialog.tsx
+++ b/keep-notes/components/label-management-dialog.tsx
@@ -11,19 +11,27 @@ import {
DialogTitle,
DialogTrigger,
} from './ui/dialog'
-import { Badge } from './ui/badge'
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n'
-export function LabelManagementDialog() {
+export interface LabelManagementDialogProps {
+ /** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+
+export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
+ const { open, onOpenChange } = props
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
const { t } = useLanguage()
const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState(null)
+ const controlled = open !== undefined && onOpenChange !== undefined
+
const handleAddLabel = async () => {
const trimmed = newLabel.trim()
if (trimmed) {
@@ -55,13 +63,7 @@ export function LabelManagementDialog() {
}
}
- return (
-