Add safe database migration workflow and note history infrastructure.
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
This introduces guarded migrations with automatic backups, fixes note creation after DB reset, and wires snapshot/restore history across notes surfaces.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AISettingsPanelProps {
|
||||
initialSettings: {
|
||||
titleSuggestions: boolean
|
||||
semanticSearch: boolean
|
||||
paragraphRefactor: boolean
|
||||
memoryEcho: boolean
|
||||
memoryEchoFrequency: 'daily' | 'weekly' | 'custom'
|
||||
aiProvider: 'auto' | 'openai' | 'ollama'
|
||||
preferredLanguage: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
|
||||
demoMode: boolean
|
||||
languageDetection: boolean
|
||||
autoLabeling: boolean
|
||||
noteHistory: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
const [settings, setSettings] = useState(initialSettings)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleToggle = async (feature: string, value: boolean) => {
|
||||
// Optimistic update
|
||||
setSettings(prev => ({ ...prev, [feature]: value }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ [feature]: value })
|
||||
toast.success(t('aiSettings.saved'))
|
||||
} catch (error) {
|
||||
console.error('Error updating setting:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
// Revert on error
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
|
||||
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ memoryEchoFrequency: value })
|
||||
toast.success(t('aiSettings.saved'))
|
||||
} catch (error) {
|
||||
console.error('Error updating frequency:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => {
|
||||
setSettings(prev => ({ ...prev, preferredLanguage: value }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ preferredLanguage: value })
|
||||
toast.success(t('aiSettings.saved'))
|
||||
} catch (error) {
|
||||
console.error('Error updating language:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemoModeToggle = async (enabled: boolean) => {
|
||||
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ demoMode: enabled })
|
||||
} catch (error) {
|
||||
console.error('Error toggling demo mode:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
throw error
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('aiSettings.saving')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Toggles */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t('aiSettings.features')}</h2>
|
||||
|
||||
<FeatureToggle
|
||||
name={t('titleSuggestions.available').replace('💡 ', '')}
|
||||
description={t('aiSettings.titleSuggestionsDesc')}
|
||||
checked={settings.titleSuggestions}
|
||||
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
||||
/>
|
||||
|
||||
|
||||
<FeatureToggle
|
||||
name="Assistant IA"
|
||||
description="Active le bouton de chat IA et les outils d'amélioration du texte"
|
||||
checked={settings.paragraphRefactor}
|
||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||
/>
|
||||
|
||||
<FeatureToggle
|
||||
name={t('memoryEcho.title')}
|
||||
description={t('memoryEcho.dailyInsight')}
|
||||
checked={settings.memoryEcho}
|
||||
onChange={(checked) => handleToggle('memoryEcho', checked)}
|
||||
/>
|
||||
|
||||
{settings.memoryEcho && (
|
||||
<Card className="p-4 ml-6">
|
||||
<Label htmlFor="frequency" className="text-sm font-medium">
|
||||
{t('aiSettings.frequency')}
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{t('aiSettings.frequencyDesc')}
|
||||
</p>
|
||||
<RadioGroup
|
||||
value={settings.memoryEchoFrequency}
|
||||
onValueChange={handleFrequencyChange}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="daily" id="daily" />
|
||||
<Label htmlFor="daily" className="font-normal">
|
||||
{t('aiSettings.frequencyDaily')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="weekly" id="weekly" />
|
||||
<Label htmlFor="weekly" className="font-normal">
|
||||
{t('aiSettings.frequencyWeekly')}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Language Detection Toggle */}
|
||||
<FeatureToggle
|
||||
name="Détection de langue"
|
||||
description="Détecte automatiquement la langue de vos notes"
|
||||
checked={settings.languageDetection ?? true}
|
||||
onChange={(checked) => handleToggle('languageDetection', checked)}
|
||||
/>
|
||||
|
||||
{/* Auto Labeling Toggle */}
|
||||
<FeatureToggle
|
||||
name="Suggestion des labels"
|
||||
description="Suggère et applique des étiquettes automatiquement à vos notes"
|
||||
checked={settings.autoLabeling ?? true}
|
||||
onChange={(checked) => handleToggle('autoLabeling', checked)}
|
||||
/>
|
||||
|
||||
<FeatureToggle
|
||||
name="Historique des notes"
|
||||
description="Active les snapshots de versions et la restauration depuis History"
|
||||
checked={settings.noteHistory ?? false}
|
||||
onChange={(checked) => handleToggle('noteHistory', checked)}
|
||||
/>
|
||||
|
||||
{/* Demo Mode Toggle */}
|
||||
<DemoModeToggle
|
||||
demoMode={settings.demoMode}
|
||||
onToggle={handleDemoModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FeatureToggleProps {
|
||||
name: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
function FeatureToggle({ name, description, checked, onChange }: FeatureToggleProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-medium">{name}</Label>
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
475
Momento-main/momento/memento-note/components/home-client.tsx
Normal file
475
Momento-main/momento/memento-note/components/home-client.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { getAllNotes, searchNotes } from '@/app/actions/notes'
|
||||
import { NoteInput } from '@/components/note-input'
|
||||
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
||||
import { NotesViewToggle } from '@/components/notes-view-toggle'
|
||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { FavoritesSection } from '@/components/favorites-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Wand2, ChevronRight, Plus, FileText } from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LabelFilter } from '@/components/label-filter'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useHomeView } from '@/context/home-view-context'
|
||||
import { NoteHistoryModal } from '@/components/note-history-modal'
|
||||
|
||||
// Lazy-load heavy dialogs — uniquement chargés à la demande
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const BatchOrganizationDialog = dynamic(
|
||||
() => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const AutoLabelSuggestionDialog = dynamic(
|
||||
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type InitialSettings = {
|
||||
showRecentNotes: boolean
|
||||
notesViewMode: 'masonry' | 'tabs'
|
||||
noteHistory: boolean
|
||||
}
|
||||
|
||||
interface HomeClientProps {
|
||||
initialNotes: Note[]
|
||||
initialSettings: InitialSettings
|
||||
}
|
||||
|
||||
export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
||||
initialNotes.filter(n => n.isPinned)
|
||||
)
|
||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
||||
const [noteHistoryEnabled, setNoteHistoryEnabled] = useState(initialSettings.noteHistory)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
|
||||
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
||||
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [historyNote, setHistoryNote] = useState<Note | null>(null)
|
||||
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
||||
const { labels } = useLabels()
|
||||
const { setControls } = useHomeView()
|
||||
|
||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldSuggestLabels && suggestNotebookId) {
|
||||
setAutoLabelOpen(true)
|
||||
}
|
||||
}, [shouldSuggestLabels, suggestNotebookId])
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const isInbox = !notebookFilter
|
||||
|
||||
const handleNoteCreated = useCallback((note: Note) => {
|
||||
setNotes((prevNotes) => {
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
|
||||
if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes
|
||||
if (!notebookFilter && note.notebookId) return prevNotes
|
||||
|
||||
if (labelFilter.length > 0) {
|
||||
const noteLabels = note.labels || []
|
||||
if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes
|
||||
}
|
||||
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter((label: any) => label.color === colorFilter)
|
||||
.map((label: any) => label.name)
|
||||
const noteLabels = note.labels || []
|
||||
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes
|
||||
}
|
||||
|
||||
if (search) {
|
||||
router.refresh()
|
||||
return prevNotes
|
||||
}
|
||||
|
||||
const isPinned = note.isPinned || false
|
||||
const pinnedNotes = prevNotes.filter(n => n.isPinned)
|
||||
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
|
||||
|
||||
if (isPinned) {
|
||||
return [note, ...pinnedNotes, ...unpinnedNotes]
|
||||
} else {
|
||||
return [...pinnedNotes, note, ...unpinnedNotes]
|
||||
}
|
||||
})
|
||||
|
||||
triggerRefresh()
|
||||
|
||||
if (!note.notebookId) {
|
||||
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
||||
if (wordCount >= 20) {
|
||||
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
|
||||
}
|
||||
}
|
||||
}, [searchParams, labels, router, triggerRefresh])
|
||||
|
||||
const handleOpenNote = (noteId: string) => {
|
||||
const note = notes.find(n => n.id === noteId)
|
||||
if (note) setEditingNote({ note, readOnly: false })
|
||||
}
|
||||
|
||||
const handleOpenHistory = useCallback((note: Note) => {
|
||||
setHistoryNote(note)
|
||||
setHistoryOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleEnableHistory = useCallback(async () => {
|
||||
await updateAISettings({ noteHistory: true })
|
||||
setNoteHistoryEnabled(true)
|
||||
}, [])
|
||||
|
||||
const handleHistoryRestored = useCallback((restored: Note) => {
|
||||
setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
|
||||
setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
|
||||
setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev))
|
||||
}, [])
|
||||
|
||||
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
|
||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||
}, [])
|
||||
|
||||
useReminderCheck(notes)
|
||||
|
||||
// Listen for global label deletion and immediately update local state
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { name } = (e as CustomEvent).detail
|
||||
if (!name) return
|
||||
const removeLabel = (note: Note) => {
|
||||
const currentLabels = note.labels || []
|
||||
const updated = currentLabels.filter((l) => l.toLowerCase() !== name.toLowerCase())
|
||||
if (updated.length === currentLabels.length) return note
|
||||
return { ...note, labels: updated.length > 0 ? updated : null }
|
||||
}
|
||||
setNotes((prev) => prev.map(removeLabel))
|
||||
setPinnedNotes((prev) => prev.map(removeLabel))
|
||||
}
|
||||
window.addEventListener('label-deleted', handler)
|
||||
return () => window.removeEventListener('label-deleted', handler)
|
||||
}, [])
|
||||
|
||||
const prevRefreshKey = useRef(refreshKey)
|
||||
|
||||
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
|
||||
// Les notes initiales suffisent sans filtre
|
||||
useEffect(() => {
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
const notebook = searchParams.get('notebook')
|
||||
const semanticMode = searchParams.get('semantic') === 'true'
|
||||
|
||||
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
|
||||
prevRefreshKey.current = refreshKey
|
||||
|
||||
// Pour le refreshKey (mutations), toujours recharger
|
||||
// Pour les filtres, charger depuis le serveur
|
||||
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
||||
|
||||
const load = async () => {
|
||||
if (!isBackgroundRefresh) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
let allNotes = search
|
||||
? await searchNotes(search, semanticMode, notebook || undefined)
|
||||
: await getAllNotes()
|
||||
|
||||
// Filtre notebook côté client
|
||||
// Shared notes appear ONLY in inbox (general notes), not in notebooks
|
||||
if (notebook) {
|
||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared)
|
||||
} else {
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
||||
}
|
||||
|
||||
// Filtre labels
|
||||
if (labelFilter.length > 0) {
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// Filtre couleur
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter((label: any) => label.color === colorFilter)
|
||||
.map((label: any) => label.name)
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// Merger avec les tailles locales pour ne pas écraser les modifications
|
||||
setNotes(prev => {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
setPinnedNotes(allNotes.filter((n: any) => n.isPinned))
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
|
||||
if (refreshKey > 0 || hasActiveFilter) {
|
||||
const cancelled = { value: false }
|
||||
load().then(() => { if (cancelled.value) return })
|
||||
return () => { cancelled.value = true }
|
||||
} else {
|
||||
// Données initiales : filtrage inbox/notebook côté client seulement
|
||||
let filtered = initialNotes
|
||||
if (notebook) {
|
||||
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
||||
} else {
|
||||
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
|
||||
}
|
||||
// Merger avec les tailles déjà modifiées localement
|
||||
setNotes(prev => {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
setPinnedNotes(filtered.filter(n => n.isPinned))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, refreshKey])
|
||||
|
||||
const { notebooks } = useNotebooks()
|
||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||
|
||||
useEffect(() => {
|
||||
setControls({
|
||||
isTabsMode: notesViewMode === 'tabs',
|
||||
openNoteComposer: () => {},
|
||||
})
|
||||
return () => setControls(null)
|
||||
}, [notesViewMode, setControls])
|
||||
|
||||
const handleNoteCreatedWrapper = (note: any) => {
|
||||
handleNoteCreated(note)
|
||||
}
|
||||
|
||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<span>{t('nav.notebooks')}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="font-medium text-primary">{notebookName}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const isTabs = notesViewMode === 'tabs'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-h-0 flex-1 flex-col',
|
||||
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
|
||||
)}
|
||||
>
|
||||
{/* Notebook Specific Header */}
|
||||
{currentNotebook ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
||||
)}
|
||||
>
|
||||
<Breadcrumbs notebookName={currentNotebook.name} />
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
|
||||
return (
|
||||
<Icon
|
||||
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
|
||||
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
||||
<LabelFilter
|
||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
||||
onFilterChange={(newLabels) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
||||
else params.delete('labels')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
||||
)}
|
||||
>
|
||||
{!isTabs && <div className="mb-1 h-5" />}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
||||
<LabelFilter
|
||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
||||
onFilterChange={(newLabels) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
||||
else params.delete('labels')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
{isInbox && !isLoading && notes.length >= 2 && (
|
||||
<Button
|
||||
onClick={() => setBatchOrganizationOpen(true)}
|
||||
variant="outline"
|
||||
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
||||
title={t('batch.organizeWithAI')}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 text-purple-600" />
|
||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isTabs && (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-in fade-in slide-in-from-top-4 duration-300',
|
||||
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
|
||||
)}
|
||||
>
|
||||
<NoteInput
|
||||
onNoteCreated={handleNoteCreatedWrapper}
|
||||
fullWidth={isTabs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
<FavoritesSection
|
||||
pinnedNotes={pinnedNotes}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
onSizeChange={handleSizeChange}
|
||||
/>
|
||||
|
||||
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
|
||||
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
||||
<NotesMainSection
|
||||
viewMode={notesViewMode}
|
||||
notes={notes.filter((note) => !note.isPinned)}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
onSizeChange={handleSizeChange}
|
||||
currentNotebookId={searchParams.get('notebook')}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
onEnableHistory={handleEnableHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && !isTabs && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{t('notes.emptyState')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
||||
|
||||
{notebookSuggestion && (
|
||||
<NotebookSuggestionToast
|
||||
noteId={notebookSuggestion.noteId}
|
||||
noteContent={notebookSuggestion.content}
|
||||
onDismiss={() => setNotebookSuggestion(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{batchOrganizationOpen && (
|
||||
<BatchOrganizationDialog
|
||||
open={batchOrganizationOpen}
|
||||
onOpenChange={setBatchOrganizationOpen}
|
||||
onNotesMoved={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{autoLabelOpen && (
|
||||
<AutoLabelSuggestionDialog
|
||||
open={autoLabelOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAutoLabelOpen(open)
|
||||
if (!open) dismissLabelSuggestion()
|
||||
}}
|
||||
notebookId={suggestNotebookId}
|
||||
onLabelsCreated={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NoteHistoryModal
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
note={historyNote}
|
||||
enabled={noteHistoryEnabled}
|
||||
onEnableHistory={handleEnableHistory}
|
||||
onRestored={handleHistoryRestored}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
338
Momento-main/momento/memento-note/components/masonry-grid.tsx
Normal file
338
Momento-main/momento/memento-note/components/masonry-grid.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Note } from '@/lib/types';
|
||||
import { NoteCard } from './note-card';
|
||||
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
||||
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
|
||||
import dynamic from 'next/dynamic';
|
||||
import './masonry-grid.css';
|
||||
|
||||
// Lazy-load NoteEditor — uniquement chargé au clic
|
||||
const NoteEditor = dynamic(
|
||||
() => import('./note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface MasonryGridProps {
|
||||
notes: Note[];
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void;
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
||||
isTrashView?: boolean;
|
||||
noteHistoryEnabled?: boolean;
|
||||
onOpenHistory?: (note: Note) => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Sortable Note Item
|
||||
// ─────────────────────────────────────────────
|
||||
interface SortableNoteProps {
|
||||
note: Note;
|
||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
||||
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
||||
onDragStartNote?: (noteId: string) => void;
|
||||
onDragEndNote?: () => void;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
isTrashView?: boolean;
|
||||
noteHistoryEnabled?: boolean;
|
||||
onOpenHistory?: (note: Note) => void;
|
||||
}
|
||||
|
||||
const SortableNoteItem = memo(function SortableNoteItem({
|
||||
note,
|
||||
onEdit,
|
||||
onSizeChange,
|
||||
onDragStartNote,
|
||||
onDragEndNote,
|
||||
isDragging,
|
||||
isOverlay,
|
||||
isTrashView,
|
||||
noteHistoryEnabled,
|
||||
onOpenHistory,
|
||||
}: SortableNoteProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: isSortableDragging,
|
||||
} = useSortable({ id: note.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isSortableDragging && !isOverlay ? 0.3 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="masonry-sortable-item"
|
||||
data-id={note.id}
|
||||
data-size={note.size}
|
||||
>
|
||||
<NoteCard
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onDragStart={onDragStartNote}
|
||||
onDragEnd={onDragEndNote}
|
||||
isDragging={isDragging}
|
||||
isTrashView={isTrashView}
|
||||
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Sortable Grid Section (pinned or others)
|
||||
// ─────────────────────────────────────────────
|
||||
interface SortableGridSectionProps {
|
||||
notes: Note[];
|
||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
||||
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
||||
draggedNoteId: string | null;
|
||||
onDragStartNote: (noteId: string) => void;
|
||||
onDragEndNote: () => void;
|
||||
isTrashView?: boolean;
|
||||
noteHistoryEnabled?: boolean;
|
||||
onOpenHistory?: (note: Note) => void;
|
||||
}
|
||||
|
||||
const SortableGridSection = memo(function SortableGridSection({
|
||||
notes,
|
||||
onEdit,
|
||||
onSizeChange,
|
||||
draggedNoteId,
|
||||
onDragStartNote,
|
||||
onDragEndNote,
|
||||
isTrashView,
|
||||
noteHistoryEnabled,
|
||||
onOpenHistory,
|
||||
}: SortableGridSectionProps) {
|
||||
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
||||
|
||||
return (
|
||||
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||
<div className="masonry-css-grid">
|
||||
{notes.map(note => (
|
||||
<SortableNoteItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onSizeChange={onSizeChange}
|
||||
onDragStartNote={onDragStartNote}
|
||||
onDragEndNote={onDragEndNote}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
isTrashView={isTrashView}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Main MasonryGrid component
|
||||
// ─────────────────────────────────────────────
|
||||
export function MasonryGrid({
|
||||
notes,
|
||||
onEdit,
|
||||
onSizeChange,
|
||||
isTrashView,
|
||||
noteHistoryEnabled = false,
|
||||
onOpenHistory,
|
||||
}: MasonryGridProps) {
|
||||
const { t } = useLanguage();
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
||||
const cardSizeMode = useCardSizeMode();
|
||||
const isUniformMode = cardSizeMode === 'uniform';
|
||||
|
||||
// Local notes state for optimistic size/order updates
|
||||
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalNotes(prev => {
|
||||
const prevIds = prev.map(n => n.id).join(',')
|
||||
const incomingIds = notes.map(n => n.id).join(',')
|
||||
if (prevIds === incomingIds) {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
}
|
||||
// Notes added/removed: full sync but preserve local sizes
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
}, [notes]);
|
||||
|
||||
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
|
||||
const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activeNote = useMemo(
|
||||
() => localNotes.find(n => n.id === activeId) ?? null,
|
||||
[localNotes, activeId]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
||||
if (onEdit) {
|
||||
onEdit(note, readOnly);
|
||||
} else {
|
||||
setEditingNote({ note, readOnly });
|
||||
}
|
||||
}, [onEdit]);
|
||||
|
||||
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
|
||||
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
|
||||
onSizeChange?.(noteId, newSize);
|
||||
}, [onSizeChange]);
|
||||
|
||||
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 }, // Évite les activations accidentelles
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile
|
||||
})
|
||||
);
|
||||
|
||||
const localNotesRef = useRef<Note[]>(localNotes)
|
||||
useEffect(() => {
|
||||
localNotesRef.current = localNotes
|
||||
}, [localNotes])
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
startDrag(event.active.id as string);
|
||||
}, [startDrag]);
|
||||
|
||||
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
endDrag();
|
||||
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const reordered = arrayMove(
|
||||
localNotesRef.current,
|
||||
localNotesRef.current.findIndex(n => n.id === active.id),
|
||||
localNotesRef.current.findIndex(n => n.id === over.id),
|
||||
);
|
||||
|
||||
if (reordered.length === 0) return;
|
||||
|
||||
setLocalNotes(reordered);
|
||||
// Persist order outside of setState to avoid "setState in render" warning
|
||||
const ids = reordered.map(n => n.id);
|
||||
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
||||
console.error('Failed to persist order:', err);
|
||||
});
|
||||
}, [endDrag]);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="masonry-dnd"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="masonry-container" data-card-size-mode={cardSizeMode}>
|
||||
{pinnedNotes.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
||||
{t('notes.pinned')}
|
||||
</h2>
|
||||
<SortableGridSection
|
||||
notes={pinnedNotes}
|
||||
onEdit={handleEdit}
|
||||
onSizeChange={handleSizeChange}
|
||||
draggedNoteId={draggedNoteId}
|
||||
onDragStartNote={startDrag}
|
||||
onDragEndNote={endDrag}
|
||||
isTrashView={isTrashView}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{othersNotes.length > 0 && (
|
||||
<div>
|
||||
{pinnedNotes.length > 0 && (
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
||||
{t('notes.others')}
|
||||
</h2>
|
||||
)}
|
||||
<SortableGridSection
|
||||
notes={othersNotes}
|
||||
onEdit={handleEdit}
|
||||
onSizeChange={handleSizeChange}
|
||||
draggedNoteId={draggedNoteId}
|
||||
onDragStartNote={startDrag}
|
||||
onDragEndNote={endDrag}
|
||||
isTrashView={isTrashView}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DragOverlay — montre une copie flottante pendant le drag */}
|
||||
<DragOverlay>
|
||||
{activeNote ? (
|
||||
<div className="masonry-sortable-item masonry-drag-overlay" data-size={activeNote.size}>
|
||||
<NoteCard
|
||||
note={activeNote}
|
||||
onEdit={handleEdit}
|
||||
isDragging={true}
|
||||
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
243
Momento-main/momento/memento-note/components/note-actions.tsx
Normal file
243
Momento-main/momento/memento-note/components/note-actions.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
MoreVertical,
|
||||
Palette,
|
||||
Pin,
|
||||
Users,
|
||||
Maximize2,
|
||||
FileText,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
History,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NOTE_COLORS } from "@/lib/types"
|
||||
import { useLanguage } from "@/lib/i18n"
|
||||
|
||||
interface NoteActionsProps {
|
||||
isPinned: boolean
|
||||
isArchived: boolean
|
||||
currentColor: string
|
||||
currentSize?: 'small' | 'medium' | 'large'
|
||||
onTogglePin: () => void
|
||||
onToggleArchive: () => void
|
||||
onColorChange: (color: string) => void
|
||||
onSizeChange?: (size: 'small' | 'medium' | 'large') => void
|
||||
onDelete: () => void
|
||||
onShareCollaborators?: () => void
|
||||
isMarkdown?: boolean
|
||||
onToggleMarkdown?: () => void
|
||||
isTrashView?: boolean
|
||||
onRestore?: () => void
|
||||
onPermanentDelete?: () => void
|
||||
onOpenHistory?: () => void
|
||||
historyEnabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NoteActions({
|
||||
isPinned,
|
||||
isArchived,
|
||||
currentColor,
|
||||
currentSize = 'small',
|
||||
onTogglePin,
|
||||
onToggleArchive,
|
||||
onColorChange,
|
||||
onSizeChange,
|
||||
onDelete,
|
||||
onShareCollaborators,
|
||||
isMarkdown = false,
|
||||
onToggleMarkdown,
|
||||
isTrashView,
|
||||
onRestore,
|
||||
onPermanentDelete,
|
||||
onOpenHistory,
|
||||
historyEnabled = false,
|
||||
className
|
||||
}: NoteActionsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
// Trash view: show only Restore and Permanent Delete
|
||||
if (isTrashView) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-1", className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Restore Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2 text-xs"
|
||||
onClick={onRestore}
|
||||
title={t('trash.restore')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('trash.restore')}</span>
|
||||
</Button>
|
||||
|
||||
{/* Permanent Delete Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
onClick={onPermanentDelete}
|
||||
title={t('trash.permanentDelete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('trash.permanentDelete')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-1", className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Color Palette */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<div className="grid grid-cols-5 gap-2 p-2">
|
||||
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
||||
<button
|
||||
key={colorName}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||
classes.bg,
|
||||
currentColor === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
|
||||
)}
|
||||
onClick={() => onColorChange(colorName)}
|
||||
title={colorName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Markdown Toggle */}
|
||||
{onToggleMarkdown && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 gap-1 px-2 text-xs", isMarkdown && "text-primary bg-primary/10")}
|
||||
title="Markdown"
|
||||
onClick={onToggleMarkdown}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">MD</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* More Options */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label={t('notes.moreOptions')}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* Pin/Unpin Option */}
|
||||
<DropdownMenuItem onClick={onTogglePin}>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
{t('notes.unpin')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
{t('notes.pin')}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onToggleArchive}>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<ArchiveRestore className="h-4 w-4 mr-2" />
|
||||
{t('notes.unarchive')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
{t('notes.archive')}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onOpenHistory && (
|
||||
<DropdownMenuItem onClick={onOpenHistory}>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
{historyEnabled
|
||||
? (t('notes.history') || 'Historique')
|
||||
: (t('notes.enableHistory') || "Activer l'historique")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Size Selector */}
|
||||
{onSizeChange && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{t('notes.size')}
|
||||
</div>
|
||||
{(['small', 'medium', 'large'] as const).map((size) => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onSelect={(e) => {
|
||||
onSizeChange?.(size);
|
||||
}}
|
||||
className={cn(
|
||||
"capitalize",
|
||||
currentSize === size && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4 mr-2" />
|
||||
{t(`notes.${size}` as const)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Collaborators */}
|
||||
{onShareCollaborators && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShareCollaborators()
|
||||
}}
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
{t('notes.shareWithCollaborators')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onDelete} className="text-red-600 dark:text-red-400">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('notes.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
782
Momento-main/momento/memento-note/components/note-card.tsx
Normal file
782
Momento-main/momento/memento-note/components/note-card.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
'use client'
|
||||
|
||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote, restoreNote, permanentDeleteNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { es } from 'date-fns/locale/es'
|
||||
import { de } from 'date-fns/locale/de'
|
||||
import { faIR } from 'date-fns/locale/fa-IR'
|
||||
import { it } from 'date-fns/locale/it'
|
||||
import { pt } from 'date-fns/locale/pt'
|
||||
import { ru } from 'date-fns/locale/ru'
|
||||
import { zhCN } from 'date-fns/locale/zh-CN'
|
||||
import { ja } from 'date-fns/locale/ja'
|
||||
import { ko } from 'date-fns/locale/ko'
|
||||
import { ar } from 'date-fns/locale/ar'
|
||||
import { hi } from 'date-fns/locale/hi'
|
||||
import { nl } from 'date-fns/locale/nl'
|
||||
import { pl } from 'date-fns/locale/pl'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
import { NoteActions } from './note-actions'
|
||||
import { CollaboratorDialog } from './collaborator-dialog'
|
||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
||||
import { ConnectionsBadge } from './connections-badge'
|
||||
import { ConnectionsOverlay } from './connections-overlay'
|
||||
import { ComparisonModal } from './comparison-modal'
|
||||
import { FusionModal } from './fusion-modal'
|
||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Mapping of supported languages to date-fns locales
|
||||
const localeMap: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
fr: fr,
|
||||
es: es,
|
||||
de: de,
|
||||
fa: faIR,
|
||||
it: it,
|
||||
pt: pt,
|
||||
ru: ru,
|
||||
zh: zhCN,
|
||||
ja: ja,
|
||||
ko: ko,
|
||||
ar: ar,
|
||||
hi: hi,
|
||||
nl: nl,
|
||||
pl: pl,
|
||||
}
|
||||
|
||||
function getDateLocale(language: string): Locale {
|
||||
return localeMap[language] || enUS
|
||||
}
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
function getNotebookIcon(iconName: string): LucideIcon {
|
||||
const IconComponent = ICON_MAP[iconName] || Folder
|
||||
return IconComponent
|
||||
}
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
isDragging?: boolean
|
||||
isDragOver?: boolean
|
||||
onDragStart?: (noteId: string) => void
|
||||
onDragEnd?: () => void
|
||||
onResize?: () => void
|
||||
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
||||
isTrashView?: boolean
|
||||
noteHistoryEnabled?: boolean
|
||||
onOpenHistory?: (note: Note) => void
|
||||
}
|
||||
|
||||
// Helper function to get initials from name
|
||||
function getInitials(name: string): string {
|
||||
if (!name) return '??'
|
||||
const trimmedName = name.trim()
|
||||
const parts = trimmedName.split(' ')
|
||||
if (parts.length === 1) {
|
||||
return trimmedName.substring(0, 2).toUpperCase()
|
||||
}
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
// Helper function to get avatar color based on name hash
|
||||
function getAvatarColor(name: string): string {
|
||||
const colors = [
|
||||
'bg-primary',
|
||||
'bg-purple-600',
|
||||
'bg-emerald-600',
|
||||
'bg-amber-600',
|
||||
'bg-pink-600',
|
||||
'bg-teal-600',
|
||||
'bg-blue-600',
|
||||
'bg-indigo-600',
|
||||
]
|
||||
|
||||
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return colors[hash % colors.length]
|
||||
}
|
||||
|
||||
export const NoteCard = memo(function NoteCard({
|
||||
note,
|
||||
onEdit,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
isDragging,
|
||||
onResize,
|
||||
onSizeChange,
|
||||
isTrashView,
|
||||
noteHistoryEnabled = false,
|
||||
onOpenHistory,
|
||||
}: NoteCardProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refreshLabels } = useLabels()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
const { data: session } = useSession()
|
||||
const { t, language } = useLanguage()
|
||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
const [, startTransition] = useTransition()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isHidden, setIsHidden] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
||||
const [owner, setOwner] = useState<any>(null)
|
||||
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
|
||||
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||
|
||||
// Move note to a notebook
|
||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
||||
setShowNotebookMenu(false)
|
||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
||||
}
|
||||
|
||||
// Optimistic UI state for instant feedback
|
||||
const [optimisticNote, addOptimisticNote] = useOptimistic(
|
||||
note,
|
||||
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
|
||||
)
|
||||
|
||||
// Local color state so color persists after transition ends
|
||||
const [localColor, setLocalColor] = useState(note.color)
|
||||
|
||||
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
// Check if this note is currently open in the editor
|
||||
const isNoteOpenInEditor = searchParams.get('note') === note.id
|
||||
|
||||
// Only fetch comparison notes when we have IDs to compare
|
||||
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
|
||||
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
|
||||
)
|
||||
|
||||
const currentUserId = session?.user?.id
|
||||
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
|
||||
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
|
||||
const isOwner = currentUserId && note.userId && currentUserId === note.userId
|
||||
|
||||
// Load collaborators only for shared notes (not owned by current user)
|
||||
useEffect(() => {
|
||||
// Skip API call for notes owned by current user — no need to fetch collaborators
|
||||
if (!isSharedNote) {
|
||||
// For own notes, set owner to current user
|
||||
if (currentUserId && session?.user) {
|
||||
setOwner({
|
||||
id: currentUserId,
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
image: session.user.image,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const loadCollaborators = async () => {
|
||||
if (note.userId && isMounted) {
|
||||
try {
|
||||
const users = await getNoteAllUsers(note.id)
|
||||
if (isMounted) {
|
||||
setCollaborators(users)
|
||||
if (users.length > 0) {
|
||||
setOwner(users[0])
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load collaborators:', error)
|
||||
if (isMounted) {
|
||||
setCollaborators([])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCollaborators()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [note.id, note.userId, isSharedNote, currentUserId, session?.user])
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
setIsHidden(true) // masquage immédiat
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
await refreshLabels()
|
||||
triggerRefresh() // met à jour la liste et le compteur du carnet
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsHidden(false)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
setIsDeleting(true)
|
||||
setIsHidden(true)
|
||||
try {
|
||||
await restoreNote(note.id)
|
||||
triggerRefresh()
|
||||
toast.success(t('trash.noteRestored'))
|
||||
} catch (error) {
|
||||
console.error('Failed to restore note:', error)
|
||||
setIsHidden(false)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermanentDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
setIsHidden(true)
|
||||
try {
|
||||
await permanentDeleteNote(note.id)
|
||||
triggerRefresh()
|
||||
toast.success(t('trash.notePermanentlyDeleted'))
|
||||
} catch (error) {
|
||||
console.error('Failed to permanently delete note:', error)
|
||||
setIsHidden(false)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ isPinned: !note.isPinned })
|
||||
await togglePin(note.id, !note.isPinned)
|
||||
|
||||
if (!note.isPinned) {
|
||||
toast.success(t('notes.pinned') || 'Note pinned')
|
||||
} else {
|
||||
toast.info(t('notes.unpinned') || 'Note unpinned')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleArchive = async () => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ isArchived: !note.isArchived })
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
})
|
||||
}
|
||||
|
||||
const handleColorChange = async (color: string) => {
|
||||
setLocalColor(color) // instant visual update, survives transition
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ color })
|
||||
await updateNote(note.id, { color }, { skipRevalidation: false })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
|
||||
startTransition(async () => {
|
||||
// Instant visual feedback for the card itself
|
||||
addOptimisticNote({ size })
|
||||
|
||||
// Notify parent so it can update its local state
|
||||
onSizeChange?.(size)
|
||||
|
||||
// Trigger layout refresh
|
||||
onResize?.()
|
||||
setTimeout(() => onResize?.(), 300)
|
||||
|
||||
// Update server in background
|
||||
|
||||
try {
|
||||
await updateSize(note.id, size);
|
||||
} catch (error) {
|
||||
console.error('Failed to update note size:', error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCheckItem = async (checkItemId: string) => {
|
||||
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
|
||||
const updatedItems = note.checkItems.map(item =>
|
||||
item.id === checkItemId ? { ...item, checked: !item.checked } : item
|
||||
)
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ checkItems: updatedItems })
|
||||
await updateNote(note.id, { checkItems: updatedItems })
|
||||
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
if (confirm(t('notes.confirmLeaveShare'))) {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
setIsDeleting(true) // Hide the note from view
|
||||
} catch (error) {
|
||||
console.error('Failed to leave share:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent opening the note editor
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ autoGenerated: null })
|
||||
await removeFusedBadge(note.id)
|
||||
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
|
||||
})
|
||||
}
|
||||
|
||||
if (isDeleting) return null
|
||||
|
||||
const getMinHeight = (size?: string) => {
|
||||
switch (size) {
|
||||
case 'medium': return '350px'
|
||||
case 'large': return '500px'
|
||||
default: return '150px' // small
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (isHidden) return null
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-testid="note-card"
|
||||
data-draggable="true"
|
||||
data-note-id={note.id}
|
||||
data-size={optimisticNote.size}
|
||||
style={{ minHeight: getMinHeight(optimisticNote.size) }}
|
||||
draggable={true}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', note.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
|
||||
onDragStart?.(note.id)
|
||||
}}
|
||||
onDragEnd={() => onDragEnd?.()}
|
||||
className={cn(
|
||||
'note-card group relative rounded-lg overflow-hidden p-6 border-transparent shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:shadow-md hover:border-border/50 hover:-translate-y-0.5',
|
||||
colorClasses.bg,
|
||||
colorClasses.card,
|
||||
colorClasses.hover,
|
||||
isDragging && 'shadow-lg'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Only trigger edit if not clicking on buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) {
|
||||
// For shared notes, pass readOnly flag
|
||||
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag Handle - Only visible on mobile/touch devices */}
|
||||
<div
|
||||
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
||||
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
|
||||
title={t('notes.dragToReorder') || 'Drag to reorder'}
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Move to Notebook Dropdown Menu */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
|
||||
title={t('notebookSuggestion.moveToNotebook')}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{t('notebookSuggestion.moveToNotebook')}
|
||||
</div>
|
||||
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
|
||||
<StickyNote className="h-4 w-4 mr-2" />
|
||||
{t('notebookSuggestion.generalNotes')}
|
||||
</DropdownMenuItem>
|
||||
{notebooks.map((notebook: any) => {
|
||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={notebook.id}
|
||||
onClick={() => handleMoveToNotebook(notebook.id)}
|
||||
>
|
||||
<NotebookIcon className="h-4 w-4 mr-2" />
|
||||
{notebook.name}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Pin Button - Visible on hover or if pinned */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="pin-button"
|
||||
className={cn(
|
||||
"absolute top-2 right-12 z-20 h-8 w-8 p-0 rounded-md transition-opacity",
|
||||
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTogglePin()
|
||||
}}
|
||||
>
|
||||
<Pin
|
||||
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
{/* Reminder Icon - Move slightly if pin button is there */}
|
||||
{note.reminder && new Date(note.reminder) > new Date() && (
|
||||
<Bell
|
||||
className="absolute top-3 right-10 h-4 w-4 text-primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fusion Badge */}
|
||||
{optimisticNote.aiProvider === 'fusion' && (
|
||||
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative mb-2 w-fit">
|
||||
<Link2 className="h-2.5 w-2.5" />
|
||||
{t('memoryEcho.fused')}
|
||||
<button
|
||||
onClick={handleRemoveFusedBadge}
|
||||
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
||||
title={t('notes.remove') || 'Remove'}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{optimisticNote.title && (
|
||||
<h3 className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
|
||||
{optimisticNote.title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Search Match Type Badge */}
|
||||
{optimisticNote.matchType && (
|
||||
<Badge
|
||||
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'mb-2 text-xs',
|
||||
optimisticNote.matchType === 'exact'
|
||||
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
|
||||
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Shared badge */}
|
||||
{isSharedNote && owner && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
|
||||
{t('notes.sharedBy')} {owner.name || owner.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleLeaveShare()
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-3 w-3 mr-1" />
|
||||
{t('notes.leaveShare')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images Component */}
|
||||
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
|
||||
|
||||
{/* Link Previews */}
|
||||
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{optimisticNote.links.map((link, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{link.imageUrl && (
|
||||
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
||||
)}
|
||||
<div className="p-2">
|
||||
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
|
||||
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
|
||||
<span className="text-[10px] text-primary mt-1 block">
|
||||
{new URL(link.url).hostname}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{optimisticNote.type === 'text' ? (
|
||||
<div className="text-sm text-foreground line-clamp-10">
|
||||
<MarkdownContent
|
||||
content={optimisticNote.content}
|
||||
className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<NoteChecklist
|
||||
items={optimisticNote.checkItems || []}
|
||||
onToggleItem={handleCheckItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Labels - using shared LabelBadge component */}
|
||||
{optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{optimisticNote.labels.map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with Date only */}
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
{/* Creation Date */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Owner Avatar - Aligned with action buttons at bottom */}
|
||||
{owner && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-2 left-2 z-20",
|
||||
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
|
||||
getAvatarColor(owner.name || owner.email || 'Unknown')
|
||||
)}
|
||||
title={owner.name || owner.email || 'Unknown'}
|
||||
>
|
||||
{getInitials(owner.name || owner.email || '??')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar Component - Always show for now to fix regression */}
|
||||
{true && (
|
||||
<NoteActions
|
||||
isPinned={optimisticNote.isPinned}
|
||||
isArchived={optimisticNote.isArchived}
|
||||
currentColor={optimisticNote.color}
|
||||
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
onColorChange={handleColorChange}
|
||||
onSizeChange={handleSizeChange}
|
||||
onDelete={() => setShowDeleteDialog(true)}
|
||||
onShareCollaborators={() => setShowCollaboratorDialog(true)}
|
||||
isTrashView={isTrashView}
|
||||
onRestore={handleRestore}
|
||||
onPermanentDelete={handlePermanentDelete}
|
||||
onOpenHistory={() => onOpenHistory?.(note)}
|
||||
historyEnabled={noteHistoryEnabled}
|
||||
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Collaborator Dialog */}
|
||||
{currentUserId && note.userId && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<CollaboratorDialog
|
||||
open={showCollaboratorDialog}
|
||||
onOpenChange={setShowCollaboratorDialog}
|
||||
noteId={note.id}
|
||||
noteOwnerId={note.userId}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections Badge - Bottom right (spec: amber, absolute) */}
|
||||
<div className="absolute bottom-2 right-2 z-10">
|
||||
<ConnectionsBadge
|
||||
noteId={note.id}
|
||||
onClick={() => {
|
||||
if (!isNoteOpenInEditor) {
|
||||
setShowConnectionsOverlay(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Connections Overlay */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ConnectionsOverlay
|
||||
isOpen={showConnectionsOverlay}
|
||||
onClose={() => setShowConnectionsOverlay(false)}
|
||||
noteId={note.id}
|
||||
onOpenNote={(connNoteId) => {
|
||||
setShowConnectionsOverlay(false)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('note', connNoteId)
|
||||
router.push(`?${params.toString()}`)
|
||||
}}
|
||||
onCompareNotes={(noteIds) => {
|
||||
setComparisonNotes(noteIds)
|
||||
}}
|
||||
onMergeNotes={async (noteIds) => {
|
||||
const fetchedNotes = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comparison Modal */}
|
||||
{comparisonNotes && comparisonNotesData.length > 0 && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ComparisonModal
|
||||
isOpen={!!comparisonNotes}
|
||||
onClose={() => setComparisonNotes(null)}
|
||||
notes={comparisonNotesData}
|
||||
onOpenNote={(noteId) => {
|
||||
const foundNote = comparisonNotesData.find(n => n.id === noteId)
|
||||
if (foundNote) {
|
||||
onEdit?.(foundNote, false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes.length > 0 && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FusionModal
|
||||
isOpen={fusionNotes.length > 0}
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={async ({ title, content }, options) => {
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
|
||||
: fusionNotes[0].labels || [],
|
||||
color: fusionNotes[0].color,
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
triggerRefresh()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleDelete}>
|
||||
{t('notes.delete') || 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { History, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { getNoteHistory, restoreNoteVersion } from '@/app/actions/notes'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { Note, NoteHistoryEntry } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NoteHistoryModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
note: Note | null
|
||||
enabled: boolean
|
||||
onEnableHistory: () => Promise<void>
|
||||
onRestored: (note: Note) => void
|
||||
}
|
||||
|
||||
function getDateLocale(language: string) {
|
||||
if (language === 'fr') return fr
|
||||
return enUS
|
||||
}
|
||||
|
||||
export function NoteHistoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
note,
|
||||
enabled,
|
||||
onEnableHistory,
|
||||
onRestored,
|
||||
}: NoteHistoryModalProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [entries, setEntries] = useState<NoteHistoryEntry[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isRestoring, startRestoring] = useTransition()
|
||||
const [isEnabling, startEnabling] = useTransition()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !note || !enabled) return
|
||||
|
||||
let cancelled = false
|
||||
setIsLoading(true)
|
||||
getNoteHistory(note.id, 50)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setEntries(result)
|
||||
setSelectedId(result[0]?.id ?? null)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load note history:', error)
|
||||
toast.error(t('general.error'))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, note, enabled, t])
|
||||
|
||||
const selectedEntry = useMemo(
|
||||
() => entries.find((entry) => entry.id === selectedId) ?? null,
|
||||
[entries, selectedId]
|
||||
)
|
||||
|
||||
const handleRestore = () => {
|
||||
if (!note || !selectedEntry) return
|
||||
startRestoring(async () => {
|
||||
try {
|
||||
const restored = await restoreNoteVersion(note.id, selectedEntry.id)
|
||||
onRestored(restored)
|
||||
toast.success(t('notes.historyRestored') || 'Version restaurée')
|
||||
} catch (error) {
|
||||
console.error('Failed to restore history entry:', error)
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEnable = () => {
|
||||
startEnabling(async () => {
|
||||
try {
|
||||
await onEnableHistory()
|
||||
toast.success(t('notes.historyEnabled') || 'History activé')
|
||||
} catch (error) {
|
||||
console.error('Failed to enable history:', error)
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl p-0">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-primary" />
|
||||
{t('notes.history') || 'Historique'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{note?.title || t('notes.untitled') || 'Sans titre'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!enabled ? (
|
||||
<div className="space-y-3 px-6 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('notes.historyDisabledDesc') || "L'historique est désactivé pour votre compte."}
|
||||
</p>
|
||||
<Button onClick={handleEnable} disabled={isEnabling}>
|
||||
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('notes.enableHistory') || "Activer l'historique"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[260px_1fr] gap-0">
|
||||
<div className="max-h-[60vh] overflow-y-auto border-r border-border/60 p-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('general.loading')}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">
|
||||
{t('notes.historyEmpty') || 'Aucune version disponible'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(entry.id)}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-2.5 py-2 text-left transition-colors',
|
||||
selectedId === entry.id
|
||||
? 'border-primary/40 bg-primary/8'
|
||||
: 'border-border/70 hover:bg-muted/60'
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
v{entry.version}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(entry.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: getDateLocale(language),
|
||||
})}
|
||||
</p>
|
||||
{entry.reason && (
|
||||
<p className="mt-1 line-clamp-1 text-[11px] text-muted-foreground">
|
||||
{entry.reason}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
|
||||
{selectedEntry ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('notes.title') || 'Titre'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-foreground">
|
||||
{selectedEntry.title || t('notes.untitled') || 'Sans titre'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('notes.content') || 'Contenu'}
|
||||
</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap rounded-md border border-border/70 bg-muted/30 p-3 text-sm text-foreground">
|
||||
{selectedEntry.content || ''}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="border-t border-border/60 px-6 py-3">
|
||||
{enabled && selectedEntry && (
|
||||
<Button onClick={handleRestore} disabled={isRestoring}>
|
||||
{isRestoring
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <RotateCcw className="mr-2 h-4 w-4" />}
|
||||
{t('notes.restore') || 'Restaurer'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useTransition } from 'react'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
updateNote,
|
||||
toggleArchive,
|
||||
deleteNote,
|
||||
createNote,
|
||||
} from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import {
|
||||
Pin,
|
||||
Palette,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Trash2,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
X,
|
||||
Plus,
|
||||
CheckSquare,
|
||||
FileText,
|
||||
Eye,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Check,
|
||||
RotateCcw,
|
||||
History,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import { EditorImages } from '@/components/editor-images'
|
||||
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||
import { GhostTags } from '@/components/ghost-tags'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
|
||||
interface NoteInlineEditorProps {
|
||||
note: Note
|
||||
onDelete?: (noteId: string) => void
|
||||
onArchive?: (noteId: string) => void
|
||||
onChange?: (noteId: string, fields: Partial<Note>) => void
|
||||
onOpenHistory?: (note: Note) => void
|
||||
noteHistoryEnabled?: boolean
|
||||
colorKey: NoteColor
|
||||
/** If true and the note is a Markdown note, open directly in preview mode */
|
||||
defaultPreviewMode?: boolean
|
||||
}
|
||||
|
||||
function getDateLocale(language: string) {
|
||||
if (language === 'fr') return fr;
|
||||
if (language === 'fa') return require('date-fns/locale').faIR;
|
||||
return enUS;
|
||||
}
|
||||
|
||||
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
|
||||
async function saveInline(
|
||||
id: string,
|
||||
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean }
|
||||
) {
|
||||
await fetch(`/api/notes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export function NoteInlineEditor({
|
||||
note,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onChange,
|
||||
onOpenHistory,
|
||||
noteHistoryEnabled = false,
|
||||
colorKey,
|
||||
defaultPreviewMode = false,
|
||||
}: NoteInlineEditorProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
const userId = session.user.id
|
||||
import('@/app/actions/ai-settings').then(({ getAISettings }) => {
|
||||
getAISettings(userId).then(settings => {
|
||||
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
||||
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
||||
}).catch(err => console.error("Failed to fetch AI settings", err))
|
||||
})
|
||||
}
|
||||
}, [session?.user?.id])
|
||||
const { labels: globalLabels, addLabel } = useLabels()
|
||||
const [, startTransition] = useTransition()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const [content, setContent] = useState(note.content || '')
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
|
||||
defaultPreviewMode && (note.isMarkdown || false)
|
||||
)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
||||
|
||||
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
|
||||
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
|
||||
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
|
||||
|
||||
// Link dialog
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||
const [isAddingLink, setIsAddingLink] = useState(false)
|
||||
|
||||
// AI side panel
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
||||
// Undo after AI copilot applies content
|
||||
const [previousContent, setPreviousContent] = useState<string | null>(null)
|
||||
|
||||
// Notebooks list (for copilot chat scope)
|
||||
const { notebooks } = useNotebooks()
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const pendingRef = useRef({ title, content, checkItems, isMarkdown })
|
||||
const noteIdRef = useRef(note.id)
|
||||
|
||||
// Title suggestions
|
||||
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
||||
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
|
||||
content: note.type === 'text' ? content : '',
|
||||
enabled: note.type === 'text' && !title
|
||||
})
|
||||
|
||||
// Keep pending ref in sync for unmount save
|
||||
useEffect(() => {
|
||||
pendingRef.current = { title, content, checkItems, isMarkdown }
|
||||
}, [title, content, checkItems, isMarkdown])
|
||||
|
||||
// ── Sync when selected note switches ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
// Flush unsaved changes for the PREVIOUS note before switching
|
||||
if (isDirty && noteIdRef.current !== note.id) {
|
||||
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
|
||||
saveInline(noteIdRef.current, {
|
||||
title: t.trim() || null,
|
||||
content: c,
|
||||
checkItems: note.type === 'checklist' ? ci : undefined,
|
||||
isMarkdown: im,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
noteIdRef.current = note.id
|
||||
setTitle(note.title || '')
|
||||
setContent(note.content || '')
|
||||
setCheckItems(note.checkItems || [])
|
||||
setIsMarkdown(note.isMarkdown || false)
|
||||
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
|
||||
setIsDirty(false)
|
||||
setDismissedTitleSuggestions(false)
|
||||
clearTimeout(saveTimerRef.current)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [note.id])
|
||||
|
||||
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
|
||||
const scheduleSave = useCallback(() => {
|
||||
setIsDirty(true)
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await saveInline(noteIdRef.current, {
|
||||
title: t.trim() || null,
|
||||
content: c,
|
||||
checkItems: note.type === 'checklist' ? ci : undefined,
|
||||
isMarkdown: im,
|
||||
})
|
||||
setIsDirty(false)
|
||||
} catch {
|
||||
// silent — retry on next keystroke
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, 1500)
|
||||
}, [note.type])
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
|
||||
saveInline(noteIdRef.current, {
|
||||
title: t.trim() || null,
|
||||
content: c,
|
||||
checkItems: note.type === 'checklist' ? ci : undefined,
|
||||
isMarkdown: im,
|
||||
}).catch(() => {})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ── Auto-tagging ──────────────────────────────────────────────────────────
|
||||
const { suggestions, isAnalyzing } = useAutoTagging({
|
||||
content: note.type === 'text' ? content : '',
|
||||
notebookId: note.notebookId,
|
||||
enabled: note.type === 'text' && autoLabelingEnabled,
|
||||
})
|
||||
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
|
||||
const filteredSuggestions = suggestions.filter(
|
||||
(s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
||||
)
|
||||
const handleSelectGhostTag = async (tag: string) => {
|
||||
const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase())
|
||||
if (!exists) {
|
||||
const newLabels = [...(note.labels || []), tag]
|
||||
// Optimistic UI — update sidebar immediately, no page refresh needed
|
||||
onChange?.(note.id, { labels: newLabels })
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try { await addLabel(tag) } catch {}
|
||||
}
|
||||
toast.success(t('ai.tagAdded', { tag }))
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNotesByIds = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
return fetched.filter((n: any) => n !== null) as Array<Partial<Note>>
|
||||
}
|
||||
|
||||
const handleMergeNotes = async (noteIds: string[]) => {
|
||||
setFusionNotes(await fetchNotesByIds(noteIds))
|
||||
}
|
||||
|
||||
const handleCompareNotes = async (noteIds: string[]) => {
|
||||
setComparisonNotes(await fetchNotesByIds(noteIds))
|
||||
}
|
||||
|
||||
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
|
||||
: fusionNotes[0].labels || [],
|
||||
color: fusionNotes[0].color,
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
triggerRefresh()
|
||||
}
|
||||
|
||||
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
||||
const handleTogglePin = () => {
|
||||
const prev = note.isPinned
|
||||
startTransition(async () => {
|
||||
onChange?.(note.id, { isPinned: !prev })
|
||||
try {
|
||||
await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true })
|
||||
toast.success(prev ? t('notes.unpinned') : t('notes.pinned') )
|
||||
} catch {
|
||||
onChange?.(note.id, { isPinned: prev })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleArchive = () => {
|
||||
startTransition(async () => {
|
||||
onArchive?.(note.id)
|
||||
try {
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
triggerRefresh()
|
||||
} catch {
|
||||
// Cannot easily revert since onArchive removes from list
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
const prev = color
|
||||
startTransition(async () => {
|
||||
onChange?.(note.id, { color })
|
||||
try {
|
||||
await updateNote(note.id, { color }, { skipRevalidation: true })
|
||||
} catch {
|
||||
onChange?.(note.id, { color: prev })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(t('notes.confirmDelete'))) return
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
triggerRefresh()
|
||||
})
|
||||
}
|
||||
|
||||
// ── Image upload ──────────────────────────────────────────────────────────
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
const newImages = [...(note.images || []), url]
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||
}
|
||||
}
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const uploadImageFile = async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!res.ok) throw new Error('Upload failed')
|
||||
const data = await res.json()
|
||||
return data.url
|
||||
}
|
||||
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
const newImages = [...(note.images || []), url]
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
}, [note.id, note.images, onChange, t])
|
||||
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
}
|
||||
|
||||
// ── Link ──────────────────────────────────────────────────────────────────
|
||||
const handleAddLink = async () => {
|
||||
if (!linkUrl) return
|
||||
setIsAddingLink(true)
|
||||
try {
|
||||
const metadata = await fetchLinkMetadata(linkUrl)
|
||||
const newLink = metadata || { url: linkUrl, title: linkUrl }
|
||||
const newLinks = [...(note.links || []), newLink]
|
||||
onChange?.(note.id, { links: newLinks })
|
||||
await updateNote(note.id, { links: newLinks })
|
||||
toast.success(t('notes.linkAdded'))
|
||||
} catch {
|
||||
toast.error(t('notes.linkAddFailed'))
|
||||
} finally {
|
||||
setLinkUrl('')
|
||||
setShowLinkInput(false)
|
||||
setIsAddingLink(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLink = async (index: number) => {
|
||||
const newLinks = (note.links || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { links: newLinks })
|
||||
await updateNote(note.id, { links: newLinks })
|
||||
}
|
||||
|
||||
// ── Checklist helpers ─────────────────────────────────────────────────────
|
||||
const handleToggleCheckItem = (id: string) => {
|
||||
const updated = checkItems.map((ci) =>
|
||||
ci.id === id ? { ...ci, checked: !ci.checked } : ci
|
||||
)
|
||||
setCheckItems(updated)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const handleUpdateCheckText = (id: string, text: string) => {
|
||||
const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci))
|
||||
setCheckItems(updated)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const handleAddCheckItem = () => {
|
||||
const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }]
|
||||
setCheckItems(updated)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const handleRemoveCheckItem = (id: string) => {
|
||||
const updated = checkItems.filter((ci) => ci.id !== id)
|
||||
setCheckItems(updated)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const dateLocale = getDateLocale(language)
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<div className="flex flex-1 min-w-0 flex-col overflow-hidden transition-all duration-300">
|
||||
|
||||
{/* ── Toolbar ───────────────────────────────────────────────── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-1.5 gap-2">
|
||||
|
||||
{/* Left group: content tools */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
title={t('notes.addImage') }
|
||||
onClick={() => fileInputRef.current?.click()}>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
title={t('notes.addLink') }
|
||||
onClick={() => setShowLinkInput(!showLinkInput)}>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon"
|
||||
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
|
||||
onClick={() => {
|
||||
const nextIsMarkdown = !isMarkdown
|
||||
setIsMarkdown(nextIsMarkdown)
|
||||
onChange?.(note.id, { isMarkdown: nextIsMarkdown })
|
||||
if (!nextIsMarkdown) setShowMarkdownPreview(false)
|
||||
scheduleSave()
|
||||
}}
|
||||
title="Markdown">
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{isMarkdown && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{note.type === 'text' && aiAssistantEnabled && (
|
||||
<Button variant="ghost" size="sm"
|
||||
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
|
||||
onClick={() => setAiOpen(!aiOpen)}
|
||||
title={t('ai.aiCopilot')}>
|
||||
{isProcessingAI
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Sparkles className="h-3.5 w-3.5" />}
|
||||
<span className="hidden sm:inline">{t('ai.aiCopilot')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{previousContent !== null && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600"
|
||||
title={t('ai.undoAI') }
|
||||
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right group: meta actions + save indicator */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
|
||||
) : isDirty ? (
|
||||
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
|
||||
) : (
|
||||
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="grid grid-cols-5 gap-2 p-2">
|
||||
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
|
||||
<button type="button" key={name}
|
||||
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', cls.bg,
|
||||
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
|
||||
onClick={() => handleColorChange(name)} title={name} />
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.moreOptions')}>
|
||||
<span className="text-base leading-none text-muted-foreground">⋯</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleToggleArchive}>
|
||||
{note.isArchived
|
||||
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
|
||||
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
|
||||
</DropdownMenuItem>
|
||||
{onOpenHistory && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenHistory(note)}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
{noteHistoryEnabled
|
||||
? (t('notes.history') || 'Historique')
|
||||
: (t('notes.enableHistory') || "Activer l'historique")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
|
||||
{showLinkInput && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
|
||||
<input
|
||||
type="url"
|
||||
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
|
||||
placeholder="https://..."
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
|
||||
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : t('notes.add')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
|
||||
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
|
||||
{/* Existing labels */}
|
||||
{(note.labels ?? []).map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
{/* AI-suggested tags inline with labels */}
|
||||
<GhostTags
|
||||
suggestions={filteredSuggestions}
|
||||
addedTags={note.labels || []}
|
||||
isAnalyzing={isAnalyzing}
|
||||
onSelectTag={handleSelectGhostTag}
|
||||
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scrollable editing area ── */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
|
||||
{/* Title */}
|
||||
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
dir="auto"
|
||||
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||
value={title}
|
||||
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
||||
/>
|
||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||
<button type="button"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/suggest-title', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const suggested = data.title || data.suggestedTitle || ''
|
||||
if (suggested) { changeTitle(suggested); scheduleSave() }
|
||||
}
|
||||
} catch { } finally { setIsProcessingAI(false) }
|
||||
}}
|
||||
disabled={isProcessingAI}
|
||||
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
|
||||
title={t('ai.suggestTitle')}
|
||||
>
|
||||
{isProcessingAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title Suggestions Dropdown / Inline list */}
|
||||
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
|
||||
<div className="mt-2 text-sm shrink-0">
|
||||
<TitleSuggestions
|
||||
suggestions={titleSuggestions}
|
||||
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
|
||||
onDismiss={() => setDismissedTitleSuggestions(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images */}
|
||||
{Array.isArray(note.images) && note.images.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<EditorImages images={note.images} onRemove={handleRemoveImage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link previews */}
|
||||
{Array.isArray(note.links) && note.links.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{note.links.map((link, idx) => (
|
||||
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
|
||||
{link.imageUrl && (
|
||||
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
|
||||
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
|
||||
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
|
||||
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button"
|
||||
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
|
||||
onClick={() => handleRemoveLink(idx)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Text / Checklist content ───────────────────────────────────── */}
|
||||
<div className="mt-4 flex flex-1 flex-col">
|
||||
{note.type === 'text' ? (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{showMarkdownPreview && isMarkdown ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
|
||||
<MarkdownContent content={content || ''} />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown')
|
||||
: t('notes.takeNote')
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ghost tag suggestions are now shown in the top labels strip */}
|
||||
</div>
|
||||
) : (
|
||||
/* Checklist */
|
||||
<div className="space-y-1">
|
||||
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
|
||||
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
|
||||
<button type="button"
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
|
||||
onClick={() => handleToggleCheckItem(ci.id)}
|
||||
/>
|
||||
<input
|
||||
dir="auto"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
|
||||
value={ci.text}
|
||||
placeholder={t('notes.listItem') }
|
||||
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
|
||||
/>
|
||||
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button"
|
||||
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
|
||||
onClick={handleAddCheckItem}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('notes.addItem') }
|
||||
</button>
|
||||
|
||||
{checkItems.filter((ci) => ci.checked).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
|
||||
{t('notes.completedLabel')} ({checkItems.filter((ci) => ci.checked).length})
|
||||
</p>
|
||||
{checkItems.filter((ci) => ci.checked).map((ci) => (
|
||||
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
|
||||
<button type="button"
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
|
||||
onClick={() => handleToggleCheckItem(ci.id)}
|
||||
>
|
||||
<CheckSquare className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
|
||||
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section ── */}
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onOpenNote={(connNoteId) => {
|
||||
window.open(`/?note=${connNoteId}`, '_blank')
|
||||
}}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
/>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
||||
<div className="shrink-0 border-t border-border/20 px-8 py-2">
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
|
||||
<span>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
|
||||
<span>·</span>
|
||||
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes.length > 0 && (
|
||||
<FusionModal
|
||||
isOpen={fusionNotes.length > 0}
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={handleConfirmFusion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comparison Modal */}
|
||||
{comparisonNotes.length > 0 && (
|
||||
<ComparisonModal
|
||||
isOpen={comparisonNotes.length > 0}
|
||||
onClose={() => setComparisonNotes([])}
|
||||
notes={comparisonNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── AI Copilot Side Panel ── */}
|
||||
{aiOpen && (
|
||||
<ContextualAIChat
|
||||
onClose={() => setAiOpen(false)}
|
||||
noteTitle={title}
|
||||
noteContent={content}
|
||||
noteImages={note.images || undefined}
|
||||
onApplyToNote={(newContent) => {
|
||||
setPreviousContent(content)
|
||||
changeContent(newContent)
|
||||
scheduleSave()
|
||||
}}
|
||||
onUndoLastAction={previousContent !== null ? () => {
|
||||
changeContent(previousContent)
|
||||
setPreviousContent(null)
|
||||
scheduleSave()
|
||||
} : undefined}
|
||||
lastActionApplied={previousContent !== null}
|
||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { NotesTabsView } from '@/components/notes-tabs-view'
|
||||
|
||||
const MasonryGridLazy = dynamic(
|
||||
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="min-h-[200px] rounded-xl border border-dashed border-muted-foreground/20 bg-muted/30 animate-pulse"
|
||||
aria-hidden
|
||||
/>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export type NotesViewMode = 'masonry' | 'tabs'
|
||||
|
||||
interface NotesMainSectionProps {
|
||||
notes: Note[]
|
||||
viewMode: NotesViewMode
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
||||
currentNotebookId?: string | null
|
||||
noteHistoryEnabled?: boolean
|
||||
onOpenHistory?: (note: Note) => void
|
||||
onEnableHistory?: () => Promise<void>
|
||||
}
|
||||
|
||||
export function NotesMainSection({
|
||||
notes,
|
||||
viewMode,
|
||||
onEdit,
|
||||
onSizeChange,
|
||||
currentNotebookId,
|
||||
noteHistoryEnabled = false,
|
||||
onOpenHistory,
|
||||
onEnableHistory,
|
||||
}: NotesMainSectionProps) {
|
||||
if (viewMode === 'tabs') {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
||||
<NotesTabsView
|
||||
notes={notes}
|
||||
onEdit={onEdit}
|
||||
currentNotebookId={currentNotebookId}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onEnableHistory={onEnableHistory}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="notes-grid">
|
||||
<MasonryGridLazy
|
||||
notes={notes}
|
||||
onEdit={onEdit}
|
||||
onSizeChange={onSizeChange}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
994
Momento-main/momento/memento-note/components/notes-tabs-view.tsx
Normal file
994
Momento-main/momento/memento-note/components/notes-tabs-view.tsx
Normal file
@@ -0,0 +1,994 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
|
||||
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
verticalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { NoteInlineEditor } from '@/components/note-inline-editor'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import {
|
||||
updateFullOrderWithoutRevalidation,
|
||||
createNote,
|
||||
deleteNote,
|
||||
updateNote,
|
||||
toggleArchive,
|
||||
} from '@/app/actions/notes'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import {
|
||||
GripVertical,
|
||||
ListChecks,
|
||||
Pin,
|
||||
PinOff,
|
||||
FileText,
|
||||
Plus,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ListFilter,
|
||||
FolderInput,
|
||||
Archive,
|
||||
Share2,
|
||||
Check,
|
||||
Hash,
|
||||
History,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { toast } from 'sonner'
|
||||
import { format, type Locale } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
|
||||
interface NotesTabsViewProps {
|
||||
notes: Note[]
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
currentNotebookId?: string | null
|
||||
noteHistoryEnabled?: boolean
|
||||
onOpenHistory?: (note: Note) => void
|
||||
onEnableHistory?: () => Promise<void>
|
||||
}
|
||||
|
||||
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
|
||||
|
||||
// Color accent strip for each note
|
||||
const COLOR_ACCENT: Record<NoteColor, string> = {
|
||||
default: 'bg-primary',
|
||||
red: 'bg-red-400',
|
||||
orange: 'bg-orange-400',
|
||||
yellow: 'bg-amber-400',
|
||||
green: 'bg-emerald-400',
|
||||
teal: 'bg-teal-400',
|
||||
blue: 'bg-sky-400',
|
||||
purple: 'bg-violet-400',
|
||||
pink: 'bg-fuchsia-400',
|
||||
gray: 'bg-gray-400',
|
||||
}
|
||||
|
||||
// Background tint gradient for selected note panel
|
||||
const COLOR_PANEL_BG: Record<NoteColor, string> = {
|
||||
default: 'from-background to-background',
|
||||
red: 'from-red-50/60 dark:from-red-950/20 to-background',
|
||||
orange: 'from-orange-50/60 dark:from-orange-950/20 to-background',
|
||||
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
|
||||
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
|
||||
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
|
||||
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
|
||||
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
|
||||
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
|
||||
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
|
||||
}
|
||||
|
||||
const COLOR_ICON: Record<NoteColor, string> = {
|
||||
default: 'text-primary',
|
||||
red: 'text-red-500',
|
||||
orange: 'text-orange-500',
|
||||
yellow: 'text-amber-500',
|
||||
green: 'text-emerald-500',
|
||||
teal: 'text-teal-500',
|
||||
blue: 'text-sky-500',
|
||||
purple: 'text-violet-500',
|
||||
pink: 'text-fuchsia-500',
|
||||
gray: 'text-gray-500',
|
||||
}
|
||||
|
||||
function getColorKey(note: Note): NoteColor {
|
||||
return (typeof note.color === 'string' && note.color in NOTE_COLORS
|
||||
? note.color
|
||||
: 'default') as NoteColor
|
||||
}
|
||||
|
||||
function getDateLocale(language: string) {
|
||||
if (language === 'fr') return fr
|
||||
if (language === 'fa') return require('date-fns/locale').faIR
|
||||
return enUS
|
||||
}
|
||||
|
||||
function formatNoteDate(date: Date | string, locale: Locale): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return format(d, 'd MMM yyyy', { locale })
|
||||
}
|
||||
|
||||
function countWords(content: string | null | undefined): number {
|
||||
if (!content) return 0
|
||||
return content.trim().split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
|
||||
// ─── Sortable List Item ───────────────────────────────────────────────────────
|
||||
|
||||
function SortableNoteListItem({
|
||||
note,
|
||||
selected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
reorderLabel,
|
||||
deleteLabel,
|
||||
language,
|
||||
untitledLabel,
|
||||
}: {
|
||||
note: Note
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
reorderLabel: string
|
||||
deleteLabel: string
|
||||
language: string
|
||||
untitledLabel: string
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: note.id,
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
}
|
||||
|
||||
const ck = getColorKey(note)
|
||||
const title = getNoteDisplayTitle(note, untitledLabel)
|
||||
const snippet =
|
||||
note.type === 'checklist'
|
||||
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 200)
|
||||
: (note.content || '').substring(0, 200)
|
||||
|
||||
const dateLocale = getDateLocale(language)
|
||||
const dateStr = formatNoteDate(note.updatedAt, dateLocale)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'group relative flex cursor-pointer select-none items-stretch transition-all duration-150',
|
||||
'border-b border-border/50 last:border-b-0',
|
||||
selected
|
||||
? 'bg-primary/[0.06] dark:bg-primary/10'
|
||||
: 'bg-background hover:bg-muted/40 dark:hover:bg-muted/20',
|
||||
isDragging && 'opacity-75 shadow-lg ring-1 ring-primary/20'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
{/* Left accent bar — solid when selected, transparent otherwise */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-[3px] shrink-0 rounded-r-full transition-all duration-200',
|
||||
selected ? COLOR_ACCENT[ck] : 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Main card content */}
|
||||
<div className="min-w-0 flex-1 px-4 py-4">
|
||||
|
||||
{/* Row 1: type icon + date */}
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{note.type === 'checklist' ? (
|
||||
<ListChecks
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0',
|
||||
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FileText
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0',
|
||||
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{note.isPinned && (
|
||||
<Pin className="h-3 w-3 shrink-0 fill-current text-primary/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'shrink-0 text-[11px] tabular-nums',
|
||||
selected ? 'text-muted-foreground' : 'text-muted-foreground/60'
|
||||
)}>
|
||||
{dateStr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: title */}
|
||||
<p
|
||||
className={cn(
|
||||
'mb-1.5 text-[13.5px] leading-snug transition-colors',
|
||||
selected
|
||||
? 'font-semibold text-foreground'
|
||||
: 'font-medium text-foreground/85 group-hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{/* Row 3: snippet */}
|
||||
{snippet && (
|
||||
<p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Row 4: label chips */}
|
||||
{Array.isArray(note.labels) && note.labels.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{note.labels.slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium leading-none transition-colors',
|
||||
selected
|
||||
? 'border-primary/25 text-primary/70'
|
||||
: 'border-border text-muted-foreground/65 group-hover:border-border/80'
|
||||
)}
|
||||
>
|
||||
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{note.labels.length > 3 && (
|
||||
<span className="inline-flex items-center rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground/50">
|
||||
+{note.labels.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions column: drag + delete on hover */}
|
||||
<div className="flex flex-col items-center justify-between py-3 pe-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab p-1 text-muted-foreground/30 active:cursor-grabbing"
|
||||
aria-label={reorderLabel}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="p-1 text-muted-foreground/40 hover:text-destructive"
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Note Meta Sidebar ────────────────────────────────────────────────────────
|
||||
|
||||
function SidebarSection({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.12em] text-muted-foreground whitespace-nowrap">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarActionBtn({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
|
||||
disabled
|
||||
? "cursor-not-allowed text-muted-foreground/60 opacity-70"
|
||||
: "text-foreground/70 hover:bg-sky-50 hover:text-sky-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-sky-600"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteMetaSidebar({
|
||||
note,
|
||||
onPinToggle,
|
||||
onArchive,
|
||||
noteHistoryEnabled = false,
|
||||
onOpenHistory,
|
||||
onEnableHistory,
|
||||
}: {
|
||||
note: Note
|
||||
onPinToggle: (note: Note) => void
|
||||
onArchive: (note: Note) => void
|
||||
noteHistoryEnabled?: boolean
|
||||
onOpenHistory?: (note: Note) => void
|
||||
onEnableHistory?: () => Promise<void>
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
const [moveOpen, setMoveOpen] = useState(false)
|
||||
const [isMoving, setIsMoving] = useState(false)
|
||||
|
||||
// t() returns the key itself when not found — use this wrapper for safe fallbacks
|
||||
const ts = (key: string, fallback: string) => {
|
||||
const v = t(key as Parameters<typeof t>[0])
|
||||
return v === key ? fallback : v
|
||||
}
|
||||
|
||||
const wordCount = countWords(note.content)
|
||||
|
||||
const noteTypeLabel =
|
||||
note.type === 'checklist'
|
||||
? ts('notes.typeChecklist', 'Checklist')
|
||||
: note.isMarkdown
|
||||
? ts('notes.typeMarkdown', 'Markdown')
|
||||
: ts('notes.typeText', 'Text')
|
||||
|
||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
||||
setIsMoving(true)
|
||||
try {
|
||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
||||
setMoveOpen(false)
|
||||
toast.success(ts('notebookSuggestion.movedToNotebook', 'Note déplacée'))
|
||||
} catch {
|
||||
toast.error(ts('notes.moveFailed', 'Déplacement échoué'))
|
||||
} finally {
|
||||
setIsMoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHistory = async () => {
|
||||
if (!noteHistoryEnabled) {
|
||||
if (!onEnableHistory) {
|
||||
toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour votre compte."))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await onEnableHistory()
|
||||
toast.success(ts('notes.historyEnabled', 'Historique activé'))
|
||||
onOpenHistory?.(note)
|
||||
} catch {
|
||||
toast.error(ts('general.error', 'Erreur'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
onOpenHistory?.(note)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex w-56 shrink-0 flex-col bg-muted border-l border-slate-300 border-t-2 border-t-primary/40 overflow-y-auto shadow-[-6px_0_16px_-4px_rgba(0,0,0,0.08)]">
|
||||
|
||||
{/* ── DOCUMENT INFO ── */}
|
||||
<div className="px-4 pt-5 pb-4 border-b border-border">
|
||||
<SidebarSection title={ts('notes.documentInfo', 'Document Info')} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Type */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.type', 'Type')}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||
note.type === 'checklist'
|
||||
? "bg-emerald-50 border-emerald-200 text-emerald-700"
|
||||
: note.isMarkdown
|
||||
? "bg-violet-50 border-violet-200 text-violet-700"
|
||||
: "bg-card border-slate-200 text-slate-600"
|
||||
)}
|
||||
>
|
||||
{note.type === 'checklist'
|
||||
? <ListChecks className="h-3 w-3" />
|
||||
: <FileText className="h-3 w-3" />}
|
||||
{noteTypeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Word count — discreet inline row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.wordCount', 'Mots')}</p>
|
||||
<p className="text-[13px] font-semibold text-foreground/70 tabular-nums">
|
||||
{wordCount.toLocaleString()} <span className="text-[10px] font-normal text-muted-foreground">{ts('notes.words', 'mots')}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{Array.isArray(note.labels) && note.labels.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-semibold text-muted-foreground">{ts('notes.labels', 'Labels')}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{note.labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card px-2 py-0.5 text-[11px] font-medium text-foreground/70"
|
||||
>
|
||||
<Hash className="h-2.5 w-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── ACTIONS ── */}
|
||||
<div className="px-4 pt-4 pb-5 flex-1">
|
||||
<SidebarSection title={ts('notes.actions', 'Actions')} />
|
||||
|
||||
<div className="space-y-0.5">
|
||||
|
||||
{/* Move to notebook */}
|
||||
<Popover open={moveOpen} onOpenChange={setMoveOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-sky-50 hover:text-sky-700 transition-all duration-150"
|
||||
>
|
||||
<span className="text-muted-foreground group-hover:text-sky-600 transition-colors">
|
||||
{isMoving
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <FolderInput className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
{t('notebookSuggestion.moveToNotebook')}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="left" align="start" className="w-52 p-1.5">
|
||||
<div className="mb-1 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
|
||||
{t('notebookSuggestion.moveToNotebook')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveToNotebook(null)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
|
||||
!note.notebookId ? 'text-primary' : 'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{!note.notebookId
|
||||
? <Check className="h-3 w-3 shrink-0" />
|
||||
: <span className="h-3 w-3 shrink-0" />}
|
||||
{t('notes.generalNotes')}
|
||||
</button>
|
||||
{notebooks.map((nb) => (
|
||||
<button
|
||||
key={nb.id}
|
||||
type="button"
|
||||
onClick={() => handleMoveToNotebook(nb.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
|
||||
note.notebookId === nb.id ? 'text-primary' : 'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{note.notebookId === nb.id
|
||||
? <Check className="h-3 w-3 shrink-0" />
|
||||
: <span className="h-3 w-3 shrink-0" />}
|
||||
{nb.name}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Pin / Unpin */}
|
||||
<SidebarActionBtn
|
||||
icon={note.isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
|
||||
onClick={() => onPinToggle(note)}
|
||||
disabled
|
||||
/>
|
||||
|
||||
{/* Archive */}
|
||||
<SidebarActionBtn
|
||||
icon={<Archive className="h-3.5 w-3.5" />}
|
||||
label={t('notes.archive')}
|
||||
onClick={() => onArchive(note)}
|
||||
/>
|
||||
|
||||
{/* History */}
|
||||
<SidebarActionBtn
|
||||
icon={<History className="h-3.5 w-3.5" />}
|
||||
label={
|
||||
noteHistoryEnabled
|
||||
? ts('notes.history', 'Historique')
|
||||
: ts('notes.enableHistory', "Activer l'historique")
|
||||
}
|
||||
onClick={() => void handleHistory()}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function NotesTabsView({
|
||||
notes,
|
||||
onEdit,
|
||||
currentNotebookId,
|
||||
noteHistoryEnabled = false,
|
||||
onOpenHistory,
|
||||
onEnableHistory,
|
||||
}: NotesTabsViewProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { triggerRefresh } = useNoteRefreshOptional()
|
||||
const [items, setItems] = useState<Note[]>(notes)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [isCreating, startCreating] = useTransition()
|
||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('date-desc')
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setItems((prev) => {
|
||||
const prevIds = prev.map((n) => n.id).join(',')
|
||||
const incomingIds = notes.map((n) => n.id).join(',')
|
||||
if (prevIds === incomingIds) {
|
||||
return prev.map((p) => {
|
||||
const fresh = notes.find((n) => n.id === p.id)
|
||||
if (!fresh) return p
|
||||
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
|
||||
return {
|
||||
...fresh,
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
checkItems: p.checkItems,
|
||||
labels: labelsChanged ? fresh.labels : p.labels
|
||||
}
|
||||
})
|
||||
}
|
||||
return notes.map((fresh) => {
|
||||
const local = prev.find((p) => p.id === fresh.id)
|
||||
if (!local) return fresh
|
||||
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort())
|
||||
return {
|
||||
...fresh,
|
||||
title: local.title,
|
||||
content: local.content,
|
||||
checkItems: local.checkItems,
|
||||
labels: labelsChanged ? fresh.labels : local.labels
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [notes])
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) {
|
||||
setSelectedId(null)
|
||||
return
|
||||
}
|
||||
setSelectedId((prev) =>
|
||||
prev && items.some((n) => n.id === prev) ? prev : items[0].id
|
||||
)
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { name } = (e as CustomEvent).detail
|
||||
if (!name) return
|
||||
setItems((prev) =>
|
||||
prev.map((note) => {
|
||||
const currentLabels = note.labels || []
|
||||
const updated = currentLabels.filter((l) => l.toLowerCase() !== name.toLowerCase())
|
||||
if (updated.length === currentLabels.length) return note
|
||||
return { ...note, labels: updated.length > 0 ? updated : null }
|
||||
})
|
||||
)
|
||||
}
|
||||
window.addEventListener('label-deleted', handler)
|
||||
return () => window.removeEventListener('label-deleted', handler)
|
||||
}, [])
|
||||
|
||||
// Sorted display items (does NOT affect persisted order)
|
||||
const sortedItems = useMemo(() => {
|
||||
if (sortOrder === 'date-desc') return [...items]
|
||||
return [...items].sort((a, b) => {
|
||||
if (sortOrder === 'date-asc') {
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
}
|
||||
if (sortOrder === 'title-asc') {
|
||||
return (a.title || '').localeCompare(b.title || '')
|
||||
}
|
||||
if (sortOrder === 'title-desc') {
|
||||
return (b.title || '').localeCompare(a.title || '')
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}, [items, sortOrder])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIndex = items.findIndex((n) => n.id === active.id)
|
||||
const newIndex = items.findIndex((n) => n.id === over.id)
|
||||
if (oldIndex < 0 || newIndex < 0) return
|
||||
const reordered = arrayMove(items, oldIndex, newIndex)
|
||||
setItems(reordered)
|
||||
try {
|
||||
await updateFullOrderWithoutRevalidation(reordered.map((n) => n.id))
|
||||
} catch {
|
||||
setItems(notes)
|
||||
toast.error(t('notes.moveFailed'))
|
||||
}
|
||||
},
|
||||
[items, notes, t]
|
||||
)
|
||||
|
||||
const selected = items.find((n) => n.id === selectedId) ?? null
|
||||
const colorKey = selected ? getColorKey(selected) : 'default'
|
||||
|
||||
const handleCreateNote = () => {
|
||||
startCreating(async () => {
|
||||
try {
|
||||
const newNote = await createNote({
|
||||
content: '',
|
||||
title: undefined,
|
||||
notebookId: currentNotebookId || undefined,
|
||||
skipRevalidation: true
|
||||
})
|
||||
if (!newNote) return
|
||||
setItems((prev) => {
|
||||
const pinned = prev.filter(n => n.isPinned)
|
||||
const unpinned = prev.filter(n => !n.isPinned)
|
||||
return [...pinned, newNote, ...unpinned]
|
||||
})
|
||||
setSelectedId(newNote.id)
|
||||
triggerRefresh()
|
||||
} catch {
|
||||
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlePinToggle = async (note: Note) => {
|
||||
const next = !note.isPinned
|
||||
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
|
||||
try {
|
||||
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
|
||||
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
|
||||
} catch {
|
||||
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
|
||||
toast.error(t('notes.updateFailed') || 'Mise à jour échouée')
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (note: Note) => {
|
||||
try {
|
||||
await toggleArchive(note.id, true)
|
||||
setItems((prev) => prev.filter((n) => n.id !== note.id))
|
||||
setSelectedId((prev) => (prev === note.id ? null : prev))
|
||||
triggerRefresh()
|
||||
toast.success(t('notes.archived') || 'Note archivée')
|
||||
} catch {
|
||||
toast.error(t('notes.archiveFailed') || 'Archivage échoué')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-xl border border-border/70 shadow-sm"
|
||||
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
|
||||
data-testid="notes-grid-tabs"
|
||||
>
|
||||
{/* ── Left panel: note list ── */}
|
||||
<div className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-4 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold tracking-tight text-foreground">
|
||||
{t('notes.title')}
|
||||
</span>
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Sort / filter button */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary',
|
||||
sortOrder !== 'date-desc' && 'text-primary bg-primary/8'
|
||||
)}
|
||||
title={t('notes.sort') || 'Trier'}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60 py-1">
|
||||
{t('notes.sortBy') || 'Trier par'}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup value={sortOrder} onValueChange={(v) => setSortOrder(v as SortOrder)}>
|
||||
<DropdownMenuRadioItem value="date-desc" className="text-[13px]">
|
||||
{t('notes.sortDateDesc') || 'Date (récent)'}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="date-asc" className="text-[13px]">
|
||||
{t('notes.sortDateAsc') || 'Date (ancien)'}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="title-asc" className="text-[13px]">
|
||||
{t('notes.sortTitleAsc') || 'Titre A → Z'}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="title-desc" className="text-[13px]">
|
||||
{t('notes.sortTitleDesc') || 'Titre Z → A'}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* New note button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
|
||||
onClick={handleCreateNote}
|
||||
disabled={isCreating}
|
||||
title={t('notes.newNote')}
|
||||
>
|
||||
{isCreating
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Plus className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable note list */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overscroll-contain bg-background"
|
||||
role="listbox"
|
||||
aria-label={t('notes.viewTabs')}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="mb-4 rounded-2xl border border-border/60 bg-muted/30 p-4">
|
||||
<FileText className="h-6 w-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{t('notes.emptyStateTabs') || 'Aucune note'}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">{t('notes.createFirst') || 'Créez votre première note'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
id="notes-tabs-dnd"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((n) => n.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{sortedItems.map((note) => (
|
||||
<SortableNoteListItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
selected={note.id === selectedId}
|
||||
onSelect={() => setSelectedId(note.id)}
|
||||
onDelete={() => setNoteToDelete(note)}
|
||||
reorderLabel={t('notes.reorderTabs')}
|
||||
deleteLabel={t('notes.delete')}
|
||||
language={language}
|
||||
untitledLabel={t('notes.untitled')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right content panel ── */}
|
||||
{selected ? (
|
||||
<div className="flex min-w-0 flex-1 overflow-hidden">
|
||||
{/* Editor */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-b',
|
||||
COLOR_PANEL_BG[colorKey]
|
||||
)}
|
||||
>
|
||||
<NoteInlineEditor
|
||||
key={selected.id}
|
||||
note={selected}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
colorKey={colorKey}
|
||||
defaultPreviewMode={true}
|
||||
onChange={(noteId, fields) => {
|
||||
setItems((prev) =>
|
||||
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
|
||||
)
|
||||
}}
|
||||
onDelete={(noteId) => {
|
||||
setItems((prev) => prev.filter((n) => n.id !== noteId))
|
||||
setSelectedId((prev) => (prev === noteId ? null : prev))
|
||||
}}
|
||||
onArchive={(noteId) => {
|
||||
setItems((prev) => prev.filter((n) => n.id !== noteId))
|
||||
setSelectedId((prev) => (prev === noteId ? null : prev))
|
||||
triggerRefresh()
|
||||
}}
|
||||
/>
|
||||
{/* Toggle sidebar button — top-right of editor, always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
title={sidebarOpen ? 'Masquer le panneau' : 'Afficher le panneau'}
|
||||
className="absolute top-3 right-3 z-20 flex h-7 w-7 items-center justify-center rounded-md border border-border/70 bg-background/90 backdrop-blur-sm shadow-sm text-muted-foreground hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
{sidebarOpen
|
||||
? <PanelRightClose className="h-3.5 w-3.5" />
|
||||
: <PanelRightOpen className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Meta sidebar — collapsible */}
|
||||
{sidebarOpen && (
|
||||
<NoteMetaSidebar
|
||||
note={selected}
|
||||
onPinToggle={handlePinToggle}
|
||||
onArchive={handleArchive}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onEnableHistory={onEnableHistory}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10">
|
||||
<div className="px-10 text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-border/60 bg-background shadow-sm">
|
||||
<FileText className="h-7 w-7 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground/60">
|
||||
{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground/50 max-w-[200px] mx-auto leading-relaxed">
|
||||
{items.length === 0
|
||||
? t('notes.emptyNotebookDesc')
|
||||
: t('notes.selectOrCreateNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!noteToDelete} onOpenChange={() => setNoteToDelete(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
|
||||
{noteToDelete && (
|
||||
<span className="mt-2 block font-medium text-foreground">
|
||||
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNoteToDelete(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (!noteToDelete) return
|
||||
try {
|
||||
await deleteNote(noteToDelete.id, { skipRevalidation: true })
|
||||
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
|
||||
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
|
||||
setNoteToDelete(null)
|
||||
triggerRefresh()
|
||||
toast.success(t('notes.deleted'))
|
||||
} catch {
|
||||
toast.error(t('notes.deleteFailed'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('notes.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user