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:
@@ -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 */}
|
||||
|
||||
@@ -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' : ''
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user