epic-ux-design #1

Open
sepehr wants to merge 26 commits from epic-ux-design into main
16 changed files with 469 additions and 303 deletions
Showing only changes of commit 39671c6472 - Show all commits

View File

@@ -154,15 +154,19 @@ export default function HomePage() {
// Load settings + notes in a single effect to avoid cascade re-renders // Load settings + notes in a single effect to avoid cascade re-renders
useEffect(() => { useEffect(() => {
let cancelled = false
const load = async () => { const load = async () => {
// Load settings first // Load settings first
let showRecent = true let showRecent = true
try { try {
const settings = await getAISettings() const settings = await getAISettings()
if (cancelled) return
showRecent = settings?.showRecentNotes !== false showRecent = settings?.showRecentNotes !== false
} catch (error) { } catch {
// Default to true on error // Default to true on error
} }
if (cancelled) return
setShowRecentNotes(showRecent) setShowRecentNotes(showRecent)
// Then load notes // Then load notes
@@ -174,12 +178,12 @@ export default function HomePage() {
const notebookFilter = searchParams.get('notebook') const notebookFilter = searchParams.get('notebook')
let allNotes = search ? await searchNotes(search, semanticMode, notebookFilter || undefined) : await getAllNotes() let allNotes = search ? await searchNotes(search, semanticMode, notebookFilter || undefined) : await getAllNotes()
if (cancelled) return
// Filter by selected notebook // Filter by selected notebook
if (notebookFilter) { if (notebookFilter) {
allNotes = allNotes.filter((note: any) => note.notebookId === notebookFilter) allNotes = allNotes.filter((note: any) => note.notebookId === notebookFilter)
} else { } else {
// If no notebook selected, only show notes without notebook (Notes générales)
allNotes = allNotes.filter((note: any) => !note.notebookId) 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) // Filter by color
// Note: We use a ref-like pattern to avoid including labels in dependencies
// This prevents dialog closing when adding new labels
if (colorFilter) { if (colorFilter) {
const labelNamesWithColor = labels const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter) .filter((label: any) => label.color === colorFilter)
@@ -226,7 +228,6 @@ export default function HomePage() {
if (notebookFilter) { if (notebookFilter) {
setRecentNotes(recentFiltered.filter((note: any) => note.notebookId === notebookFilter)) setRecentNotes(recentFiltered.filter((note: any) => note.notebookId === notebookFilter))
} else { } else {
// Show ALL recent notes when in inbox (not just notes without notebooks)
setRecentNotes(recentFiltered) setRecentNotes(recentFiltered)
} }
} else { } else {
@@ -238,6 +239,7 @@ export default function HomePage() {
} }
load() load()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey]) // Intentionally omit 'labels' to prevent reload when adding tags }, [searchParams, refreshKey]) // Intentionally omit 'labels' to prevent reload when adding tags
// Get notebooks context to display header // Get notebooks context to display header

View File

@@ -1,16 +1,24 @@
'use client' 'use client'
import React, { useState } from 'react' 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 { Button } from '@/components/ui/button'
import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react' import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react'
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes' import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import Link from 'next/link' 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() { export default function SettingsPage() {
const { t } = useLanguage() const { t } = useLanguage()
const router = useRouter()
const { refreshLabels } = useLabels()
const { refreshNotebooks } = useNotebooks()
const { triggerRefresh } = useNoteRefresh()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [cleanupLoading, setCleanupLoading] = useState(false) const [cleanupLoading, setCleanupLoading] = useState(false)
const [syncLoading, setSyncLoading] = useState(false) const [syncLoading, setSyncLoading] = useState(false)
@@ -52,11 +60,13 @@ export default function SettingsPage() {
try { try {
const result = await syncAllEmbeddings() const result = await syncAllEmbeddings()
if (result.success) { 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) { } catch (error) {
console.error(error) console.error(error)
toast.error("Error during indexing") toast.error(t('settings.indexingError'))
} finally { } finally {
setSyncLoading(false) setSyncLoading(false)
} }
@@ -67,11 +77,26 @@ export default function SettingsPage() {
try { try {
const result = await cleanupAllOrphans() const result = await cleanupAllOrphans()
if (result.success) { 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) { } catch (error) {
console.error(error) console.error(error)
toast.error("Error during cleanup") toast.error(t('settings.cleanupError'))
} finally { } finally {
setCleanupLoading(false) setCleanupLoading(false)
} }
@@ -82,7 +107,7 @@ export default function SettingsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold mb-2">{t('settings.title')}</h1> <h1 className="text-3xl font-bold mb-2">{t('settings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-muted-foreground">
{t('settings.description')} {t('settings.description')}
</p> </p>
</div> </div>
@@ -176,31 +201,31 @@ export default function SettingsPage() {
description={t('settings.maintenanceDescription')} description={t('settings.maintenanceDescription')}
> >
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="flex items-center justify-between p-4 border rounded-lg"> <div className="flex items-center justify-between gap-4 p-4 border border-border rounded-lg bg-card">
<div> <div className="min-w-0">
<h3 className="font-medium flex items-center gap-2"> <h3 className="font-medium flex items-center gap-2">
{t('settings.cleanTags')} {t('settings.cleanTags')}
</h3> </h3>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-muted-foreground">
{t('settings.cleanTagsDescription')} {t('settings.cleanTagsDescription')}
</p> </p>
</div> </div>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}> <Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading} className="shrink-0">
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />} {cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
{t('general.clean')} {t('general.clean')}
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between p-4 border rounded-lg"> <div className="flex items-center justify-between gap-4 p-4 border border-border rounded-lg bg-card">
<div> <div className="min-w-0">
<h3 className="font-medium flex items-center gap-2"> <h3 className="font-medium flex items-center gap-2">
{t('settings.semanticIndexing')} {t('settings.semanticIndexing')}
</h3> </h3>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-muted-foreground">
{t('settings.semanticIndexingDescription')} {t('settings.semanticIndexingDescription')}
</p> </p>
</div> </div>
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}> <Button variant="secondary" onClick={handleSync} disabled={syncLoading} className="shrink-0">
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />} {syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
{t('general.indexAll')} {t('general.indexAll')}
</Button> </Button>

View File

@@ -37,90 +37,108 @@ function getHashColor(name: string): string {
return colors[Math.abs(hash) % colors.length] return colors[Math.abs(hash) % colors.length]
} }
// Comprehensive sync function for labels - ensures consistency between Note.labels and Label table /** Clé stable (carnet + nom) : les étiquettes sont uniques par (notebookId, name) côté Prisma */
async function syncLabels(userId: string, noteLabels: string[] = []) { function labelScopeKey(notebookId: string | null | undefined, rawName: string): string {
try { const name = rawName.trim().toLowerCase()
// Step 1: Create Label records for any labels in notes that don't exist in Label table if (!name) return ''
// Get all existing labels for this user to do case-insensitive check in JS const nb = notebookId ?? ''
const existingLabels = await prisma.label.findMany({ return `${nb}\u0000${name}`
where: { userId },
select: { id: true, name: true }
})
// Create a map for case-insensitive lookup
const existingLabelMap = new Map<string, string>()
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) {
try {
await prisma.label.create({
data: {
userId,
name: trimmedLabel,
color: getHashColor(trimmedLabel)
}
})
// Add to map to prevent duplicates in same batch
existingLabelMap.set(lowerLabel, trimmedLabel)
} catch (e: any) {
// Ignore unique constraint violations (race condition)
if (e.code !== 'P2002') {
console.error(`[SYNC] Failed to create label "${trimmedLabel}":`, e)
}
}
}
} }
// Step 2: Get ALL labels currently used in ALL user's notes function collectLabelNamesFromNote(note: {
const allNotes = await prisma.note.findMany({ labels: string | null
where: { userId }, labelRelations?: { name: string }[]
select: { labels: true } }): string[] {
}) const names: string[] = []
const usedLabelsSet = new Set<string>()
allNotes.forEach(note => {
if (note.labels) { if (note.labels) {
try { try {
const parsedLabels: string[] = JSON.parse(note.labels) const parsed: unknown = JSON.parse(note.labels)
if (Array.isArray(parsedLabels)) { if (Array.isArray(parsed)) {
parsedLabels.forEach(l => { for (const l of parsed) {
if (l && l.trim()) { if (typeof l === 'string' && l.trim()) names.push(l.trim())
usedLabelsSet.add(l.trim().toLowerCase())
} }
})
} }
} catch (e) { } catch (e) {
console.error('[SYNC] Failed to parse labels:', e) console.error('[SYNC] Failed to parse labels:', e)
} }
} }
}) for (const rel of note.labelRelations ?? []) {
if (rel.name?.trim()) names.push(rel.name.trim())
// Step 3: Delete orphan Label records (labels not in any note)
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)
} }
return names
}
/**
* Sync Label rows with Note.labels + labelRelations.
* Les étiquettes dun 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 {
const nbScope = notebookId ?? null
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 {
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 } },
},
})
const usedLabelsSet = new Set<string>()
for (const note of allNotes) {
for (const name of collectLabelNamesFromNote(note)) {
const key = labelScopeKey(note.notebookId, name)
if (key) usedLabelsSet.add(key)
}
}
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)
} }
} }
} catch (error) { } 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) // Get all notes (non-archived by default)
export async function getNotes(includeArchived = false) { export async function getNotes(includeArchived = false) {
const session = await auth(); 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) { 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) // Revalidate main page (handles both inbox and notebook views via query params)
@@ -428,7 +469,7 @@ export async function createNote(data: {
where: { id: noteId }, where: { id: noteId },
data: { labels: JSON.stringify(appliedLabels) } data: { labels: JSON.stringify(appliedLabels) }
}) })
await syncLabels(userId, appliedLabels) await syncLabels(userId, appliedLabels, notebookId ?? null)
revalidatePath('/') revalidatePath('/')
} }
} }
@@ -526,10 +567,14 @@ export async function updateNote(id: string, data: {
data: updateData data: updateData
}) })
// Sync labels to ensure consistency between Note.labels and Label table // Sync Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet
// This handles both creating new Label records and cleaning up orphans const notebookMoved =
if (data.labels !== undefined) { data.notebookId !== undefined && data.notebookId !== oldNotebookId
await syncLabels(session.user.id, data.labels || []) 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 // 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() { export async function cleanupAllOrphans() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized'); if (!session?.user?.id) throw new Error('Unauthorized');
@@ -676,82 +721,84 @@ export async function cleanupAllOrphans() {
let deletedCount = 0; let deletedCount = 0;
let errors: any[] = []; let errors: any[] = [];
try { try {
// Step 1: Get all labels from notes const allNotes = await prisma.note.findMany({
const allNotes = await prisma.note.findMany({ where: { userId }, select: { labels: true } })
const allNoteLabels = new Set<string>();
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({
where: { userId }, where: { userId },
select: { id: true, name: true } select: {
}) notebookId: true,
const existingLabelMap = new Map<string, string>() labels: true,
existingLabels.forEach(label => { labelRelations: { select: { name: true } },
existingLabelMap.set(label.name.toLowerCase(), label.name) },
}) })
// Step 3: Create missing Label records const usedSet = new Set<string>()
for (const labelName of allNoteLabels) { for (const note of allNotes) {
const lowerLabel = labelName.toLowerCase(); for (const name of collectLabelNamesFromNote(note)) {
const existingName = existingLabelMap.get(lowerLabel); 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<string>()
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 { try {
await prisma.label.create({ const created = await prisma.label.create({
data: { data: {
userId, userId,
name: labelName, name: trimmed,
color: getHashColor(labelName) color: getHashColor(trimmed),
} notebookId: nb,
}); },
createdCount++; })
existingLabelMap.set(lowerLabel, labelName); allScoped.push(created)
createdCount++
} catch (e: any) { } catch (e: any) {
console.error(`Failed to create label:`, e); console.error(`Failed to create label:`, e)
errors.push({ label: labelName, error: e.message, code: e.code }); errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code })
// Continue with next label allScoped = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
} }
} }
} }
// Step 4: Delete orphan Label records allScoped = await prisma.label.findMany({
const allDefinedLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } }) where: { userId },
const usedLabelsSet = new Set<string>(); select: { id: true, name: true, notebookId: true },
allNotes.forEach(note => { })
if (note.labels) { for (const label of allScoped) {
const key = labelScopeKey(label.notebookId, label.name)
if (!key || usedSet.has(key)) continue
try { try {
const parsedLabels: string[] = JSON.parse(note.labels); await prisma.label.update({
if (Array.isArray(parsedLabels)) parsedLabels.forEach(l => usedLabelsSet.add(l.toLowerCase())); where: { id: label.id },
} catch (e) { data: { notes: { set: [] } },
console.error('Failed to parse labels for orphan check:', e); })
} await prisma.label.delete({ where: { id: label.id } })
} deletedCount++
}); } catch (e: any) {
const orphans = allDefinedLabels.filter(label => !usedLabelsSet.has(label.name.toLowerCase())); console.error(`Failed to delete orphan ${label.id}:`, e)
for (const orphan of orphans) { errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code })
try {
await prisma.label.delete({ where: { id: orphan.id } });
deletedCount++;
} catch (e) {
console.error(`Failed to delete orphan:`, e);
} }
} }
revalidatePath('/') revalidatePath('/')
revalidatePath('/settings')
return { return {
success: true, success: true,
created: createdCount, created: createdCount,

View File

@@ -42,6 +42,7 @@ export async function POST(request: NextRequest) {
data: plan, data: plan,
}) })
} catch (error) { } catch (error) {
console.error('[batch-organize POST] Error:', error)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { auth } from '@/auth' import { auth } from '@/auth'
import { revalidatePath } from 'next/cache' 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) // POST /api/notes/[id]/move - Move a note to a notebook (or to Inbox)
export async function POST( export async function POST(
@@ -60,10 +61,12 @@ export async function POST(
// Update the note's notebook // Update the note's notebook
// notebookId = null or "" means move to Inbox (Notes générales) // notebookId = null or "" means move to Inbox (Notes générales)
const targetNotebookId = notebookId && notebookId !== '' ? notebookId : null
const updatedNote = await prisma.note.update({ const updatedNote = await prisma.note.update({
where: { id }, where: { id },
data: { data: {
notebookId: notebookId && notebookId !== '' ? notebookId : null notebookId: targetNotebookId
}, },
include: { include: {
notebook: { notebook: {
@@ -72,6 +75,8 @@ export async function POST(
} }
}) })
await reconcileLabelsAfterNoteMove(id, targetNotebookId)
revalidatePath('/') revalidatePath('/')
return NextResponse.json({ return NextResponse.json({

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Button } from './ui/button' import { Button } from './ui/button'
import { import {
Dialog, Dialog,
@@ -27,21 +27,24 @@ export function BatchOrganizationDialog({
onOpenChange, onOpenChange,
onNotesMoved, onNotesMoved,
}: BatchOrganizationDialogProps) { }: BatchOrganizationDialogProps) {
const { t } = useLanguage() const { t, language } = useLanguage()
const [plan, setPlan] = useState<OrganizationPlan | null>(null) const [plan, setPlan] = useState<OrganizationPlan | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
const [selectedNotes, setSelectedNotes] = useState<Set<string>>(new Set()) const [selectedNotes, setSelectedNotes] = useState<Set<string>>(new Set())
const [fetchError, setFetchError] = useState<string | null>(null)
const fetchOrganizationPlan = async () => { const fetchOrganizationPlan = async () => {
setLoading(true) setLoading(true)
setFetchError(null)
try { try {
const response = await fetch('/api/ai/batch-organize', { const response = await fetch('/api/ai/batch-organize', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
language: document.documentElement.lang || 'en' language: language || 'en'
}), }),
}) })
@@ -49,32 +52,38 @@ export function BatchOrganizationDialog({
if (data.success && data.data) { if (data.success && data.data) {
setPlan(data.data) setPlan(data.data)
// Select all notes by default
const allNoteIds = new Set<string>() const allNoteIds = new Set<string>()
data.data.notebooks.forEach((nb: NotebookOrganization) => { data.data.notebooks.forEach((nb: NotebookOrganization) => {
nb.notes.forEach(note => allNoteIds.add(note.noteId)) nb.notes.forEach(note => allNoteIds.add(note.noteId))
}) })
setSelectedNotes(allNoteIds) setSelectedNotes(allNoteIds)
} else { } else {
toast.error(data.error || t('ai.batchOrganization.error')) const msg = data.error || t('ai.batchOrganization.error')
setFetchError(msg)
toast.error(msg)
} }
} catch (error) { } catch (error) {
console.error('Failed to create organization plan:', 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 { } finally {
setLoading(false) setLoading(false)
} }
} }
const handleOpenChange = (isOpen: boolean) => { useEffect(() => {
if (!isOpen) { if (open) {
// Reset state when closing fetchOrganizationPlan()
} else {
setPlan(null) setPlan(null)
setSelectedNotes(new Set()) setSelectedNotes(new Set())
} else { setFetchError(null)
// Fetch plan when opening
fetchOrganizationPlan()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handleOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen) onOpenChange(isOpen)
} }
@@ -173,6 +182,13 @@ export function BatchOrganizationDialog({
{t('ai.batchOrganization.analyzing')} {t('ai.batchOrganization.analyzing')}
</p> </p>
</div> </div>
) : fetchError ? (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<p className="text-sm text-destructive text-center">{fetchError}</p>
<Button variant="outline" onClick={fetchOrganizationPlan}>
{t('general.tryAgain')}
</Button>
</div>
) : plan ? ( ) : plan ? (
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{/* Summary */} {/* Summary */}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useState, useEffect } from 'react' import { memo, useState, useEffect, useRef, useCallback } from 'react'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider' import { useLanguage } from '@/lib/i18n/LanguageProvider'
@@ -15,45 +15,40 @@ interface ConnectionsBadgeProps {
export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) { export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
const { t } = useLanguage() const { t } = useLanguage()
const [connectionCount, setConnectionCount] = useState<number>(0) const [connectionCount, setConnectionCount] = useState<number>(0)
const [isLoading, setIsLoading] = useState(false)
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [fetchAttempted, setFetchAttempted] = useState(false) const containerRef = useRef<HTMLSpanElement>(null)
const fetchedRef = useRef(false)
useEffect(() => { const fetchConnections = useCallback(async () => {
if (fetchAttempted) return if (fetchedRef.current) return
setFetchAttempted(true) fetchedRef.current = true
let isMounted = true
const fetchConnections = async () => {
setIsLoading(true)
try { try {
const count = await getConnectionsCount(noteId) const count = await getConnectionsCount(noteId)
if (isMounted) {
setConnectionCount(count) setConnectionCount(count)
} } catch {
} catch (error) {
console.error('[ConnectionsBadge] Failed to fetch connections:', error)
if (isMounted) {
setConnectionCount(0) setConnectionCount(0)
} }
} finally { }, [noteId])
if (isMounted) {
setIsLoading(false)
}
}
}
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
fetchConnections() fetchConnections()
observer.disconnect()
return () => {
isMounted = false
} }
}, [noteId]) // eslint-disable-line react-hooks/exhaustive-deps },
{ rootMargin: '200px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [fetchConnections])
// Don't render if no connections or still loading if (connectionCount === 0) {
if (connectionCount === 0 || isLoading) { return <span ref={containerRef} className="hidden" />
return null
} }
const plural = connectionCount > 1 ? 's' : '' const plural = connectionCount > 1 ? 's' : ''

View File

@@ -11,19 +11,27 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from './ui/dialog' } from './ui/dialog'
import { Badge } from './ui/badge'
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 { useLabels } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n' 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 { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
const { t } = useLanguage() const { t } = useLanguage()
const [newLabel, setNewLabel] = useState('') const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState<string | null>(null) const [editingColorId, setEditingColorId] = useState<string | null>(null)
const controlled = open !== undefined && onOpenChange !== undefined
const handleAddLabel = async () => { const handleAddLabel = async () => {
const trimmed = newLabel.trim() const trimmed = newLabel.trim()
if (trimmed) { if (trimmed) {
@@ -55,13 +63,7 @@ export function LabelManagementDialog() {
} }
} }
return ( const dialogContent = (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title={t('labels.manage')}>
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent <DialogContent
className="max-w-md" className="max-w-md"
onInteractOutside={(event) => { onInteractOutside={(event) => {
@@ -117,23 +119,23 @@ export function LabelManagementDialog() {
{/* List labels */} {/* List labels */}
<div className="max-h-[60vh] overflow-y-auto space-y-2"> <div className="max-h-[60vh] overflow-y-auto space-y-2">
{loading ? ( {loading ? (
<p className="text-sm text-gray-500">{t('labels.loading')}</p> <p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
) : labels.length === 0 ? ( ) : labels.length === 0 ? (
<p className="text-sm text-gray-500">{t('labels.noLabelsFound')}</p> <p className="text-sm text-muted-foreground">{t('labels.noLabelsFound')}</p>
) : ( ) : (
labels.map((label) => { labels.map((label) => {
const colorClasses = LABEL_COLORS[label.color] const colorClasses = LABEL_COLORS[label.color]
const isEditing = editingColorId === label.id const isEditing = editingColorId === label.id
return ( return (
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-gray-100 dark:hover:bg-zinc-800/50 group"> <div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
<div className="flex items-center gap-3 flex-1 relative"> <div className="flex items-center gap-3 flex-1 relative">
<Tag className={cn("h-4 w-4", colorClasses.text)} /> <Tag className={cn("h-4 w-4", colorClasses.text)} />
<span className="font-medium text-sm">{label.name}</span> <span className="font-medium text-sm">{label.name}</span>
{/* Color Picker Popover */} {/* Color Picker Popover */}
{isEditing && ( {isEditing && (
<div className="absolute z-20 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48"> <div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
<div className="grid grid-cols-5 gap-2"> <div className="grid grid-cols-5 gap-2">
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => { {(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
const classes = LABEL_COLORS[color] const classes = LABEL_COLORS[color]
@@ -159,7 +161,7 @@ export function LabelManagementDialog() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100" className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setEditingColorId(isEditing ? null : label.id)} onClick={() => setEditingColorId(isEditing ? null : label.id)}
title={t('labels.changeColor')} title={t('labels.changeColor')}
> >
@@ -182,6 +184,24 @@ export function LabelManagementDialog() {
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
)
if (controlled) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{dialogContent}
</Dialog>
)
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title={t('labels.manage')}>
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
{dialogContent}
</Dialog> </Dialog>
) )
} }

View File

@@ -15,6 +15,7 @@ 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 { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from '@/components/label-management-dialog'
import { Notebook } from '@/lib/types' import { Notebook } from '@/lib/types'
// Map icon names to lucide-react components // Map icon names to lucide-react components
@@ -53,6 +54,7 @@ export function NotebooksList() {
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null) const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
const [summaryNotebook, setSummaryNotebook] = useState<Notebook | null>(null) const [summaryNotebook, setSummaryNotebook] = useState<Notebook | null>(null)
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null) const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
const currentNotebookId = searchParams.get('notebook') const currentNotebookId = searchParams.get('notebook')
@@ -97,7 +99,7 @@ export function NotebooksList() {
} }
const handleToggleExpand = (notebookId: string) => { const handleToggleExpand = (notebookId: string) => {
setExpandedNotebook(expandedNotebook === notebookId ? null : notebookId) setExpandedNotebook((prev) => (prev === notebookId ? null : notebookId))
} }
const handleLabelFilter = (labelName: string, notebookId: string) => { const handleLabelFilter = (labelName: string, notebookId: string) => {
@@ -126,6 +128,7 @@ export function NotebooksList() {
return ( return (
<> <>
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
<div className="flex flex-col pt-1"> <div className="flex flex-col pt-1">
{/* Header with Add Button */} {/* Header with Add Button */}
<div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300"> <div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300">
@@ -157,7 +160,7 @@ export function NotebooksList() {
onDragOver={(e) => handleDragOver(e, notebook.id)} onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
className={cn( className={cn(
"flex flex-col mr-2 rounded-r-full overflow-hidden transition-all relative", "flex flex-col mr-2 rounded-r-full transition-all relative",
!notebook.color && "bg-primary/10 dark:bg-primary/20", !notebook.color && "bg-primary/10 dark:bg-primary/20",
isDragOver && "ring-2 ring-primary ring-dashed" isDragOver && "ring-2 ring-primary ring-dashed"
)} )}
@@ -186,39 +189,56 @@ export function NotebooksList() {
onSummary={() => setSummaryNotebook(notebook)} onSummary={() => setSummaryNotebook(notebook)}
/> />
<button <button
onClick={() => handleToggleExpand(notebook.id)} type="button"
className={cn("transition-colors p-1", !notebook.color && "text-primary hover:text-primary/80 dark:text-primary-foreground dark:hover:text-primary-foreground/80")} onClick={(e) => {
e.stopPropagation()
handleToggleExpand(notebook.id)
}}
className={cn(
"shrink-0 rounded-full p-1 transition-colors",
!notebook.color &&
"text-primary hover:text-primary/80 dark:text-primary-foreground dark:hover:text-primary-foreground/80"
)}
style={notebook.color ? { color: notebook.color } : undefined} style={notebook.color ? { color: notebook.color } : undefined}
aria-expanded={isExpanded}
> >
<ChevronDown className={cn("w-4 h-4 transition-transform", isExpanded && "rotate-180")} /> <ChevronDown className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-180")} />
</button> </button>
</div> </div>
</div> </div>
{/* Contextual Labels Tree */} {/* Contextual Labels Tree */}
{isExpanded && labels.length > 0 && ( {isExpanded && (
<div className="flex flex-col pb-2"> <div className="flex flex-col pb-2">
{labels.map((label: any) => ( {labels.length === 0 ? (
<p className="pointer-events-none pl-12 pr-4 py-2 text-xs text-muted-foreground">
{t('sidebar.noLabelsInNotebook')}
</p>
) : (
labels.map((label: any) => (
<button <button
key={label.id} key={label.id}
type="button"
onClick={() => handleLabelFilter(label.name, notebook.id)} onClick={() => handleLabelFilter(label.name, notebook.id)}
className={cn( className={cn(
"pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 hover:bg-black/5 dark:hover:bg-white/10 transition-colors rounded-r-full mr-2", 'pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 rounded-r-full mr-2 transition-colors',
searchParams.get('labels')?.includes(label.name) && "font-bold text-gray-900 dark:text-white" 'hover:bg-accent/60 text-muted-foreground hover:text-foreground',
searchParams.get('labels')?.includes(label.name) &&
'font-semibold text-foreground'
)} )}
> >
<Tag className="w-4 h-4 text-gray-500" /> <Tag className="h-4 w-4 shrink-0" />
<span className="text-xs font-medium text-gray-600 dark:text-gray-300"> <span className="text-xs font-medium truncate">{label.name}</span>
{label.name}
</span>
</button> </button>
))} ))
)}
<button <button
onClick={() => router.push('/settings/labels')} type="button"
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 text-gray-500 hover:text-gray-800 hover:bg-black/5 dark:hover:bg-white/10 rounded-r-full mr-2 transition-colors group/label" onClick={() => setLabelsDialogOpen(true)}
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 rounded-r-full mr-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
> >
<Plus className="w-3 h-3 group-hover/label:scale-110 transition-transform" /> <Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" />
<span className="text-xs font-medium opacity-80">{t('sidebar.editLabels') || 'Edit Labels'}</span> <span className="text-xs font-medium">{t('sidebar.editLabels')}</span>
</button> </button>
</div> </div>
)} )}

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, User } from 'lucide-react' import { Bell, Check, X, Clock, User } from 'lucide-react'
@@ -37,7 +36,6 @@ interface ShareRequest {
} }
export function NotificationPanel() { export function NotificationPanel() {
const router = useRouter()
const { triggerRefresh } = useNoteRefresh() const { triggerRefresh } = useNoteRefresh()
const { t } = useLanguage() const { t } = useLanguage()
const [requests, setRequests] = useState<ShareRequest[]>([]) const [requests, setRequests] = useState<ShareRequest[]>([])
@@ -59,17 +57,16 @@ export function NotificationPanel() {
useEffect(() => { useEffect(() => {
loadRequests() loadRequests()
const interval = setInterval(loadRequests, 10000) const interval = setInterval(loadRequests, 30000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
const handleAccept = async (shareId: string) => { const handleAccept = async (shareId: string) => {
try { try {
await respondToShareRequest(shareId, 'accept') await respondToShareRequest(shareId, 'accept')
router.refresh()
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId)) setRequests(prev => prev.filter(r => r.id !== shareId))
setPendingCount(prev => prev - 1) setPendingCount(prev => prev - 1)
triggerRefresh()
toast.success(t('notes.noteCreated'), { toast.success(t('notes.noteCreated'), {
description: t('collaboration.nowHasAccess', { name: 'Note' }), description: t('collaboration.nowHasAccess', { name: 'Note' }),
duration: 3000, duration: 3000,
@@ -83,11 +80,9 @@ export function NotificationPanel() {
const handleDecline = async (shareId: string) => { const handleDecline = async (shareId: string) => {
try { try {
await respondToShareRequest(shareId, 'decline') await respondToShareRequest(shareId, 'decline')
router.refresh()
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId)) setRequests(prev => prev.filter(r => r.id !== shareId))
setPendingCount(prev => prev - 1) setPendingCount(prev => prev - 1)
toast.info(t('general.operationFailed')) toast.info(t('notification.declined'))
} catch (error: any) { } catch (error: any) {
console.error('[NOTIFICATION] Error:', error) console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || t('general.error')) toast.error(error.message || t('general.error'))
@@ -97,10 +92,8 @@ export function NotificationPanel() {
const handleRemove = async (shareId: string) => { const handleRemove = async (shareId: string) => {
try { try {
await removeSharedNoteFromView(shareId) await removeSharedNoteFromView(shareId)
router.refresh()
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId)) setRequests(prev => prev.filter(r => r.id !== shareId))
toast.info(t('general.operationFailed')) toast.info(t('notification.removed'))
} catch (error: any) { } catch (error: any) {
toast.error(error.message || t('general.error')) toast.error(error.message || t('general.error'))
} }

View File

@@ -54,6 +54,9 @@ export interface NotebooksContextValue {
// Actions: Notes // Actions: Notes
moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void> moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void>
/** Recharger la liste des carnets (ex. après maintenance étiquettes) */
refreshNotebooks: () => Promise<void>
// Actions: AI (stubs pour l'instant) // Actions: AI (stubs pour l'instant)
suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null> suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null>
suggestLabelsForNote: (noteContent: string, notebookId: string) => Promise<string[]> suggestLabelsForNote: (noteContent: string, notebookId: string) => Promise<string[]>
@@ -262,6 +265,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
updateLabel, updateLabel,
deleteLabel, deleteLabel,
moveNoteToNotebookOptimistic, moveNoteToNotebookOptimistic,
refreshNotebooks: loadNotebooks,
suggestNotebookForNote, suggestNotebookForNote,
suggestLabelsForNote, suggestLabelsForNote,
}), [ }), [
@@ -279,6 +283,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
updateLabel, updateLabel,
deleteLabel, deleteLabel,
moveNoteToNotebookOptimistic, moveNoteToNotebookOptimistic,
loadNotebooks,
suggestNotebookForNote, suggestNotebookForNote,
suggestLabelsForNote, suggestLabelsForNote,
]) ])

View File

@@ -1,44 +1,53 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { Note } from '@/lib/types'; import { Note } from '@/lib/types';
import { toast } from 'sonner'; import { toast } from 'sonner';
const STORAGE_KEY = 'memento-notified-reminders';
function getNotifiedFromStorage(): Set<string> {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
function persistNotified(ids: Set<string>) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
} catch { /* quota exceeded — non-critical */ }
}
export function useReminderCheck(notes: Note[]) { export function useReminderCheck(notes: Note[]) {
const [notifiedReminders, setNotifiedReminders] = useState<Set<string>>(new Set()); const notifiedRef = useRef<Set<string>>(getNotifiedFromStorage());
useEffect(() => { useEffect(() => {
const checkReminders = () => { const checkReminders = () => {
const now = new Date(); const now = new Date();
const dueReminders: string[] = []; const newIds: string[] = [];
for (const note of notes) {
if (!note.reminder || note.isReminderDone) continue;
if (notifiedRef.current.has(note.id)) continue;
// First pass: collect which reminders are due
notes.forEach(note => {
if (!note.reminder) return;
const reminderDate = new Date(note.reminder); const reminderDate = new Date(note.reminder);
if (reminderDate <= now) {
if (reminderDate <= now && !notifiedReminders.has(note.id)) { newIds.push(note.id);
dueReminders.push(note.id); toast.info(`🔔 ${note.title || 'Untitled Note'}`, { id: `reminder-${note.id}` });
toast.info("🔔 Reminder: " + (note.title || "Untitled Note")); }
} }
});
// Second pass: update state only once with all due reminders if (newIds.length > 0) {
if (dueReminders.length > 0) { for (const id of newIds) notifiedRef.current.add(id);
setNotifiedReminders(prev => { persistNotified(notifiedRef.current);
const newSet = new Set(prev);
dueReminders.forEach(id => newSet.add(id));
return newSet;
});
} }
}; };
// Check immediately
checkReminders(); checkReminders();
const interval = setInterval(checkReminders, 30_000);
// Then check every 30 seconds
const interval = setInterval(checkReminders, 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [notes]); }, [notes]);
} }

View File

@@ -443,15 +443,37 @@ Deine Antwort (nur JSON):
}, },
}) })
// Assign label to all suggested notes (updateMany doesn't support relations) // Assign to notes: UI reads `Note.labels` (JSON string[]); relations must stay in sync
for (const noteId of suggestedLabel.noteIds) { for (const noteId of suggestedLabel.noteIds) {
const note = await prisma.note.findFirst({
where: { id: noteId, userId, notebookId },
select: { labels: true },
})
if (!note) continue
let names: string[] = []
if (note.labels) {
try {
const parsed = JSON.parse(note.labels) as unknown
names = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
: []
} catch {
names = []
}
}
const trimmed = suggestedLabel.name.trim()
if (!names.some((n) => n.toLowerCase() === trimmed.toLowerCase())) {
names = [...names, suggestedLabel.name]
}
await prisma.note.update({ await prisma.note.update({
where: { id: noteId }, where: { id: noteId },
data: { data: {
labels: JSON.stringify(names),
labelRelations: { labelRelations: {
connect: { connect: { id: label.id },
id: label.id,
},
}, },
}, },
}) })

View File

@@ -1,5 +1,5 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory' import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
export interface NoteForOrganization { export interface NoteForOrganization {
@@ -102,24 +102,12 @@ export class BatchOrganizationService {
): Promise<OrganizationPlan> { ): Promise<OrganizationPlan> {
const prompt = this.buildPrompt(notes, notebooks, language) const prompt = this.buildPrompt(notes, notebooks, language)
try {
const config = await getSystemConfig() const config = await getSystemConfig()
const provider = getAIProvider(config) const provider = getTagsProvider(config)
const response = await provider.generateText(prompt) const response = await provider.generateText(prompt)
// Parse AI response
const plan = this.parseAIResponse(response, notes, notebooks) const plan = this.parseAIResponse(response, notes, notebooks)
return plan return plan
} catch (error) {
console.error('Failed to create organization plan:', error)
// Return empty plan on error
return {
notebooks: [],
totalNotes: notes.length,
unorganizedNotes: notes.length,
}
}
} }
/** /**
@@ -870,6 +858,10 @@ ${notesList}
return instructions[language] || instructions['en'] || instructions['fr'] return instructions[language] || instructions['en'] || instructions['fr']
} }
private normalizeForMatch(str: string): string {
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim()
}
/** /**
* Parse AI response into OrganizationPlan * Parse AI response into OrganizationPlan
*/ */
@@ -879,7 +871,6 @@ ${notesList}
notebooks: any[] notebooks: any[]
): OrganizationPlan { ): OrganizationPlan {
try { try {
// Try to parse JSON response
const jsonMatch = response.match(/\{[\s\S]*\}/) const jsonMatch = response.match(/\{[\s\S]*\}/)
if (!jsonMatch) { if (!jsonMatch) {
throw new Error('No JSON found in response') throw new Error('No JSON found in response')
@@ -889,9 +880,10 @@ ${notesList}
const notebookOrganizations: NotebookOrganization[] = [] const notebookOrganizations: NotebookOrganization[] = []
// Process each notebook in AI response
for (const aiNotebook of aiData.carnets || []) { for (const aiNotebook of aiData.carnets || []) {
const notebook = notebooks.find(nb => nb.name === aiNotebook.nom) const aiName = this.normalizeForMatch(aiNotebook.nom || '')
const notebook = notebooks.find(nb => this.normalizeForMatch(nb.name) === aiName)
|| notebooks.find(nb => this.normalizeForMatch(nb.name).includes(aiName) || aiName.includes(this.normalizeForMatch(nb.name)))
if (!notebook) continue if (!notebook) continue
const noteAssignments = aiNotebook.notes const noteAssignments = aiNotebook.notes
@@ -933,12 +925,8 @@ ${notesList}
unorganizedNotes: unorganizedCount, unorganizedNotes: unorganizedCount,
} }
} catch (error) { } catch (error) {
console.error('Failed to parse AI response:', error) console.error('Failed to parse AI response:', error, '\nRaw response:', response.substring(0, 500))
return { throw new Error(`AI response parsing failed: ${error instanceof Error ? error.message : 'Invalid JSON'}`)
notebooks: [],
totalNotes: notes.length,
unorganizedNotes: notes.length,
}
} }
} }

View File

@@ -33,6 +33,7 @@
"reminders": "Reminders", "reminders": "Reminders",
"labels": "Labels", "labels": "Labels",
"editLabels": "Edit labels", "editLabels": "Edit labels",
"noLabelsInNotebook": "No labels in this notebook yet",
"archive": "Archive", "archive": "Archive",
"trash": "Trash" "trash": "Trash"
}, },
@@ -389,7 +390,9 @@
"notification": { "notification": {
"shared": "shared \"{title}\"", "shared": "shared \"{title}\"",
"untitled": "Untitled", "untitled": "Untitled",
"notifications": "Notifications" "notifications": "Notifications",
"declined": "Share declined",
"removed": "Note removed from list"
}, },
"nav": { "nav": {
"home": "Home", "home": "Home",
@@ -448,6 +451,12 @@
"maintenanceDescription": "Tools to maintain your database health", "maintenanceDescription": "Tools to maintain your database health",
"cleanTags": "Clean Orphan Tags", "cleanTags": "Clean Orphan Tags",
"cleanTagsDescription": "Remove tags that are no longer used by any notes", "cleanTagsDescription": "Remove tags that are no longer used by any notes",
"cleanupDone": "Synced {created} label record(s), removed {deleted} orphan(s)",
"cleanupNothing": "Nothing to do — labels already match your notes",
"cleanupWithErrors": "some operations failed",
"cleanupError": "Could not clean up labels",
"indexingComplete": "Indexing complete: {count} note(s) processed",
"indexingError": "Error during indexing",
"semanticIndexing": "Semantic Indexing", "semanticIndexing": "Semantic Indexing",
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search", "semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
"profile": "Profile", "profile": "Profile",

View File

@@ -637,7 +637,9 @@
"notification": { "notification": {
"shared": "a partagé « {title} »", "shared": "a partagé « {title} »",
"untitled": "Sans titre", "untitled": "Sans titre",
"notifications": "Notifications" "notifications": "Notifications",
"declined": "Partage refusé",
"removed": "Note retirée de la liste"
}, },
"nav": { "nav": {
"accountSettings": "Paramètres du compte", "accountSettings": "Paramètres du compte",
@@ -924,6 +926,12 @@
"appearance": "Apparence", "appearance": "Apparence",
"cleanTags": "Nettoyer les étiquettes orphelines", "cleanTags": "Nettoyer les étiquettes orphelines",
"cleanTagsDescription": "Supprimer les étiquettes qui ne sont plus utilisées par aucune note", "cleanTagsDescription": "Supprimer les étiquettes qui ne sont plus utilisées par aucune note",
"cleanupDone": "{created} étiquette(s) synchronisée(s), {deleted} orpheline(s) supprimée(s)",
"cleanupNothing": "Aucune action nécessaire — les étiquettes sont déjà alignées avec vos notes",
"cleanupWithErrors": "certaines opérations ont échoué",
"cleanupError": "Impossible de nettoyer les étiquettes",
"indexingComplete": "Indexation terminée : {count} note(s) traitée(s)",
"indexingError": "Erreur pendant lindexation",
"description": "Gérez vos paramètres et préférences", "description": "Gérez vos paramètres et préférences",
"language": "Langue", "language": "Langue",
"languageAuto": "Langue définie sur Auto", "languageAuto": "Langue définie sur Auto",
@@ -962,6 +970,7 @@
"archive": "Archives", "archive": "Archives",
"editLabels": "Modifier les étiquettes", "editLabels": "Modifier les étiquettes",
"labels": "Étiquettes", "labels": "Étiquettes",
"noLabelsInNotebook": "Aucune étiquette dans ce carnet",
"notes": "Notes", "notes": "Notes",
"reminders": "Rappels", "reminders": "Rappels",
"trash": "Corbeille" "trash": "Corbeille"