fix(keep-notes): sidebar chevron, labels sync, batch org errors, perf guards

- Notebooks: chevron visible when expanded (remove overflow clip), functional expand state
- Labels: sync/cleanup by notebookId, reconcile after note move
- Settings: refresh notebooks after cleanup; label dialog routing
- ConnectionsBadge lazy-load; reminder check persistence; i18n keys

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-04-13 22:07:09 +02:00
parent fa7e166f3e
commit 39671c6472
16 changed files with 469 additions and 303 deletions

View File

@@ -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<OrganizationPlan | null>(null)
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
const [selectedNotes, setSelectedNotes] = useState<Set<string>>(new Set())
const [fetchError, setFetchError] = useState<string | null>(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<string>()
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')}
</p>
</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 ? (
<div className="space-y-6 py-4">
{/* Summary */}

View File

@@ -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<number>(0)
const [isLoading, setIsLoading] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [fetchAttempted, setFetchAttempted] = useState(false)
const containerRef = useRef<HTMLSpanElement>(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 <span ref={containerRef} className="hidden" />
}
const plural = connectionCount > 1 ? 's' : ''

View File

@@ -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<string | null>(null)
const controlled = open !== undefined && onOpenChange !== undefined
const handleAddLabel = async () => {
const trimmed = newLabel.trim()
if (trimmed) {
@@ -55,13 +63,7 @@ export function LabelManagementDialog() {
}
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title={t('labels.manage')}>
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
const dialogContent = (
<DialogContent
className="max-w-md"
onInteractOutside={(event) => {
@@ -117,23 +119,23 @@ export function LabelManagementDialog() {
{/* List labels */}
<div className="max-h-[60vh] overflow-y-auto space-y-2">
{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 ? (
<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) => {
const colorClasses = LABEL_COLORS[label.color]
const isEditing = editingColorId === label.id
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">
<Tag className={cn("h-4 w-4", colorClasses.text)} />
<span className="font-medium text-sm">{label.name}</span>
{/* Color Picker Popover */}
{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">
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
const classes = LABEL_COLORS[color]
@@ -159,7 +161,7 @@ export function LabelManagementDialog() {
<Button
variant="ghost"
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)}
title={t('labels.changeColor')}
>
@@ -182,6 +184,24 @@ export function LabelManagementDialog() {
</div>
</div>
</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>
)
}

View File

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

View File

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